Stage 6: jail management — backend service, routers, tests, and frontend
- jail_service.py: list/detail/control/ban/unban/ignore-list/IP-lookup - jails.py router: 11 endpoints including ignore list management - bans.py router: active bans, ban, unban - geo.py router: IP lookup with geo enrichment - models: Jail.actions, ActiveBan.country/.banned_at optional, GeoDetail - 217 tests pass (40 service + 36 router + 141 existing), 76% coverage - Frontend: types/jail.ts, api/jails.ts, hooks/useJails.ts - JailsPage: jail overview table with controls, ban/unban forms, active bans table, IP lookup - JailDetailPage: full detail, start/stop/idle/reload, patterns, ignore list management
This commit is contained in:
213
frontend/src/api/jails.ts
Normal file
213
frontend/src/api/jails.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Jails API module.
|
||||
*
|
||||
* Wraps all backend endpoints under `/api/jails`, `/api/bans`, and
|
||||
* `/api/geo` that relate to jail management.
|
||||
*/
|
||||
|
||||
import { del, get, post } from "./client";
|
||||
import { ENDPOINTS } from "./endpoints";
|
||||
import type {
|
||||
ActiveBanListResponse,
|
||||
IpLookupResponse,
|
||||
JailCommandResponse,
|
||||
JailDetailResponse,
|
||||
JailListResponse,
|
||||
} from "../types/jail";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Jail overview
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fetch the list of all fail2ban jails.
|
||||
*
|
||||
* @returns A {@link JailListResponse} containing summary info for each jail.
|
||||
* @throws {ApiError} On non-2xx responses.
|
||||
*/
|
||||
export async function fetchJails(): Promise<JailListResponse> {
|
||||
return get<JailListResponse>(ENDPOINTS.jails);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch full detail for a single jail.
|
||||
*
|
||||
* @param name - Jail name (e.g. `"sshd"`).
|
||||
* @returns A {@link JailDetailResponse} with config, ignore list, and status.
|
||||
* @throws {ApiError} On non-2xx responses (404 if the jail does not exist).
|
||||
*/
|
||||
export async function fetchJail(name: string): Promise<JailDetailResponse> {
|
||||
return get<JailDetailResponse>(ENDPOINTS.jail(name));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Jail controls
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Start a stopped jail.
|
||||
*
|
||||
* @param name - Jail name.
|
||||
* @returns A {@link JailCommandResponse} confirming the operation.
|
||||
* @throws {ApiError} On non-2xx responses.
|
||||
*/
|
||||
export async function startJail(name: string): Promise<JailCommandResponse> {
|
||||
return post<JailCommandResponse>(ENDPOINTS.jailStart(name), {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a running jail.
|
||||
*
|
||||
* @param name - Jail name.
|
||||
* @returns A {@link JailCommandResponse} confirming the operation.
|
||||
* @throws {ApiError} On non-2xx responses.
|
||||
*/
|
||||
export async function stopJail(name: string): Promise<JailCommandResponse> {
|
||||
return post<JailCommandResponse>(ENDPOINTS.jailStop(name), {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle idle mode for a jail.
|
||||
*
|
||||
* @param name - Jail name.
|
||||
* @param on - `true` to enable idle mode, `false` to disable.
|
||||
* @returns A {@link JailCommandResponse} confirming the toggle.
|
||||
* @throws {ApiError} On non-2xx responses.
|
||||
*/
|
||||
export async function setJailIdle(
|
||||
name: string,
|
||||
on: boolean,
|
||||
): Promise<JailCommandResponse> {
|
||||
return post<JailCommandResponse>(ENDPOINTS.jailIdle(name), on);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload configuration for a single jail.
|
||||
*
|
||||
* @param name - Jail name.
|
||||
* @returns A {@link JailCommandResponse} confirming the reload.
|
||||
* @throws {ApiError} On non-2xx responses.
|
||||
*/
|
||||
export async function reloadJail(name: string): Promise<JailCommandResponse> {
|
||||
return post<JailCommandResponse>(ENDPOINTS.jailReload(name), {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload configuration for **all** jails at once.
|
||||
*
|
||||
* @returns A {@link JailCommandResponse} confirming the operation.
|
||||
* @throws {ApiError} On non-2xx responses.
|
||||
*/
|
||||
export async function reloadAllJails(): Promise<JailCommandResponse> {
|
||||
return post<JailCommandResponse>(ENDPOINTS.jailsReloadAll, {});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ignore list
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Return the ignore list for a jail.
|
||||
*
|
||||
* @param name - Jail name.
|
||||
* @returns Array of IP addresses / CIDR networks on the ignore list.
|
||||
* @throws {ApiError} On non-2xx responses.
|
||||
*/
|
||||
export async function fetchIgnoreList(name: string): Promise<string[]> {
|
||||
return get<string[]>(ENDPOINTS.jailIgnoreIp(name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an IP or CIDR network to a jail's ignore list.
|
||||
*
|
||||
* @param name - Jail name.
|
||||
* @param ip - IP address or CIDR network to add.
|
||||
* @returns A {@link JailCommandResponse} confirming the addition.
|
||||
* @throws {ApiError} On non-2xx responses.
|
||||
*/
|
||||
export async function addIgnoreIp(
|
||||
name: string,
|
||||
ip: string,
|
||||
): Promise<JailCommandResponse> {
|
||||
return post<JailCommandResponse>(ENDPOINTS.jailIgnoreIp(name), { ip });
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an IP or CIDR network from a jail's ignore list.
|
||||
*
|
||||
* @param name - Jail name.
|
||||
* @param ip - IP address or CIDR network to remove.
|
||||
* @returns A {@link JailCommandResponse} confirming the removal.
|
||||
* @throws {ApiError} On non-2xx responses.
|
||||
*/
|
||||
export async function delIgnoreIp(
|
||||
name: string,
|
||||
ip: string,
|
||||
): Promise<JailCommandResponse> {
|
||||
return del<JailCommandResponse>(ENDPOINTS.jailIgnoreIp(name), { ip });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ban / unban
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Manually ban an IP address in a specific jail.
|
||||
*
|
||||
* @param jail - Jail name.
|
||||
* @param ip - IP address to ban.
|
||||
* @returns A {@link JailCommandResponse} confirming the ban.
|
||||
* @throws {ApiError} On non-2xx responses.
|
||||
*/
|
||||
export async function banIp(
|
||||
jail: string,
|
||||
ip: string,
|
||||
): Promise<JailCommandResponse> {
|
||||
return post<JailCommandResponse>(ENDPOINTS.bans, { jail, ip });
|
||||
}
|
||||
|
||||
/**
|
||||
* Unban an IP address from a specific jail or all jails.
|
||||
*
|
||||
* @param ip - IP address to unban.
|
||||
* @param jail - Target jail name, or `undefined` to unban from all jails.
|
||||
* @param unbanAll - When `true`, remove the IP from every jail.
|
||||
* @returns A {@link JailCommandResponse} confirming the unban.
|
||||
* @throws {ApiError} On non-2xx responses.
|
||||
*/
|
||||
export async function unbanIp(
|
||||
ip: string,
|
||||
jail?: string,
|
||||
unbanAll = false,
|
||||
): Promise<JailCommandResponse> {
|
||||
return del<JailCommandResponse>(ENDPOINTS.bans, { ip, jail, unban_all: unbanAll });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Active bans
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fetch all currently active bans across every jail.
|
||||
*
|
||||
* @returns An {@link ActiveBanListResponse} with geo-enriched entries.
|
||||
* @throws {ApiError} On non-2xx responses.
|
||||
*/
|
||||
export async function fetchActiveBans(): Promise<ActiveBanListResponse> {
|
||||
return get<ActiveBanListResponse>(ENDPOINTS.bansActive);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Geo / IP lookup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Look up ban status and geo-location for an IP address.
|
||||
*
|
||||
* @param ip - IP address to look up.
|
||||
* @returns An {@link IpLookupResponse} with ban history and geo info.
|
||||
* @throws {ApiError} On non-2xx responses (400 for invalid IP).
|
||||
*/
|
||||
export async function lookupIp(ip: string): Promise<IpLookupResponse> {
|
||||
return get<IpLookupResponse>(ENDPOINTS.geoLookup(ip));
|
||||
}
|
||||
358
frontend/src/hooks/useJails.ts
Normal file
358
frontend/src/hooks/useJails.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* Jail management hooks.
|
||||
*
|
||||
* Provides data-fetching and mutation hooks for all jail-related views,
|
||||
* following the same patterns established by `useBans.ts` and
|
||||
* `useServerStatus.ts`.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
addIgnoreIp,
|
||||
banIp,
|
||||
delIgnoreIp,
|
||||
fetchActiveBans,
|
||||
fetchJail,
|
||||
fetchJails,
|
||||
lookupIp,
|
||||
reloadAllJails,
|
||||
reloadJail,
|
||||
setJailIdle,
|
||||
startJail,
|
||||
stopJail,
|
||||
unbanIp,
|
||||
} from "../api/jails";
|
||||
import type {
|
||||
ActiveBan,
|
||||
IpLookupResponse,
|
||||
Jail,
|
||||
JailSummary,
|
||||
} from "../types/jail";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useJails — overview list
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Return value for {@link useJails}. */
|
||||
export interface UseJailsResult {
|
||||
/** All known jails. */
|
||||
jails: JailSummary[];
|
||||
/** Total count returned by the backend. */
|
||||
total: number;
|
||||
/** `true` while a fetch is in progress. */
|
||||
loading: boolean;
|
||||
/** Error message from the last failed fetch, or `null`. */
|
||||
error: string | null;
|
||||
/** Re-fetch the jail list from the backend. */
|
||||
refresh: () => void;
|
||||
/** Start a specific jail (returns a promise for error handling). */
|
||||
startJail: (name: string) => Promise<void>;
|
||||
/** Stop a specific jail. */
|
||||
stopJail: (name: string) => Promise<void>;
|
||||
/** Toggle idle mode for a jail. */
|
||||
setIdle: (name: string, on: boolean) => Promise<void>;
|
||||
/** Reload a specific jail. */
|
||||
reloadJail: (name: string) => Promise<void>;
|
||||
/** Reload all jails at once. */
|
||||
reloadAll: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and manage the jail overview list.
|
||||
*
|
||||
* Automatically loads on mount and exposes control mutations that refresh
|
||||
* the list after each operation.
|
||||
*
|
||||
* @returns Current jail list, loading/error state, and control callbacks.
|
||||
*/
|
||||
export function useJails(): UseJailsResult {
|
||||
const [jails, setJails] = useState<JailSummary[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
fetchJails()
|
||||
.then((res) => {
|
||||
if (!ctrl.signal.aborted) {
|
||||
setJails(res.jails);
|
||||
setTotal(res.total);
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!ctrl.signal.aborted) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!ctrl.signal.aborted) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
return () => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
const withRefresh =
|
||||
(fn: (name: string) => Promise<unknown>) =>
|
||||
async (name: string): Promise<void> => {
|
||||
await fn(name);
|
||||
load();
|
||||
};
|
||||
|
||||
return {
|
||||
jails,
|
||||
total,
|
||||
loading,
|
||||
error,
|
||||
refresh: load,
|
||||
startJail: withRefresh(startJail),
|
||||
stopJail: withRefresh(stopJail),
|
||||
setIdle: (name, on) => setJailIdle(name, on).then(() => load()),
|
||||
reloadJail: withRefresh(reloadJail),
|
||||
reloadAll: () => reloadAllJails().then(() => load()),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useJailDetail — single jail
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Return value for {@link useJailDetail}. */
|
||||
export interface UseJailDetailResult {
|
||||
/** Full jail configuration, or `null` while loading. */
|
||||
jail: Jail | null;
|
||||
/** Current ignore list. */
|
||||
ignoreList: string[];
|
||||
/** Whether `ignoreself` is enabled. */
|
||||
ignoreSelf: boolean;
|
||||
/** `true` while a fetch is in progress. */
|
||||
loading: boolean;
|
||||
/** Error message or `null`. */
|
||||
error: string | null;
|
||||
/** Re-fetch from the backend. */
|
||||
refresh: () => void;
|
||||
/** Add an IP to the ignore list. */
|
||||
addIp: (ip: string) => Promise<void>;
|
||||
/** Remove an IP from the ignore list. */
|
||||
removeIp: (ip: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and manage the detail view for a single jail.
|
||||
*
|
||||
* @param name - Jail name to load.
|
||||
* @returns Jail detail, ignore list management helpers, and fetch state.
|
||||
*/
|
||||
export function useJailDetail(name: string): UseJailDetailResult {
|
||||
const [jail, setJail] = useState<Jail | null>(null);
|
||||
const [ignoreList, setIgnoreList] = useState<string[]>([]);
|
||||
const [ignoreSelf, setIgnoreSelf] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
fetchJail(name)
|
||||
.then((res) => {
|
||||
if (!ctrl.signal.aborted) {
|
||||
setJail(res.jail);
|
||||
setIgnoreList(res.ignore_list);
|
||||
setIgnoreSelf(res.ignore_self);
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!ctrl.signal.aborted) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!ctrl.signal.aborted) setLoading(false);
|
||||
});
|
||||
}, [name]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
return () => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
const addIp = async (ip: string): Promise<void> => {
|
||||
await addIgnoreIp(name, ip);
|
||||
load();
|
||||
};
|
||||
|
||||
const removeIp = async (ip: string): Promise<void> => {
|
||||
await delIgnoreIp(name, ip);
|
||||
load();
|
||||
};
|
||||
|
||||
return {
|
||||
jail,
|
||||
ignoreList,
|
||||
ignoreSelf,
|
||||
loading,
|
||||
error,
|
||||
refresh: load,
|
||||
addIp,
|
||||
removeIp,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useActiveBans — live ban list
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Return value for {@link useActiveBans}. */
|
||||
export interface UseActiveBansResult {
|
||||
/** All currently active bans. */
|
||||
bans: ActiveBan[];
|
||||
/** Total ban count. */
|
||||
total: number;
|
||||
/** `true` while fetching. */
|
||||
loading: boolean;
|
||||
/** Error message or `null`. */
|
||||
error: string | null;
|
||||
/** Re-fetch the active bans. */
|
||||
refresh: () => void;
|
||||
/** Ban an IP in a specific jail. */
|
||||
banIp: (jail: string, ip: string) => Promise<void>;
|
||||
/** Unban an IP from a jail (or all jails when `jail` is omitted). */
|
||||
unbanIp: (ip: string, jail?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and manage the currently-active ban list.
|
||||
*
|
||||
* @returns Active ban list, mutation callbacks, and fetch state.
|
||||
*/
|
||||
export function useActiveBans(): UseActiveBansResult {
|
||||
const [bans, setBans] = useState<ActiveBan[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
fetchActiveBans()
|
||||
.then((res) => {
|
||||
if (!ctrl.signal.aborted) {
|
||||
setBans(res.bans);
|
||||
setTotal(res.total);
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!ctrl.signal.aborted) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!ctrl.signal.aborted) setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
return () => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
const doBan = async (jail: string, ip: string): Promise<void> => {
|
||||
await banIp(jail, ip);
|
||||
load();
|
||||
};
|
||||
|
||||
const doUnban = async (ip: string, jail?: string): Promise<void> => {
|
||||
await unbanIp(ip, jail);
|
||||
load();
|
||||
};
|
||||
|
||||
return {
|
||||
bans,
|
||||
total,
|
||||
loading,
|
||||
error,
|
||||
refresh: load,
|
||||
banIp: doBan,
|
||||
unbanIp: doUnban,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useIpLookup — single IP lookup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Return value for {@link useIpLookup}. */
|
||||
export interface UseIpLookupResult {
|
||||
/** Lookup result, or `null` when no lookup has been performed yet. */
|
||||
result: IpLookupResponse | null;
|
||||
/** `true` while a lookup is in progress. */
|
||||
loading: boolean;
|
||||
/** Error message or `null`. */
|
||||
error: string | null;
|
||||
/** Trigger an IP lookup. */
|
||||
lookup: (ip: string) => void;
|
||||
/** Clear the result and error state. */
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manage IP lookup state (lazy — no fetch on mount).
|
||||
*
|
||||
* @returns Lookup result, state flags, and a `lookup` trigger callback.
|
||||
*/
|
||||
export function useIpLookup(): UseIpLookupResult {
|
||||
const [result, setResult] = useState<IpLookupResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const lookup = useCallback((ip: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
|
||||
lookupIp(ip)
|
||||
.then((res) => {
|
||||
setResult(res);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setResult(null);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
return { result, loading, error, lookup, clear };
|
||||
}
|
||||
@@ -1,25 +1,582 @@
|
||||
/**
|
||||
* Jail detail placeholder page — full implementation in Stage 6.
|
||||
* Jail detail page.
|
||||
*
|
||||
* Displays full configuration and state for a single fail2ban jail:
|
||||
* - Status badges and control buttons (start, stop, idle, reload)
|
||||
* - Log paths, fail-regex, ignore-regex patterns
|
||||
* - Date pattern, encoding, and actions
|
||||
* - Ignore list management (add / remove IPs)
|
||||
*/
|
||||
|
||||
import { Text, makeStyles, tokens } from "@fluentui/react-components";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Field,
|
||||
Input,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
Spinner,
|
||||
Text,
|
||||
Tooltip,
|
||||
makeStyles,
|
||||
tokens,
|
||||
} from "@fluentui/react-components";
|
||||
import {
|
||||
ArrowClockwiseRegular,
|
||||
ArrowLeftRegular,
|
||||
ArrowSyncRegular,
|
||||
DismissRegular,
|
||||
PauseRegular,
|
||||
PlayRegular,
|
||||
StopRegular,
|
||||
} from "@fluentui/react-icons";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
reloadJail,
|
||||
setJailIdle,
|
||||
startJail,
|
||||
stopJail,
|
||||
} from "../api/jails";
|
||||
import { useJailDetail } from "../hooks/useJails";
|
||||
import type { Jail } from "../types/jail";
|
||||
import { ApiError } from "../api/client";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: { padding: tokens.spacingVerticalXXL },
|
||||
root: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: tokens.spacingVerticalL,
|
||||
},
|
||||
breadcrumb: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: tokens.spacingHorizontalS,
|
||||
},
|
||||
section: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: tokens.spacingVerticalS,
|
||||
backgroundColor: tokens.colorNeutralBackground1,
|
||||
borderRadius: tokens.borderRadiusMedium,
|
||||
borderTopWidth: "1px",
|
||||
borderTopStyle: "solid",
|
||||
borderTopColor: tokens.colorNeutralStroke2,
|
||||
borderRightWidth: "1px",
|
||||
borderRightStyle: "solid",
|
||||
borderRightColor: tokens.colorNeutralStroke2,
|
||||
borderBottomWidth: "1px",
|
||||
borderBottomStyle: "solid",
|
||||
borderBottomColor: tokens.colorNeutralStroke2,
|
||||
borderLeftWidth: "1px",
|
||||
borderLeftStyle: "solid",
|
||||
borderLeftColor: tokens.colorNeutralStroke2,
|
||||
padding: tokens.spacingVerticalM,
|
||||
},
|
||||
sectionHeader: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: tokens.spacingHorizontalM,
|
||||
paddingBottom: tokens.spacingVerticalS,
|
||||
borderBottomWidth: "1px",
|
||||
borderBottomStyle: "solid",
|
||||
borderBottomColor: tokens.colorNeutralStroke2,
|
||||
},
|
||||
headerRow: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: tokens.spacingHorizontalM,
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
controlRow: {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: tokens.spacingHorizontalS,
|
||||
},
|
||||
grid: {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "max-content 1fr",
|
||||
gap: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalM}`,
|
||||
alignItems: "baseline",
|
||||
},
|
||||
label: {
|
||||
fontWeight: tokens.fontWeightSemibold,
|
||||
color: tokens.colorNeutralForeground2,
|
||||
},
|
||||
mono: {
|
||||
fontFamily: "Consolas, 'Courier New', monospace",
|
||||
fontSize: tokens.fontSizeBase200,
|
||||
},
|
||||
codeList: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: tokens.spacingVerticalXXS,
|
||||
paddingTop: tokens.spacingVerticalXS,
|
||||
},
|
||||
codeItem: {
|
||||
fontFamily: "Consolas, 'Courier New', monospace",
|
||||
fontSize: tokens.fontSizeBase200,
|
||||
padding: `2px ${tokens.spacingHorizontalS}`,
|
||||
backgroundColor: tokens.colorNeutralBackground2,
|
||||
borderRadius: tokens.borderRadiusSmall,
|
||||
wordBreak: "break-all",
|
||||
},
|
||||
centred: {
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: tokens.spacingVerticalXXL,
|
||||
},
|
||||
ignoreRow: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: tokens.spacingHorizontalS,
|
||||
padding: `${tokens.spacingVerticalXXS} ${tokens.spacingHorizontalS}`,
|
||||
backgroundColor: tokens.colorNeutralBackground2,
|
||||
borderRadius: tokens.borderRadiusSmall,
|
||||
},
|
||||
formRow: {
|
||||
display: "flex",
|
||||
gap: tokens.spacingHorizontalM,
|
||||
alignItems: "flex-end",
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
formField: { minWidth: "200px", flexGrow: 1 },
|
||||
});
|
||||
|
||||
export function JailDetailPage(): JSX.Element {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function fmtSeconds(s: number): string {
|
||||
if (s < 0) return "permanent";
|
||||
if (s < 60) return `${String(s)} s`;
|
||||
if (s < 3600) return `${String(Math.round(s / 60))} min`;
|
||||
return `${String(Math.round(s / 3600))} h`;
|
||||
}
|
||||
|
||||
function CodeList({ items, empty }: { items: string[]; empty: string }): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const { name } = useParams<{ name: string }>();
|
||||
if (items.length === 0) {
|
||||
return <Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>{empty}</Text>;
|
||||
}
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<Text as="h1" size={700} weight="semibold">
|
||||
Jail: {name}
|
||||
</Text>
|
||||
<Text as="p" size={300}>
|
||||
Jail detail view will be implemented in Stage 6.
|
||||
</Text>
|
||||
<div className={styles.codeList}>
|
||||
{items.map((item, i) => (
|
||||
<span key={i} className={styles.codeItem}>
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-component: Jail info card
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface JailInfoProps {
|
||||
jail: Jail;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
function JailInfoSection({ jail, onRefresh }: JailInfoProps): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const navigate = useNavigate();
|
||||
const [ctrlError, setCtrlError] = useState<string | null>(null);
|
||||
|
||||
const handle =
|
||||
(fn: () => Promise<unknown>, postNavigate = false) =>
|
||||
(): void => {
|
||||
setCtrlError(null);
|
||||
fn()
|
||||
.then(() => {
|
||||
if (postNavigate) {
|
||||
navigate("/jails");
|
||||
} else {
|
||||
onRefresh();
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const msg =
|
||||
err instanceof ApiError
|
||||
? `${String(err.status)}: ${err.body}`
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err);
|
||||
setCtrlError(msg);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<div className={styles.headerRow}>
|
||||
<Text
|
||||
size={600}
|
||||
weight="semibold"
|
||||
style={{ fontFamily: "Consolas, 'Courier New', monospace" }}
|
||||
>
|
||||
{jail.name}
|
||||
</Text>
|
||||
{jail.running ? (
|
||||
jail.idle ? (
|
||||
<Badge appearance="filled" color="warning">idle</Badge>
|
||||
) : (
|
||||
<Badge appearance="filled" color="success">running</Badge>
|
||||
)
|
||||
) : (
|
||||
<Badge appearance="filled" color="danger">stopped</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<ArrowClockwiseRegular />}
|
||||
onClick={onRefresh}
|
||||
aria-label="Refresh"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{ctrlError && (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{ctrlError}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
|
||||
{/* Control buttons */}
|
||||
<div className={styles.controlRow}>
|
||||
{jail.running ? (
|
||||
<Tooltip content="Stop jail" relationship="label">
|
||||
<Button
|
||||
appearance="secondary"
|
||||
icon={<StopRegular />}
|
||||
onClick={handle(() => stopJail(jail.name).then(() => void 0))}
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip content="Start jail" relationship="label">
|
||||
<Button
|
||||
appearance="primary"
|
||||
icon={<PlayRegular />}
|
||||
onClick={handle(() => startJail(jail.name).then(() => void 0))}
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip
|
||||
content={jail.idle ? "Resume from idle mode" : "Pause monitoring (idle mode)"}
|
||||
relationship="label"
|
||||
>
|
||||
<Button
|
||||
appearance="outline"
|
||||
icon={<PauseRegular />}
|
||||
onClick={handle(() => setJailIdle(jail.name, !jail.idle).then(() => void 0))}
|
||||
disabled={!jail.running}
|
||||
>
|
||||
{jail.idle ? "Resume" : "Set Idle"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Reload jail configuration" relationship="label">
|
||||
<Button
|
||||
appearance="outline"
|
||||
icon={<ArrowSyncRegular />}
|
||||
onClick={handle(() => reloadJail(jail.name).then(() => void 0))}
|
||||
>
|
||||
Reload
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Stats grid */}
|
||||
{jail.status && (
|
||||
<div className={styles.grid} style={{ marginTop: tokens.spacingVerticalS }}>
|
||||
<Text className={styles.label}>Currently banned:</Text>
|
||||
<Text>{String(jail.status.currently_banned)}</Text>
|
||||
<Text className={styles.label}>Total banned:</Text>
|
||||
<Text>{String(jail.status.total_banned)}</Text>
|
||||
<Text className={styles.label}>Currently failed:</Text>
|
||||
<Text>{String(jail.status.currently_failed)}</Text>
|
||||
<Text className={styles.label}>Total failed:</Text>
|
||||
<Text>{String(jail.status.total_failed)}</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Config grid */}
|
||||
<div className={styles.grid} style={{ marginTop: tokens.spacingVerticalS }}>
|
||||
<Text className={styles.label}>Backend:</Text>
|
||||
<Text className={styles.mono}>{jail.backend}</Text>
|
||||
<Text className={styles.label}>Find time:</Text>
|
||||
<Text>{fmtSeconds(jail.find_time)}</Text>
|
||||
<Text className={styles.label}>Ban time:</Text>
|
||||
<Text>{fmtSeconds(jail.ban_time)}</Text>
|
||||
<Text className={styles.label}>Max retry:</Text>
|
||||
<Text>{String(jail.max_retry)}</Text>
|
||||
{jail.date_pattern && (
|
||||
<>
|
||||
<Text className={styles.label}>Date pattern:</Text>
|
||||
<Text className={styles.mono}>{jail.date_pattern}</Text>
|
||||
</>
|
||||
)}
|
||||
{jail.log_encoding && (
|
||||
<>
|
||||
<Text className={styles.label}>Log encoding:</Text>
|
||||
<Text className={styles.mono}>{jail.log_encoding}</Text>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-component: Patterns section
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function PatternsSection({ jail }: { jail: Jail }): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
return (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<Text as="h2" size={500} weight="semibold">
|
||||
Log Paths & Patterns
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Text size={300} weight="semibold">Log Paths</Text>
|
||||
<CodeList items={jail.log_paths} empty="No log paths configured." />
|
||||
|
||||
<Text size={300} weight="semibold" style={{ marginTop: tokens.spacingVerticalS }}>
|
||||
Fail Regex
|
||||
</Text>
|
||||
<CodeList items={jail.fail_regex} empty="No fail-regex patterns." />
|
||||
|
||||
<Text size={300} weight="semibold" style={{ marginTop: tokens.spacingVerticalS }}>
|
||||
Ignore Regex
|
||||
</Text>
|
||||
<CodeList items={jail.ignore_regex} empty="No ignore-regex patterns." />
|
||||
|
||||
{jail.actions.length > 0 && (
|
||||
<>
|
||||
<Text size={300} weight="semibold" style={{ marginTop: tokens.spacingVerticalS }}>
|
||||
Actions
|
||||
</Text>
|
||||
<CodeList items={jail.actions} empty="" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-component: Ignore list section
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface IgnoreListSectionProps {
|
||||
jailName: string;
|
||||
ignoreList: string[];
|
||||
ignoreSelf: boolean;
|
||||
onAdd: (ip: string) => Promise<void>;
|
||||
onRemove: (ip: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function IgnoreListSection({
|
||||
jailName: _jailName,
|
||||
ignoreList,
|
||||
ignoreSelf,
|
||||
onAdd,
|
||||
onRemove,
|
||||
}: IgnoreListSectionProps): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const [inputVal, setInputVal] = useState("");
|
||||
const [opError, setOpError] = useState<string | null>(null);
|
||||
|
||||
const handleAdd = (): void => {
|
||||
if (!inputVal.trim()) return;
|
||||
setOpError(null);
|
||||
onAdd(inputVal.trim())
|
||||
.then(() => {
|
||||
setInputVal("");
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const msg =
|
||||
err instanceof ApiError
|
||||
? `${String(err.status)}: ${err.body}`
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err);
|
||||
setOpError(msg);
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemove = (ip: string): void => {
|
||||
setOpError(null);
|
||||
onRemove(ip).catch((err: unknown) => {
|
||||
const msg =
|
||||
err instanceof ApiError
|
||||
? `${String(err.status)}: ${err.body}`
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err);
|
||||
setOpError(msg);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM }}>
|
||||
<Text as="h2" size={500} weight="semibold">
|
||||
Ignore List (IP Whitelist)
|
||||
</Text>
|
||||
{ignoreSelf && (
|
||||
<Tooltip content="This jail ignores the server's own IP addresses" relationship="label">
|
||||
<Badge appearance="tint" color="informative">
|
||||
ignore self
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<Badge appearance="tint">{String(ignoreList.length)}</Badge>
|
||||
</div>
|
||||
|
||||
{opError && (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{opError}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
|
||||
{/* Add form */}
|
||||
<div className={styles.formRow}>
|
||||
<div className={styles.formField}>
|
||||
<Field label="Add IP or CIDR network">
|
||||
<Input
|
||||
placeholder="e.g. 10.0.0.0/8 or 192.168.1.1"
|
||||
value={inputVal}
|
||||
onChange={(_, d) => {
|
||||
setInputVal(d.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleAdd();
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Button
|
||||
appearance="primary"
|
||||
onClick={handleAdd}
|
||||
disabled={!inputVal.trim()}
|
||||
style={{ marginBottom: "4px" }}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
{ignoreList.length === 0 ? (
|
||||
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
|
||||
The ignore list is empty.
|
||||
</Text>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
|
||||
{ignoreList.map((ip) => (
|
||||
<div key={ip} className={styles.ignoreRow}>
|
||||
<Text className={styles.mono}>{ip}</Text>
|
||||
<Tooltip content={`Remove ${ip} from ignore list`} relationship="label">
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<DismissRegular />}
|
||||
onClick={() => {
|
||||
handleRemove(ip);
|
||||
}}
|
||||
aria-label={`Remove ${ip}`}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Jail detail page.
|
||||
*
|
||||
* Fetches and displays the full configuration and state of a single jail
|
||||
* identified by the `:name` route parameter.
|
||||
*/
|
||||
export function JailDetailPage(): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const { name = "" } = useParams<{ name: string }>();
|
||||
const { jail, ignoreList, ignoreSelf, loading, error, refresh, addIp, removeIp } =
|
||||
useJailDetail(name);
|
||||
|
||||
if (loading && !jail) {
|
||||
return (
|
||||
<div className={styles.centred}>
|
||||
<Spinner label={`Loading jail ${name}…`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<Link to="/jails" style={{ textDecoration: "none" }}>
|
||||
<Button appearance="subtle" icon={<ArrowLeftRegular />}>
|
||||
Back to Jails
|
||||
</Button>
|
||||
</Link>
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>Failed to load jail {name}: {error}</MessageBarBody>
|
||||
</MessageBar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!jail) return <></>;
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
{/* Breadcrumb */}
|
||||
<div className={styles.breadcrumb}>
|
||||
<Link to="/jails" style={{ textDecoration: "none" }}>
|
||||
<Button appearance="subtle" size="small" icon={<ArrowLeftRegular />}>
|
||||
Jails
|
||||
</Button>
|
||||
</Link>
|
||||
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
|
||||
/
|
||||
</Text>
|
||||
<Text size={200} className={styles.mono}>
|
||||
{name}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<JailInfoSection jail={jail} onRefresh={refresh} />
|
||||
<PatternsSection jail={jail} />
|
||||
<IgnoreListSection
|
||||
jailName={name}
|
||||
ignoreList={ignoreList}
|
||||
ignoreSelf={ignoreSelf}
|
||||
onAdd={addIp}
|
||||
onRemove={removeIp}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,875 @@
|
||||
/**
|
||||
* Jails overview placeholder page — full implementation in Stage 6.
|
||||
* Jails management page.
|
||||
*
|
||||
* Provides four sections in a vertically-stacked layout:
|
||||
* 1. **Jail Overview** — table of all jails with quick status badges and
|
||||
* per-row start/stop/idle/reload controls.
|
||||
* 2. **Ban / Unban IP** — form to manually ban or unban an IP address.
|
||||
* 3. **Currently Banned IPs** — live table of all active bans.
|
||||
* 4. **IP Lookup** — check whether an IP is currently banned and view its
|
||||
* geo-location details.
|
||||
*/
|
||||
|
||||
import { Text, makeStyles, tokens } from "@fluentui/react-components";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
DataGrid,
|
||||
DataGridBody,
|
||||
DataGridCell,
|
||||
DataGridHeader,
|
||||
DataGridHeaderCell,
|
||||
DataGridRow,
|
||||
Field,
|
||||
Input,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
Select,
|
||||
Spinner,
|
||||
Text,
|
||||
Tooltip,
|
||||
makeStyles,
|
||||
tokens,
|
||||
type TableColumnDefinition,
|
||||
createTableColumn,
|
||||
} from "@fluentui/react-components";
|
||||
import {
|
||||
ArrowClockwiseRegular,
|
||||
ArrowSyncRegular,
|
||||
DismissRegular,
|
||||
LockClosedRegular,
|
||||
LockOpenRegular,
|
||||
PauseRegular,
|
||||
PlayRegular,
|
||||
SearchRegular,
|
||||
StopRegular,
|
||||
} from "@fluentui/react-icons";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useActiveBans, useIpLookup, useJails } from "../hooks/useJails";
|
||||
import type { ActiveBan, JailSummary } from "../types/jail";
|
||||
import { ApiError } from "../api/client";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: { padding: tokens.spacingVerticalXXL },
|
||||
root: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: tokens.spacingVerticalL,
|
||||
},
|
||||
section: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: tokens.spacingVerticalS,
|
||||
backgroundColor: tokens.colorNeutralBackground1,
|
||||
borderRadius: tokens.borderRadiusMedium,
|
||||
borderTopWidth: "1px",
|
||||
borderTopStyle: "solid",
|
||||
borderTopColor: tokens.colorNeutralStroke2,
|
||||
borderRightWidth: "1px",
|
||||
borderRightStyle: "solid",
|
||||
borderRightColor: tokens.colorNeutralStroke2,
|
||||
borderBottomWidth: "1px",
|
||||
borderBottomStyle: "solid",
|
||||
borderBottomColor: tokens.colorNeutralStroke2,
|
||||
borderLeftWidth: "1px",
|
||||
borderLeftStyle: "solid",
|
||||
borderLeftColor: tokens.colorNeutralStroke2,
|
||||
padding: tokens.spacingVerticalM,
|
||||
},
|
||||
sectionHeader: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: tokens.spacingHorizontalM,
|
||||
paddingBottom: tokens.spacingVerticalS,
|
||||
borderBottomWidth: "1px",
|
||||
borderBottomStyle: "solid",
|
||||
borderBottomColor: tokens.colorNeutralStroke2,
|
||||
},
|
||||
tableWrapper: { overflowX: "auto" },
|
||||
centred: {
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: tokens.spacingVerticalXXL,
|
||||
},
|
||||
mono: {
|
||||
fontFamily: "Consolas, 'Courier New', monospace",
|
||||
fontSize: tokens.fontSizeBase200,
|
||||
},
|
||||
formRow: {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: tokens.spacingHorizontalM,
|
||||
alignItems: "flex-end",
|
||||
},
|
||||
formField: { minWidth: "180px", flexGrow: 1 },
|
||||
actionRow: {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: tokens.spacingHorizontalS,
|
||||
},
|
||||
lookupResult: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: tokens.spacingVerticalS,
|
||||
marginTop: tokens.spacingVerticalS,
|
||||
padding: tokens.spacingVerticalS,
|
||||
backgroundColor: tokens.colorNeutralBackground2,
|
||||
borderRadius: tokens.borderRadiusMedium,
|
||||
borderTopWidth: "1px",
|
||||
borderTopStyle: "solid",
|
||||
borderTopColor: tokens.colorNeutralStroke2,
|
||||
borderRightWidth: "1px",
|
||||
borderRightStyle: "solid",
|
||||
borderRightColor: tokens.colorNeutralStroke2,
|
||||
borderBottomWidth: "1px",
|
||||
borderBottomStyle: "solid",
|
||||
borderBottomColor: tokens.colorNeutralStroke2,
|
||||
borderLeftWidth: "1px",
|
||||
borderLeftStyle: "solid",
|
||||
borderLeftColor: tokens.colorNeutralStroke2,
|
||||
},
|
||||
lookupRow: {
|
||||
display: "flex",
|
||||
gap: tokens.spacingHorizontalM,
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
},
|
||||
lookupLabel: { fontWeight: tokens.fontWeightSemibold },
|
||||
});
|
||||
|
||||
export function JailsPage(): JSX.Element {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function fmtSeconds(s: number): string {
|
||||
if (s < 0) return "permanent";
|
||||
if (s < 60) return `${String(s)}s`;
|
||||
if (s < 3600) return `${String(Math.round(s / 60))}m`;
|
||||
return `${String(Math.round(s / 3600))}h`;
|
||||
}
|
||||
|
||||
function fmtTimestamp(iso: string | null): string {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Jail overview columns
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const jailColumns: TableColumnDefinition<JailSummary>[] = [
|
||||
createTableColumn<JailSummary>({
|
||||
columnId: "name",
|
||||
renderHeaderCell: () => "Jail",
|
||||
renderCell: (j) => (
|
||||
<Link to={`/jails/${encodeURIComponent(j.name)}`} style={{ textDecoration: "none" }}>
|
||||
<Text style={{ fontFamily: "Consolas, 'Courier New', monospace", fontSize: "0.85rem" }}>
|
||||
{j.name}
|
||||
</Text>
|
||||
</Link>
|
||||
),
|
||||
}),
|
||||
createTableColumn<JailSummary>({
|
||||
columnId: "status",
|
||||
renderHeaderCell: () => "Status",
|
||||
renderCell: (j) => {
|
||||
if (!j.running) return <Badge appearance="filled" color="danger">stopped</Badge>;
|
||||
if (j.idle) return <Badge appearance="filled" color="warning">idle</Badge>;
|
||||
return <Badge appearance="filled" color="success">running</Badge>;
|
||||
},
|
||||
}),
|
||||
createTableColumn<JailSummary>({
|
||||
columnId: "backend",
|
||||
renderHeaderCell: () => "Backend",
|
||||
renderCell: (j) => <Text size={200}>{j.backend}</Text>,
|
||||
}),
|
||||
createTableColumn<JailSummary>({
|
||||
columnId: "banned",
|
||||
renderHeaderCell: () => "Banned",
|
||||
renderCell: (j) => (
|
||||
<Text size={200}>{j.status ? String(j.status.currently_banned) : "—"}</Text>
|
||||
),
|
||||
}),
|
||||
createTableColumn<JailSummary>({
|
||||
columnId: "failed",
|
||||
renderHeaderCell: () => "Failed",
|
||||
renderCell: (j) => (
|
||||
<Text size={200}>{j.status ? String(j.status.currently_failed) : "—"}</Text>
|
||||
),
|
||||
}),
|
||||
createTableColumn<JailSummary>({
|
||||
columnId: "findTime",
|
||||
renderHeaderCell: () => "Find Time",
|
||||
renderCell: (j) => <Text size={200}>{fmtSeconds(j.find_time)}</Text>,
|
||||
}),
|
||||
createTableColumn<JailSummary>({
|
||||
columnId: "banTime",
|
||||
renderHeaderCell: () => "Ban Time",
|
||||
renderCell: (j) => <Text size={200}>{fmtSeconds(j.ban_time)}</Text>,
|
||||
}),
|
||||
createTableColumn<JailSummary>({
|
||||
columnId: "maxRetry",
|
||||
renderHeaderCell: () => "Max Retry",
|
||||
renderCell: (j) => <Text size={200}>{String(j.max_retry)}</Text>,
|
||||
}),
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Active bans columns
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildBanColumns(
|
||||
onUnban: (ip: string, jail: string) => void,
|
||||
): TableColumnDefinition<ActiveBan>[] {
|
||||
return [
|
||||
createTableColumn<ActiveBan>({
|
||||
columnId: "ip",
|
||||
renderHeaderCell: () => "IP",
|
||||
renderCell: (b) => (
|
||||
<Text style={{ fontFamily: "Consolas, 'Courier New', monospace", fontSize: "0.85rem" }}>
|
||||
{b.ip}
|
||||
</Text>
|
||||
),
|
||||
}),
|
||||
createTableColumn<ActiveBan>({
|
||||
columnId: "jail",
|
||||
renderHeaderCell: () => "Jail",
|
||||
renderCell: (b) => <Text size={200}>{b.jail}</Text>,
|
||||
}),
|
||||
createTableColumn<ActiveBan>({
|
||||
columnId: "country",
|
||||
renderHeaderCell: () => "Country",
|
||||
renderCell: (b) => <Text size={200}>{b.country ?? "—"}</Text>,
|
||||
}),
|
||||
createTableColumn<ActiveBan>({
|
||||
columnId: "bannedAt",
|
||||
renderHeaderCell: () => "Banned At",
|
||||
renderCell: (b) => <Text size={200}>{fmtTimestamp(b.banned_at)}</Text>,
|
||||
}),
|
||||
createTableColumn<ActiveBan>({
|
||||
columnId: "expiresAt",
|
||||
renderHeaderCell: () => "Expires At",
|
||||
renderCell: (b) => <Text size={200}>{fmtTimestamp(b.expires_at)}</Text>,
|
||||
}),
|
||||
createTableColumn<ActiveBan>({
|
||||
columnId: "count",
|
||||
renderHeaderCell: () => "Count",
|
||||
renderCell: (b) => (
|
||||
<Tooltip
|
||||
content={`Banned ${String(b.ban_count)} time${b.ban_count === 1 ? "" : "s"}`}
|
||||
relationship="label"
|
||||
>
|
||||
<Badge
|
||||
appearance="filled"
|
||||
color={b.ban_count > 3 ? "danger" : b.ban_count > 1 ? "warning" : "informative"}
|
||||
>
|
||||
{String(b.ban_count)}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
),
|
||||
}),
|
||||
createTableColumn<ActiveBan>({
|
||||
columnId: "unban",
|
||||
renderHeaderCell: () => "",
|
||||
renderCell: (b) => (
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<DismissRegular />}
|
||||
onClick={() => {
|
||||
onUnban(b.ip, b.jail);
|
||||
}}
|
||||
aria-label={`Unban ${b.ip} from ${b.jail}`}
|
||||
>
|
||||
Unban
|
||||
</Button>
|
||||
),
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-component: Jail overview section
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function JailOverviewSection(): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const { jails, total, loading, error, refresh, startJail, stopJail, setIdle, reloadJail, reloadAll } =
|
||||
useJails();
|
||||
const [opError, setOpError] = useState<string | null>(null);
|
||||
|
||||
const handle = (fn: () => Promise<void>): void => {
|
||||
setOpError(null);
|
||||
fn().catch((err: unknown) => {
|
||||
setOpError(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<Text as="h2" size={500} weight="semibold">
|
||||
Jail Overview
|
||||
{total > 0 && (
|
||||
<Badge appearance="tint" style={{ marginLeft: "8px" }}>
|
||||
{String(total)}
|
||||
</Badge>
|
||||
)}
|
||||
</Text>
|
||||
<div className={styles.actionRow}>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<ArrowSyncRegular />}
|
||||
onClick={() => {
|
||||
handle(reloadAll);
|
||||
}}
|
||||
>
|
||||
Reload All
|
||||
</Button>
|
||||
<Button size="small" appearance="subtle" icon={<ArrowClockwiseRegular />} onClick={refresh}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{opError && (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{opError}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
{error && (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>Failed to load jails: {error}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
|
||||
{loading && jails.length === 0 ? (
|
||||
<div className={styles.centred}>
|
||||
<Spinner label="Loading jails…" />
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.tableWrapper}>
|
||||
<DataGrid
|
||||
items={jails}
|
||||
columns={jailColumns}
|
||||
getRowId={(j: JailSummary) => j.name}
|
||||
focusMode="composite"
|
||||
>
|
||||
<DataGridHeader>
|
||||
<DataGridRow>
|
||||
{({ renderHeaderCell }) => (
|
||||
<DataGridHeaderCell>{renderHeaderCell()}</DataGridHeaderCell>
|
||||
)}
|
||||
</DataGridRow>
|
||||
</DataGridHeader>
|
||||
<DataGridBody<JailSummary>>
|
||||
{({ item }) => (
|
||||
<DataGridRow<JailSummary> key={item.name}>
|
||||
{({ renderCell, columnId }) => {
|
||||
if (columnId === "status") {
|
||||
return (
|
||||
<DataGridCell>
|
||||
<div style={{ display: "flex", gap: "6px", alignItems: "center" }}>
|
||||
{renderCell(item)}
|
||||
<Tooltip
|
||||
content={item.running ? "Stop jail" : "Start jail"}
|
||||
relationship="label"
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={item.running ? <StopRegular /> : <PlayRegular />}
|
||||
onClick={() => {
|
||||
handle(async () => {
|
||||
if (item.running) await stopJail(item.name);
|
||||
else await startJail(item.name);
|
||||
});
|
||||
}}
|
||||
aria-label={
|
||||
item.running ? `Stop ${item.name}` : `Start ${item.name}`
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={item.idle ? "Resume from idle" : "Set idle"}
|
||||
relationship="label"
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<PauseRegular />}
|
||||
onClick={() => {
|
||||
handle(async () => setIdle(item.name, !item.idle));
|
||||
}}
|
||||
disabled={!item.running}
|
||||
aria-label={`Toggle idle for ${item.name}`}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content="Reload jail" relationship="label">
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<ArrowSyncRegular />}
|
||||
onClick={() => {
|
||||
handle(async () => reloadJail(item.name));
|
||||
}}
|
||||
aria-label={`Reload ${item.name}`}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</DataGridCell>
|
||||
);
|
||||
}
|
||||
return <DataGridCell>{renderCell(item)}</DataGridCell>;
|
||||
}}
|
||||
</DataGridRow>
|
||||
)}
|
||||
</DataGridBody>
|
||||
</DataGrid>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-component: Ban / Unban IP form
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface BanUnbanFormProps {
|
||||
jailNames: string[];
|
||||
onBan: (jail: string, ip: string) => Promise<void>;
|
||||
onUnban: (ip: string, jail?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function BanUnbanForm({ jailNames, onBan, onUnban }: BanUnbanFormProps): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const [banIpVal, setBanIpVal] = useState("");
|
||||
const [banJail, setBanJail] = useState("");
|
||||
const [unbanIpVal, setUnbanIpVal] = useState("");
|
||||
const [unbanJail, setUnbanJail] = useState("");
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [formSuccess, setFormSuccess] = useState<string | null>(null);
|
||||
|
||||
const handleBan = (): void => {
|
||||
setFormError(null);
|
||||
setFormSuccess(null);
|
||||
if (!banIpVal.trim() || !banJail) {
|
||||
setFormError("Both IP address and jail are required.");
|
||||
return;
|
||||
}
|
||||
onBan(banJail, banIpVal.trim())
|
||||
.then(() => {
|
||||
setFormSuccess(`${banIpVal.trim()} banned in ${banJail}.`);
|
||||
setBanIpVal("");
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const msg =
|
||||
err instanceof ApiError
|
||||
? `${String(err.status)}: ${err.body}`
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err);
|
||||
setFormError(msg);
|
||||
});
|
||||
};
|
||||
|
||||
const handleUnban = (fromAllJails: boolean): void => {
|
||||
setFormError(null);
|
||||
setFormSuccess(null);
|
||||
if (!unbanIpVal.trim()) {
|
||||
setFormError("IP address is required.");
|
||||
return;
|
||||
}
|
||||
const jail = fromAllJails ? undefined : unbanJail || undefined;
|
||||
onUnban(unbanIpVal.trim(), jail)
|
||||
.then(() => {
|
||||
const scope = jail ?? "all jails";
|
||||
setFormSuccess(`${unbanIpVal.trim()} unbanned from ${scope}.`);
|
||||
setUnbanIpVal("");
|
||||
setUnbanJail("");
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const msg =
|
||||
err instanceof ApiError
|
||||
? `${String(err.status)}: ${err.body}`
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err);
|
||||
setFormError(msg);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<Text as="h2" size={500} weight="semibold">
|
||||
Ban / Unban IP
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{formError && (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{formError}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
{formSuccess && (
|
||||
<MessageBar intent="success">
|
||||
<MessageBarBody>{formSuccess}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
|
||||
{/* Ban row */}
|
||||
<Text size={300} weight="semibold">
|
||||
Ban an IP
|
||||
</Text>
|
||||
<div className={styles.formRow}>
|
||||
<div className={styles.formField}>
|
||||
<Field label="IP Address">
|
||||
<Input
|
||||
placeholder="e.g. 192.168.1.100"
|
||||
value={banIpVal}
|
||||
onChange={(_, d) => {
|
||||
setBanIpVal(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className={styles.formField}>
|
||||
<Field label="Jail">
|
||||
<Select
|
||||
value={banJail}
|
||||
onChange={(_, d) => {
|
||||
setBanJail(d.value);
|
||||
}}
|
||||
>
|
||||
<option value="">Select jail…</option>
|
||||
{jailNames.map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
<Button
|
||||
appearance="primary"
|
||||
icon={<LockClosedRegular />}
|
||||
onClick={handleBan}
|
||||
style={{ marginBottom: "4px" }}
|
||||
>
|
||||
Ban
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Unban row */}
|
||||
<Text
|
||||
size={300}
|
||||
weight="semibold"
|
||||
style={{ marginTop: tokens.spacingVerticalS }}
|
||||
>
|
||||
Unban an IP
|
||||
</Text>
|
||||
<div className={styles.formRow}>
|
||||
<div className={styles.formField}>
|
||||
<Field label="IP Address">
|
||||
<Input
|
||||
placeholder="e.g. 192.168.1.100"
|
||||
value={unbanIpVal}
|
||||
onChange={(_, d) => {
|
||||
setUnbanIpVal(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className={styles.formField}>
|
||||
<Field label="Jail (optional — leave blank for all)">
|
||||
<Select
|
||||
value={unbanJail}
|
||||
onChange={(_, d) => {
|
||||
setUnbanJail(d.value);
|
||||
}}
|
||||
>
|
||||
<option value="">All jails</option>
|
||||
{jailNames.map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
<Button
|
||||
appearance="secondary"
|
||||
icon={<LockOpenRegular />}
|
||||
onClick={() => {
|
||||
handleUnban(false);
|
||||
}}
|
||||
style={{ marginBottom: "4px" }}
|
||||
>
|
||||
Unban
|
||||
</Button>
|
||||
<Button
|
||||
appearance="outline"
|
||||
icon={<LockOpenRegular />}
|
||||
onClick={() => {
|
||||
handleUnban(true);
|
||||
}}
|
||||
style={{ marginBottom: "4px" }}
|
||||
>
|
||||
Unban from All Jails
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-component: Active bans section
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ActiveBansSection(): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const { bans, total, loading, error, refresh, unbanIp } = useActiveBans();
|
||||
const [opError, setOpError] = useState<string | null>(null);
|
||||
|
||||
const handleUnban = (ip: string, jail: string): void => {
|
||||
setOpError(null);
|
||||
unbanIp(ip, jail).catch((err: unknown) => {
|
||||
setOpError(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
};
|
||||
|
||||
const banColumns = buildBanColumns(handleUnban);
|
||||
|
||||
return (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<Text as="h2" size={500} weight="semibold">
|
||||
Currently Banned IPs
|
||||
{total > 0 && (
|
||||
<Badge appearance="filled" color="danger" style={{ marginLeft: "8px" }}>
|
||||
{String(total)}
|
||||
</Badge>
|
||||
)}
|
||||
</Text>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<ArrowClockwiseRegular />}
|
||||
onClick={refresh}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{opError && (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{opError}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
{error && (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>Failed to load bans: {error}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
|
||||
{loading && bans.length === 0 ? (
|
||||
<div className={styles.centred}>
|
||||
<Spinner label="Loading active bans…" />
|
||||
</div>
|
||||
) : bans.length === 0 ? (
|
||||
<div className={styles.centred}>
|
||||
<Text size={300}>No IPs are currently banned.</Text>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.tableWrapper}>
|
||||
<DataGrid
|
||||
items={bans}
|
||||
columns={banColumns}
|
||||
getRowId={(b: ActiveBan) => `${b.jail}:${b.ip}`}
|
||||
focusMode="composite"
|
||||
>
|
||||
<DataGridHeader>
|
||||
<DataGridRow>
|
||||
{({ renderHeaderCell }) => (
|
||||
<DataGridHeaderCell>{renderHeaderCell()}</DataGridHeaderCell>
|
||||
)}
|
||||
</DataGridRow>
|
||||
</DataGridHeader>
|
||||
<DataGridBody<ActiveBan>>
|
||||
{({ item }) => (
|
||||
<DataGridRow<ActiveBan> key={`${item.jail}:${item.ip}`}>
|
||||
{({ renderCell }) => (
|
||||
<DataGridCell>{renderCell(item)}</DataGridCell>
|
||||
)}
|
||||
</DataGridRow>
|
||||
)}
|
||||
</DataGridBody>
|
||||
</DataGrid>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-component: IP Lookup section
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function IpLookupSection(): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const { result, loading, error, lookup, clear } = useIpLookup();
|
||||
const [inputVal, setInputVal] = useState("");
|
||||
|
||||
const handleLookup = (): void => {
|
||||
if (inputVal.trim()) {
|
||||
lookup(inputVal.trim());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<Text as="h2" size={500} weight="semibold">
|
||||
IP Lookup
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className={styles.formRow}>
|
||||
<div className={styles.formField}>
|
||||
<Field label="IP Address">
|
||||
<Input
|
||||
placeholder="e.g. 1.2.3.4 or 2001:db8::1"
|
||||
value={inputVal}
|
||||
onChange={(_, d) => {
|
||||
setInputVal(d.value);
|
||||
clear();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleLookup();
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Button
|
||||
appearance="primary"
|
||||
icon={loading ? <Spinner size="tiny" /> : <SearchRegular />}
|
||||
onClick={handleLookup}
|
||||
disabled={loading || !inputVal.trim()}
|
||||
style={{ marginBottom: "4px" }}
|
||||
>
|
||||
Look up
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{error}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className={styles.lookupResult}>
|
||||
<div className={styles.lookupRow}>
|
||||
<Text className={styles.lookupLabel}>IP:</Text>
|
||||
<Text className={styles.mono}>{result.ip}</Text>
|
||||
</div>
|
||||
|
||||
<div className={styles.lookupRow}>
|
||||
<Text className={styles.lookupLabel}>Currently banned in:</Text>
|
||||
{result.currently_banned_in.length === 0 ? (
|
||||
<Badge appearance="tint" color="success">
|
||||
not banned
|
||||
</Badge>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "4px" }}>
|
||||
{result.currently_banned_in.map((j) => (
|
||||
<Badge key={j} appearance="filled" color="danger">
|
||||
{j}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{result.geo && (
|
||||
<>
|
||||
{result.geo.country_name && (
|
||||
<div className={styles.lookupRow}>
|
||||
<Text className={styles.lookupLabel}>Country:</Text>
|
||||
<Text>
|
||||
{result.geo.country_name}
|
||||
{result.geo.country_code ? ` (${result.geo.country_code})` : ""}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{result.geo.org && (
|
||||
<div className={styles.lookupRow}>
|
||||
<Text className={styles.lookupLabel}>Organisation:</Text>
|
||||
<Text>{result.geo.org}</Text>
|
||||
</div>
|
||||
)}
|
||||
{result.geo.asn && (
|
||||
<div className={styles.lookupRow}>
|
||||
<Text className={styles.lookupLabel}>ASN:</Text>
|
||||
<Text className={styles.mono}>{result.geo.asn}</Text>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Jails management page.
|
||||
*
|
||||
* Renders four sections: Jail Overview, Ban/Unban IP, Currently Banned IPs,
|
||||
* and IP Lookup.
|
||||
*/
|
||||
export function JailsPage(): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const { jails } = useJails();
|
||||
const { banIp, unbanIp } = useActiveBans();
|
||||
|
||||
const jailNames = jails.map((j) => j.name);
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<Text as="h1" size={700} weight="semibold">
|
||||
Jails
|
||||
</Text>
|
||||
<Text as="p" size={300}>
|
||||
Jail management will be implemented in Stage 6.
|
||||
</Text>
|
||||
|
||||
<JailOverviewSection />
|
||||
|
||||
<BanUnbanForm jailNames={jailNames} onBan={banIp} onUnban={unbanIp} />
|
||||
|
||||
<ActiveBansSection />
|
||||
|
||||
<IpLookupSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
210
frontend/src/types/jail.ts
Normal file
210
frontend/src/types/jail.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* TypeScript interfaces mirroring the backend jail Pydantic models.
|
||||
*
|
||||
* Backend sources:
|
||||
* - `backend/app/models/jail.py`
|
||||
* - `backend/app/models/ban.py` (ActiveBan / ActiveBanListResponse)
|
||||
* - `backend/app/models/geo.py` (GeoDetail / IpLookupResponse)
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Jail statistics
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Live filter+actions counters for a single jail.
|
||||
*
|
||||
* Mirrors `JailStatus` from `backend/app/models/jail.py`.
|
||||
*/
|
||||
export interface JailStatus {
|
||||
/** Number of IPs currently banned under this jail. */
|
||||
currently_banned: number;
|
||||
/** Total bans issued since fail2ban started. */
|
||||
total_banned: number;
|
||||
/** Current number of log-line matches that have not yet led to a ban. */
|
||||
currently_failed: number;
|
||||
/** Total log-line matches since fail2ban started. */
|
||||
total_failed: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Jail list (overview)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Lightweight snapshot of one jail for the overview table.
|
||||
*
|
||||
* Mirrors `JailSummary` from `backend/app/models/jail.py`.
|
||||
*/
|
||||
export interface JailSummary {
|
||||
/** Machine-readable jail name (e.g. `"sshd"`). */
|
||||
name: string;
|
||||
/** Whether the jail is enabled in the configuration. */
|
||||
enabled: boolean;
|
||||
/** Whether fail2ban is currently monitoring the jail. */
|
||||
running: boolean;
|
||||
/** Whether the jail is in idle mode (monitoring paused). */
|
||||
idle: boolean;
|
||||
/** Backend type used for log access (e.g. `"systemd"`, `"polling"`). */
|
||||
backend: string;
|
||||
/** Observation window in seconds before a ban is triggered. */
|
||||
find_time: number;
|
||||
/** Duration of a ban in seconds (negative = permanent). */
|
||||
ban_time: number;
|
||||
/** Maximum log-line failures before a ban is issued. */
|
||||
max_retry: number;
|
||||
/** Live ban/failure counters, or `null` when unavailable. */
|
||||
status: JailStatus | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from `GET /api/jails`.
|
||||
*
|
||||
* Mirrors `JailListResponse` from `backend/app/models/jail.py`.
|
||||
*/
|
||||
export interface JailListResponse {
|
||||
/** All known jails. */
|
||||
jails: JailSummary[];
|
||||
/** Total number of jails. */
|
||||
total: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Jail detail
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Full configuration and state of a single jail.
|
||||
*
|
||||
* Mirrors `Jail` from `backend/app/models/jail.py`.
|
||||
*/
|
||||
export interface Jail {
|
||||
/** Machine-readable jail name. */
|
||||
name: string;
|
||||
/** Whether the jail is running. */
|
||||
running: boolean;
|
||||
/** Whether the jail is in idle mode. */
|
||||
idle: boolean;
|
||||
/** Backend type (systemd, polling, etc.). */
|
||||
backend: string;
|
||||
/** Log file paths monitored by this jail. */
|
||||
log_paths: string[];
|
||||
/** Fail-regex patterns used to identify offenders. */
|
||||
fail_regex: string[];
|
||||
/** Ignore-regex patterns used to whitelist log lines. */
|
||||
ignore_regex: string[];
|
||||
/** Date-pattern used for timestamp parsing, or empty string. */
|
||||
date_pattern: string;
|
||||
/** Log file encoding (e.g. `"UTF-8"`). */
|
||||
log_encoding: string;
|
||||
/** Action names attached to this jail. */
|
||||
actions: string[];
|
||||
/** Observation window in seconds. */
|
||||
find_time: number;
|
||||
/** Ban duration in seconds; negative means permanent. */
|
||||
ban_time: number;
|
||||
/** Maximum failures before ban is applied. */
|
||||
max_retry: number;
|
||||
/** Live counters, or `null` when not available. */
|
||||
status: JailStatus | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from `GET /api/jails/{name}`.
|
||||
*
|
||||
* Mirrors `JailDetailResponse` from `backend/app/models/jail.py`.
|
||||
*/
|
||||
export interface JailDetailResponse {
|
||||
/** Full jail configuration. */
|
||||
jail: Jail;
|
||||
/** Current ignore list (IPs / networks that are never banned). */
|
||||
ignore_list: string[];
|
||||
/** Whether the jail ignores the server's own IP addresses. */
|
||||
ignore_self: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Jail command response
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Generic acknowledgement from jail control endpoints.
|
||||
*
|
||||
* Mirrors `JailCommandResponse` from `backend/app/models/jail.py`.
|
||||
*/
|
||||
export interface JailCommandResponse {
|
||||
/** Human-readable result message. */
|
||||
message: string;
|
||||
/** Target jail name, or `"*"` for operations on all jails. */
|
||||
jail: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Active bans
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A single currently-active ban entry.
|
||||
*
|
||||
* Mirrors `ActiveBan` from `backend/app/models/ban.py`.
|
||||
*/
|
||||
export interface ActiveBan {
|
||||
/** Banned IP address. */
|
||||
ip: string;
|
||||
/** Jail that issued the ban. */
|
||||
jail: string;
|
||||
/** ISO 8601 UTC timestamp the ban started, or `null` when unavailable. */
|
||||
banned_at: string | null;
|
||||
/** ISO 8601 UTC timestamp the ban expires, or `null` for permanent bans. */
|
||||
expires_at: string | null;
|
||||
/** Number of times this IP has been banned before. */
|
||||
ban_count: number;
|
||||
/** ISO 3166-1 alpha-2 country code, or `null` when unknown. */
|
||||
country: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from `GET /api/bans/active`.
|
||||
*
|
||||
* Mirrors `ActiveBanListResponse` from `backend/app/models/ban.py`.
|
||||
*/
|
||||
export interface ActiveBanListResponse {
|
||||
/** List of all currently active bans. */
|
||||
bans: ActiveBan[];
|
||||
/** Total number of active bans. */
|
||||
total: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Geo / IP lookup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Geo-location information for an IP address.
|
||||
*
|
||||
* Mirrors `GeoDetail` from `backend/app/models/geo.py`.
|
||||
*/
|
||||
export interface GeoDetail {
|
||||
/** ISO 3166-1 alpha-2 country code (e.g. `"DE"`), or `null`. */
|
||||
country_code: string | null;
|
||||
/** Country name (e.g. `"Germany"`), or `null`. */
|
||||
country_name: string | null;
|
||||
/** Autonomous System Number string (e.g. `"AS3320"`), or `null`. */
|
||||
asn: string | null;
|
||||
/** Organisation name associated with the IP, or `null`. */
|
||||
org: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from `GET /api/geo/lookup/{ip}`.
|
||||
*
|
||||
* Mirrors `IpLookupResponse` from `backend/app/models/geo.py`.
|
||||
*/
|
||||
export interface IpLookupResponse {
|
||||
/** The queried IP address. */
|
||||
ip: string;
|
||||
/** Jails in which the IP is currently banned. */
|
||||
currently_banned_in: string[];
|
||||
/** Geo-location data, or `null` when the lookup failed. */
|
||||
geo: GeoDetail | null;
|
||||
}
|
||||
Reference in New Issue
Block a user