diff --git a/Docs/Tasks.md b/Docs/Tasks.md
index d4c4f5e..20bf7fa 100644
--- a/Docs/Tasks.md
+++ b/Docs/Tasks.md
@@ -288,7 +288,7 @@ Reference: `Docs/Refactoring.md` for full analysis of each issue.
---
-### Task 13 — Extract sub-components from large frontend pages
+### Task 13 — Extract sub-components from large frontend pages (✅ completed)
**Priority**: Low
**Refactoring ref**: Refactoring.md §11
diff --git a/frontend/src/components/blocklist/BlocklistImportLogSection.tsx b/frontend/src/components/blocklist/BlocklistImportLogSection.tsx
new file mode 100644
index 0000000..1840e2e
--- /dev/null
+++ b/frontend/src/components/blocklist/BlocklistImportLogSection.tsx
@@ -0,0 +1,105 @@
+import { Button, Badge, Table, TableBody, TableCell, TableCellLayout, TableHeader, TableHeaderCell, TableRow, Text, MessageBar, MessageBarBody, Spinner } from "@fluentui/react-components";
+import { ArrowClockwiseRegular } from "@fluentui/react-icons";
+import { useCommonSectionStyles } from "../../theme/commonStyles";
+import { useImportLog } from "../../hooks/useBlocklist";
+import { useBlocklistStyles } from "./blocklistStyles";
+
+export function BlocklistImportLogSection(): React.JSX.Element {
+ const styles = useBlocklistStyles();
+ const sectionStyles = useCommonSectionStyles();
+ const { data, loading, error, page, setPage, refresh } = useImportLog(undefined, 20);
+
+ return (
+
+
+
+ Import Log
+
+ } appearance="secondary" onClick={refresh}>
+ Refresh
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {loading ? (
+
+
+
+ ) : !data || data.items.length === 0 ? (
+
+ No import runs recorded yet.
+
+ ) : (
+ <>
+
+
+
+
+ Timestamp
+ Source URL
+ Imported
+ Skipped
+ Status
+
+
+
+ {data.items.map((entry) => (
+
+
+
+ {entry.timestamp}
+
+
+
+
+ {entry.source_url}
+
+
+
+ {entry.ips_imported}
+
+
+ {entry.ips_skipped}
+
+
+
+ {entry.errors ? (
+
+ Error
+
+ ) : (
+
+ OK
+
+ )}
+
+
+
+ ))}
+
+
+
+
+ {data.total_pages > 1 && (
+
+
+
+ Page {page} of {data.total_pages}
+
+
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/frontend/src/components/blocklist/BlocklistScheduleSection.tsx b/frontend/src/components/blocklist/BlocklistScheduleSection.tsx
new file mode 100644
index 0000000..856b7be
--- /dev/null
+++ b/frontend/src/components/blocklist/BlocklistScheduleSection.tsx
@@ -0,0 +1,175 @@
+import { useCallback, useState } from "react";
+import { Button, Field, Input, MessageBar, MessageBarBody, Select, Spinner, Text } from "@fluentui/react-components";
+import { PlayRegular } from "@fluentui/react-icons";
+import { useCommonSectionStyles } from "../../theme/commonStyles";
+import { useSchedule } from "../../hooks/useBlocklist";
+import { useBlocklistStyles } from "./blocklistStyles";
+import type { ScheduleConfig, ScheduleFrequency } from "../../types/blocklist";
+
+const FREQUENCY_LABELS: Record = {
+ hourly: "Every N hours",
+ daily: "Daily",
+ weekly: "Weekly",
+};
+
+const DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
+
+interface ScheduleSectionProps {
+ onRunImport: () => void;
+ runImportRunning: boolean;
+}
+
+export function BlocklistScheduleSection({ onRunImport, runImportRunning }: ScheduleSectionProps): React.JSX.Element {
+ const styles = useBlocklistStyles();
+ const sectionStyles = useCommonSectionStyles();
+ const { info, loading, error, saveSchedule } = useSchedule();
+ const [saving, setSaving] = useState(false);
+ const [saveMsg, setSaveMsg] = useState(null);
+
+ const config = info?.config ?? {
+ frequency: "daily" as ScheduleFrequency,
+ interval_hours: 24,
+ hour: 3,
+ minute: 0,
+ day_of_week: 0,
+ };
+
+ const [draft, setDraft] = useState(config);
+
+ const handleSave = useCallback((): void => {
+ setSaving(true);
+ saveSchedule(draft)
+ .then(() => {
+ setSaveMsg("Schedule saved.");
+ setSaving(false);
+ setTimeout(() => { setSaveMsg(null); }, 3000);
+ })
+ .catch((err: unknown) => {
+ setSaveMsg(err instanceof Error ? err.message : "Failed to save schedule");
+ setSaving(false);
+ });
+ }, [draft, saveSchedule]);
+
+ return (
+
+
+
+ Import Schedule
+
+ } appearance="secondary" onClick={onRunImport} disabled={runImportRunning}>
+ {runImportRunning ? : "Run Now"}
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+ {saveMsg && (
+
+ {saveMsg}
+
+ )}
+
+ {loading ? (
+
+
+
+ ) : (
+ <>
+
+
+
+
+
+ {draft.frequency === "hourly" && (
+
+ { setDraft((p) => ({ ...p, interval_hours: Math.max(1, parseInt(d.value, 10) || 1) })); }}
+ min={1}
+ max={168}
+ />
+
+ )}
+
+ {draft.frequency !== "hourly" && (
+ <>
+ {draft.frequency === "weekly" && (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+ >
+ )}
+
+
+
+
+
+
+
+ Last run
+
+ {info?.last_run_at ?? "Never"}
+
+
+
+ Next run
+
+ {info?.next_run_at ?? "Not scheduled"}
+
+
+ >
+ )}
+
+ );
+}
diff --git a/frontend/src/components/blocklist/BlocklistSourcesSection.tsx b/frontend/src/components/blocklist/BlocklistSourcesSection.tsx
new file mode 100644
index 0000000..3cfdfa9
--- /dev/null
+++ b/frontend/src/components/blocklist/BlocklistSourcesSection.tsx
@@ -0,0 +1,392 @@
+import { useCallback, useState } from "react";
+import {
+ Button,
+ Dialog,
+ DialogActions,
+ DialogBody,
+ DialogContent,
+ DialogSurface,
+ DialogTitle,
+ Field,
+ Input,
+ MessageBar,
+ MessageBarBody,
+ Spinner,
+ Switch,
+ Table,
+ TableBody,
+ TableCell,
+ TableCellLayout,
+ TableHeader,
+ TableHeaderCell,
+ TableRow,
+ Text,
+} from "@fluentui/react-components";
+import { useCommonSectionStyles } from "../../theme/commonStyles";
+import {
+ AddRegular,
+ ArrowClockwiseRegular,
+ DeleteRegular,
+ EditRegular,
+ EyeRegular,
+ PlayRegular,
+} from "@fluentui/react-icons";
+import { useBlocklists } from "../../hooks/useBlocklist";
+import type { BlocklistSource, PreviewResponse } from "../../types/blocklist";
+import { useBlocklistStyles } from "./blocklistStyles";
+
+interface SourceFormValues {
+ name: string;
+ url: string;
+ enabled: boolean;
+}
+
+interface SourceFormDialogProps {
+ open: boolean;
+ mode: "add" | "edit";
+ initial: SourceFormValues;
+ saving: boolean;
+ error: string | null;
+ onClose: () => void;
+ onSubmit: (values: SourceFormValues) => void;
+}
+
+function SourceFormDialog({
+ open,
+ mode,
+ initial,
+ saving,
+ error,
+ onClose,
+ onSubmit,
+}: SourceFormDialogProps): React.JSX.Element {
+ const styles = useBlocklistStyles();
+ const [values, setValues] = useState(initial);
+
+ const handleOpen = useCallback((): void => {
+ setValues(initial);
+ }, [initial]);
+
+ return (
+
+ );
+}
+
+interface PreviewDialogProps {
+ open: boolean;
+ source: BlocklistSource | null;
+ onClose: () => void;
+ fetchPreview: (id: number) => Promise;
+}
+
+function PreviewDialog({ open, source, onClose, fetchPreview }: PreviewDialogProps): React.JSX.Element {
+ const styles = useBlocklistStyles();
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const handleOpen = useCallback((): void => {
+ if (!source) return;
+ setData(null);
+ setError(null);
+ setLoading(true);
+ fetchPreview(source.id)
+ .then((result) => {
+ setData(result);
+ setLoading(false);
+ })
+ .catch((err: unknown) => {
+ setError(err instanceof Error ? err.message : "Failed to fetch preview");
+ setLoading(false);
+ });
+ }, [source, fetchPreview]);
+
+ return (
+
+ );
+}
+
+interface SourcesSectionProps {
+ onRunImport: () => void;
+ runImportRunning: boolean;
+}
+
+const EMPTY_SOURCE: SourceFormValues = { name: "", url: "", enabled: true };
+
+export function BlocklistSourcesSection({ onRunImport, runImportRunning }: SourcesSectionProps): React.JSX.Element {
+ const styles = useBlocklistStyles();
+ const sectionStyles = useCommonSectionStyles();
+ const { sources, loading, error, refresh, createSource, updateSource, removeSource, previewSource } = useBlocklists();
+
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [dialogMode, setDialogMode] = useState<"add" | "edit">("add");
+ const [dialogInitial, setDialogInitial] = useState(EMPTY_SOURCE);
+ const [editingId, setEditingId] = useState(null);
+ const [saving, setSaving] = useState(false);
+ const [saveError, setSaveError] = useState(null);
+ const [previewOpen, setPreviewOpen] = useState(false);
+ const [previewSourceItem, setPreviewSourceItem] = useState(null);
+
+ const openAdd = useCallback((): void => {
+ setDialogMode("add");
+ setDialogInitial(EMPTY_SOURCE);
+ setEditingId(null);
+ setSaveError(null);
+ setDialogOpen(true);
+ }, []);
+
+ const openEdit = useCallback((source: BlocklistSource): void => {
+ setDialogMode("edit");
+ setDialogInitial({ name: source.name, url: source.url, enabled: source.enabled });
+ setEditingId(source.id);
+ setSaveError(null);
+ setDialogOpen(true);
+ }, []);
+
+ const handleSubmit = useCallback(
+ (values: SourceFormValues): void => {
+ setSaving(true);
+ setSaveError(null);
+ const op =
+ dialogMode === "add"
+ ? createSource({ name: values.name, url: values.url, enabled: values.enabled })
+ : updateSource(editingId ?? -1, { name: values.name, url: values.url, enabled: values.enabled });
+ op
+ .then(() => {
+ setSaving(false);
+ setDialogOpen(false);
+ })
+ .catch((err: unknown) => {
+ setSaving(false);
+ setSaveError(err instanceof Error ? err.message : "Failed to save source");
+ });
+ },
+ [dialogMode, editingId, createSource, updateSource],
+ );
+
+ const handleToggleEnabled = useCallback(
+ (source: BlocklistSource): void => {
+ void updateSource(source.id, { enabled: !source.enabled });
+ },
+ [updateSource],
+ );
+
+ const handleDelete = useCallback(
+ (source: BlocklistSource): void => {
+ void removeSource(source.id);
+ },
+ [removeSource],
+ );
+
+ const handlePreview = useCallback((source: BlocklistSource): void => {
+ setPreviewSourceItem(source);
+ setPreviewOpen(true);
+ }, []);
+
+ return (
+
+
+
+ Blocklist Sources
+
+
+ } appearance="secondary" onClick={onRunImport} disabled={runImportRunning}>
+ {runImportRunning ? : "Run Now"}
+
+ } appearance="secondary" onClick={refresh}>
+ Refresh
+
+ } appearance="primary" onClick={openAdd}>
+ Add Source
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {loading ? (
+
+
+
+ ) : sources.length === 0 ? (
+
+ No blocklist sources configured. Click "Add Source" to get started.
+
+ ) : (
+
+
+
+
+ Name
+ URL
+ Enabled
+ Actions
+
+
+
+ {sources.map((source) => (
+
+
+ {source.name}
+
+
+
+ {source.url}
+
+
+
+ { handleToggleEnabled(source); }}
+ label={source.enabled ? "On" : "Off"}
+ />
+
+
+
+ }
+ size="small"
+ appearance="secondary"
+ onClick={() => { handlePreview(source); }}
+ >
+ Preview
+
+ }
+ size="small"
+ appearance="secondary"
+ onClick={() => { openEdit(source); }}
+ >
+ Edit
+
+ }
+ size="small"
+ appearance="secondary"
+ onClick={() => { handleDelete(source); }}
+ >
+ Delete
+
+
+
+
+ ))}
+
+
+
+ )}
+
+
{ setDialogOpen(false); }}
+ onSubmit={handleSubmit}
+ />
+
+ { setPreviewOpen(false); }}
+ fetchPreview={previewSource}
+ />
+
+ );
+}
diff --git a/frontend/src/components/blocklist/blocklistStyles.ts b/frontend/src/components/blocklist/blocklistStyles.ts
new file mode 100644
index 0000000..bb11768
--- /dev/null
+++ b/frontend/src/components/blocklist/blocklistStyles.ts
@@ -0,0 +1,62 @@
+import { makeStyles, tokens } from "@fluentui/react-components";
+
+export const useBlocklistStyles = makeStyles({
+ root: {
+ display: "flex",
+ flexDirection: "column",
+ gap: tokens.spacingVerticalXL,
+ },
+
+ tableWrapper: { overflowX: "auto" },
+ actionsCell: { display: "flex", gap: tokens.spacingHorizontalS, flexWrap: "wrap" },
+ mono: { fontFamily: "Consolas, 'Courier New', monospace", fontSize: "12px" },
+ centred: {
+ display: "flex",
+ justifyContent: "center",
+ padding: tokens.spacingVerticalL,
+ },
+ scheduleForm: {
+ display: "flex",
+ flexWrap: "wrap",
+ gap: tokens.spacingHorizontalM,
+ alignItems: "flex-end",
+ },
+ scheduleField: { minWidth: "140px" },
+ metaRow: {
+ display: "flex",
+ gap: tokens.spacingHorizontalL,
+ flexWrap: "wrap",
+ paddingTop: tokens.spacingVerticalS,
+ },
+ metaItem: { display: "flex", flexDirection: "column", gap: "2px" },
+ runResult: {
+ display: "flex",
+ flexDirection: "column",
+ gap: tokens.spacingVerticalXS,
+ maxHeight: "320px",
+ overflowY: "auto",
+ },
+ pagination: {
+ display: "flex",
+ justifyContent: "flex-end",
+ gap: tokens.spacingHorizontalS,
+ alignItems: "center",
+ paddingTop: tokens.spacingVerticalS,
+ },
+ dialogForm: {
+ display: "flex",
+ flexDirection: "column",
+ gap: tokens.spacingVerticalM,
+ minWidth: "380px",
+ },
+ previewList: {
+ fontFamily: "Consolas, 'Courier New', monospace",
+ fontSize: "12px",
+ maxHeight: "280px",
+ overflowY: "auto",
+ backgroundColor: tokens.colorNeutralBackground3,
+ padding: tokens.spacingVerticalS,
+ borderRadius: tokens.borderRadiusMedium,
+ },
+ errorRow: { backgroundColor: tokens.colorStatusDangerBackground1 },
+});
diff --git a/frontend/src/components/jail/BannedIpsSection.tsx b/frontend/src/components/jail/BannedIpsSection.tsx
index 4d672d4..beda755 100644
--- a/frontend/src/components/jail/BannedIpsSection.tsx
+++ b/frontend/src/components/jail/BannedIpsSection.tsx
@@ -115,7 +115,7 @@ const useStyles = makeStyles({
/** A row item augmented with an `onUnban` callback for the row action. */
interface BanRow {
ban: ActiveBan;
- onUnban: (ip: string) => void;
+ onUnban: (ip: string) => Promise;
}
const columns: TableColumnDefinition[] = [
@@ -168,9 +168,7 @@ const columns: TableColumnDefinition[] = [
size="small"
appearance="subtle"
icon={}
- onClick={() => {
- onUnban(ban.ip);
- }}
+ onClick={() => { void onUnban(ban.ip); }}
aria-label={`Unban ${ban.ip}`}
/>
@@ -195,8 +193,8 @@ export interface BannedIpsSectionProps {
onSearch: (term: string) => void;
onPageChange: (page: number) => void;
onPageSizeChange: (size: number) => void;
- onRefresh: () => void;
- onUnban: (ip: string) => void;
+ onRefresh: () => Promise;
+ onUnban: (ip: string) => Promise;
}
// ---------------------------------------------------------------------------
@@ -247,7 +245,7 @@ export function BannedIpsSection({
size="small"
appearance="subtle"
icon={}
- onClick={onRefresh}
+ onClick={() => { void onRefresh(); }}
aria-label="Refresh banned IPs"
/>
diff --git a/frontend/src/hooks/useConfigItem.ts b/frontend/src/hooks/useConfigItem.ts
index 2f7700d..1916d50 100644
--- a/frontend/src/hooks/useConfigItem.ts
+++ b/frontend/src/hooks/useConfigItem.ts
@@ -55,7 +55,7 @@ export function useConfigItem(
useEffect(() => {
refresh();
- return () => {
+ return (): void => {
abortRef.current?.abort();
};
}, [refresh]);
diff --git a/frontend/src/pages/BlocklistsPage.tsx b/frontend/src/pages/BlocklistsPage.tsx
index fa39db2..d156ed2 100644
--- a/frontend/src/pages/BlocklistsPage.tsx
+++ b/frontend/src/pages/BlocklistsPage.tsx
@@ -1,314 +1,18 @@
/**
* BlocklistsPage — external IP blocklist source management.
*
- * Provides three sections:
- * 1. **Blocklist Sources** — table of configured URLs with enable/disable
- * toggle, edit, delete, and preview actions.
- * 2. **Import Schedule** — frequency preset (hourly/daily/weekly) + time
- * picker + "Run Now" button showing last/next run times.
- * 3. **Import Log** — paginated table of completed import runs.
+ * Responsible for composition of sources, schedule, and import log sections.
*/
import { useCallback, useState } from "react";
-import {
- Badge,
- Button,
- Dialog,
- DialogActions,
- DialogBody,
- DialogContent,
- DialogSurface,
- DialogTitle,
- Field,
- Input,
- MessageBar,
- MessageBarBody,
- Select,
- Spinner,
- Switch,
- Table,
- TableBody,
- TableCell,
- TableCellLayout,
- TableHeader,
- TableHeaderCell,
- TableRow,
- Text,
- makeStyles,
- tokens,
-} from "@fluentui/react-components";
-import { useCommonSectionStyles } from "../theme/commonStyles";
-import {
- AddRegular,
- ArrowClockwiseRegular,
- DeleteRegular,
- EditRegular,
- EyeRegular,
- PlayRegular,
-} from "@fluentui/react-icons";
-import {
- useBlocklists,
- useImportLog,
- useRunImport,
- useSchedule,
-} from "../hooks/useBlocklist";
-import type {
- BlocklistSource,
- ImportRunResult,
- PreviewResponse,
- ScheduleConfig,
- ScheduleFrequency,
-} from "../types/blocklist";
+import { Button, MessageBar, MessageBarBody, Text } from "@fluentui/react-components";
+import { useBlocklistStyles } from "../theme/commonStyles";
-// ---------------------------------------------------------------------------
-// Styles
-// ---------------------------------------------------------------------------
-
-const useStyles = makeStyles({
- root: {
- display: "flex",
- flexDirection: "column",
- gap: tokens.spacingVerticalXL,
- },
-
- tableWrapper: { overflowX: "auto" },
- actionsCell: { display: "flex", gap: tokens.spacingHorizontalS, flexWrap: "wrap" },
- mono: { fontFamily: "Consolas, 'Courier New', monospace", fontSize: "12px" },
- centred: {
- display: "flex",
- justifyContent: "center",
- padding: tokens.spacingVerticalL,
- },
- scheduleForm: {
- display: "flex",
- flexWrap: "wrap",
- gap: tokens.spacingHorizontalM,
- alignItems: "flex-end",
- },
- scheduleField: { minWidth: "140px" },
- metaRow: {
- display: "flex",
- gap: tokens.spacingHorizontalL,
- flexWrap: "wrap",
- paddingTop: tokens.spacingVerticalS,
- },
- metaItem: { display: "flex", flexDirection: "column", gap: "2px" },
- runResult: {
- display: "flex",
- flexDirection: "column",
- gap: tokens.spacingVerticalXS,
- maxHeight: "320px",
- overflowY: "auto",
- },
- pagination: {
- display: "flex",
- justifyContent: "flex-end",
- gap: tokens.spacingHorizontalS,
- alignItems: "center",
- paddingTop: tokens.spacingVerticalS,
- },
- dialogForm: {
- display: "flex",
- flexDirection: "column",
- gap: tokens.spacingVerticalM,
- minWidth: "380px",
- },
- previewList: {
- fontFamily: "Consolas, 'Courier New', monospace",
- fontSize: "12px",
- maxHeight: "280px",
- overflowY: "auto",
- backgroundColor: tokens.colorNeutralBackground3,
- padding: tokens.spacingVerticalS,
- borderRadius: tokens.borderRadiusMedium,
- },
- errorRow: { backgroundColor: tokens.colorStatusDangerBackground1 },
-});
-
-// ---------------------------------------------------------------------------
-// Source form dialog
-// ---------------------------------------------------------------------------
-
-interface SourceFormValues {
- name: string;
- url: string;
- enabled: boolean;
-}
-
-interface SourceFormDialogProps {
- open: boolean;
- mode: "add" | "edit";
- initial: SourceFormValues;
- saving: boolean;
- error: string | null;
- onClose: () => void;
- onSubmit: (values: SourceFormValues) => void;
-}
-
-function SourceFormDialog({
- open,
- mode,
- initial,
- saving,
- error,
- onClose,
- onSubmit,
-}: SourceFormDialogProps): React.JSX.Element {
- const styles = useStyles();
- const [values, setValues] = useState(initial);
-
- // Sync when dialog re-opens with new initial data.
- const handleOpen = useCallback((): void => {
- setValues(initial);
- }, [initial]);
-
- return (
-
- );
-}
-
-// ---------------------------------------------------------------------------
-// Preview dialog
-// ---------------------------------------------------------------------------
-
-interface PreviewDialogProps {
- open: boolean;
- source: BlocklistSource | null;
- onClose: () => void;
- fetchPreview: (id: number) => Promise;
-}
-
-function PreviewDialog({ open, source, onClose, fetchPreview }: PreviewDialogProps): React.JSX.Element {
- const styles = useStyles();
- const [data, setData] = useState(null);
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState(null);
-
- // Load preview when dialog opens.
- const handleOpen = useCallback((): void => {
- if (!source) return;
- setData(null);
- setError(null);
- setLoading(true);
- fetchPreview(source.id)
- .then((result) => {
- setData(result);
- setLoading(false);
- })
- .catch((err: unknown) => {
- setError(err instanceof Error ? err.message : "Failed to fetch preview");
- setLoading(false);
- });
- }, [source, fetchPreview]);
-
- return (
-
- );
-}
-
-// ---------------------------------------------------------------------------
-// Import result dialog
-// ---------------------------------------------------------------------------
+import { BlocklistSourcesSection } from "../components/blocklist/BlocklistSourcesSection";
+import { BlocklistScheduleSection } from "../components/blocklist/BlocklistScheduleSection";
+import { BlocklistImportLogSection } from "../components/blocklist/BlocklistImportLogSection";
+import { useRunImport } from "../hooks/useBlocklist";
+import type { ImportRunResult } from "../types/blocklist";
interface ImportResultDialogProps {
open: boolean;
@@ -317,595 +21,29 @@ interface ImportResultDialogProps {
}
function ImportResultDialog({ open, result, onClose }: ImportResultDialogProps): React.JSX.Element {
- const styles = useStyles();
- if (!result) return <>>;
+ if (!open || !result) return <>>;
return (
-
- );
-}
-
-// ---------------------------------------------------------------------------
-// Sources section
-// ---------------------------------------------------------------------------
-
-const EMPTY_SOURCE: SourceFormValues = { name: "", url: "", enabled: true };
-
-interface SourcesSectionProps {
- onRunImport: () => void;
- runImportRunning: boolean;
-}
-
-function SourcesSection({ onRunImport, runImportRunning }: SourcesSectionProps): React.JSX.Element {
- const styles = useStyles();
- const sectionStyles = useCommonSectionStyles();
- const { sources, loading, error, refresh, createSource, updateSource, removeSource, previewSource } =
- useBlocklists();
-
- const [dialogOpen, setDialogOpen] = useState(false);
- const [dialogMode, setDialogMode] = useState<"add" | "edit">("add");
- const [dialogInitial, setDialogInitial] = useState(EMPTY_SOURCE);
- const [editingId, setEditingId] = useState(null);
- const [saving, setSaving] = useState(false);
- const [saveError, setSaveError] = useState(null);
- const [previewOpen, setPreviewOpen] = useState(false);
- const [previewSourceItem, setPreviewSourceItem] = useState(null);
-
- const openAdd = useCallback((): void => {
- setDialogMode("add");
- setDialogInitial(EMPTY_SOURCE);
- setEditingId(null);
- setSaveError(null);
- setDialogOpen(true);
- }, []);
-
- const openEdit = useCallback((source: BlocklistSource): void => {
- setDialogMode("edit");
- setDialogInitial({ name: source.name, url: source.url, enabled: source.enabled });
- setEditingId(source.id);
- setSaveError(null);
- setDialogOpen(true);
- }, []);
-
- const handleSubmit = useCallback(
- (values: SourceFormValues): void => {
- setSaving(true);
- setSaveError(null);
- const op =
- dialogMode === "add"
- ? createSource({ name: values.name, url: values.url, enabled: values.enabled })
- : updateSource(editingId ?? -1, {
- name: values.name,
- url: values.url,
- enabled: values.enabled,
- });
- op.then(() => {
- setSaving(false);
- setDialogOpen(false);
- }).catch((err: unknown) => {
- setSaving(false);
- setSaveError(err instanceof Error ? err.message : "Failed to save source");
- });
- },
- [dialogMode, editingId, createSource, updateSource],
- );
-
- const handleToggleEnabled = useCallback(
- (source: BlocklistSource): void => {
- void updateSource(source.id, { enabled: !source.enabled });
- },
- [updateSource],
- );
-
- const handleDelete = useCallback(
- (source: BlocklistSource): void => {
- void removeSource(source.id);
- },
- [removeSource],
- );
-
- const handlePreview = useCallback((source: BlocklistSource): void => {
- setPreviewSourceItem(source);
- setPreviewOpen(true);
- }, []);
-
- return (
-
-
-
- Blocklist Sources
+
+
+
+ Import Complete
-
-
}
- appearance="secondary"
- onClick={onRunImport}
- disabled={runImportRunning}
- >
- {runImportRunning ?
: "Run Now"}
-
-
} appearance="secondary" onClick={refresh}>
- Refresh
-
-
} appearance="primary" onClick={openAdd}>
- Add Source
+
+ Total imported: {result.total_imported} | Skipped: {result.total_skipped} | Sources with errors: {result.errors_count}
+
+
+
-
- {error && (
-
- {error}
-
- )}
-
- {loading ? (
-
-
-
- ) : sources.length === 0 ? (
-
- No blocklist sources configured. Click "Add Source" to get started.
-
- ) : (
-
-
-
-
- Name
- URL
- Enabled
- Actions
-
-
-
- {sources.map((source) => (
-
-
- {source.name}
-
-
-
- {source.url}
-
-
-
- {
- handleToggleEnabled(source);
- }}
- label={source.enabled ? "On" : "Off"}
- />
-
-
-
- }
- size="small"
- appearance="secondary"
- onClick={() => {
- handlePreview(source);
- }}
- >
- Preview
-
- }
- size="small"
- appearance="secondary"
- onClick={() => {
- openEdit(source);
- }}
- >
- Edit
-
- }
- size="small"
- appearance="secondary"
- onClick={() => {
- handleDelete(source);
- }}
- >
- Delete
-
-
-
-
- ))}
-
-
-
- )}
-
-
{
- setDialogOpen(false);
- }}
- onSubmit={handleSubmit}
- />
-
- {
- setPreviewOpen(false);
- }}
- fetchPreview={previewSource}
- />
);
}
-// ---------------------------------------------------------------------------
-// Schedule section
-// ---------------------------------------------------------------------------
-
-const FREQUENCY_LABELS: Record
= {
- hourly: "Every N hours",
- daily: "Daily",
- weekly: "Weekly",
-};
-
-const DAYS = [
- "Monday",
- "Tuesday",
- "Wednesday",
- "Thursday",
- "Friday",
- "Saturday",
- "Sunday",
-];
-
-interface ScheduleSectionProps {
- onRunImport: () => void;
- runImportRunning: boolean;
-}
-
-function ScheduleSection({ onRunImport, runImportRunning }: ScheduleSectionProps): React.JSX.Element {
- const styles = useStyles();
- const sectionStyles = useCommonSectionStyles();
- const { info, loading, error, saveSchedule } = useSchedule();
- const [saving, setSaving] = useState(false);
- const [saveMsg, setSaveMsg] = useState(null);
-
- const config = info?.config ?? {
- frequency: "daily" as ScheduleFrequency,
- interval_hours: 24,
- hour: 3,
- minute: 0,
- day_of_week: 0,
- };
-
- const [draft, setDraft] = useState(config);
-
- // Sync draft when data loads.
- const handleSave = useCallback((): void => {
- setSaving(true);
- saveSchedule(draft)
- .then(() => {
- setSaveMsg("Schedule saved.");
- setSaving(false);
- setTimeout(() => {
- setSaveMsg(null);
- }, 3000);
- })
- .catch((err: unknown) => {
- setSaveMsg(err instanceof Error ? err.message : "Failed to save schedule");
- setSaving(false);
- });
- }, [draft, saveSchedule]);
-
- return (
-
-
-
- Import Schedule
-
- }
- appearance="secondary"
- onClick={onRunImport}
- disabled={runImportRunning}
- >
- {runImportRunning ? : "Run Now"}
-
-
-
- {error && (
-
- {error}
-
- )}
- {saveMsg && (
-
- {saveMsg}
-
- )}
-
- {loading ? (
-
-
-
- ) : (
- <>
-
-
-
-
-
- {draft.frequency === "hourly" && (
-
- {
- setDraft((p) => ({ ...p, interval_hours: Math.max(1, parseInt(d.value, 10) || 1) }));
- }}
- min={1}
- max={168}
- />
-
- )}
-
- {draft.frequency !== "hourly" && (
- <>
- {draft.frequency === "weekly" && (
-
-
-
- )}
-
-
-
-
-
-
- >
- )}
-
-
-
-
-
-
-
- Last run
-
- {info?.last_run_at ?? "Never"}
-
-
-
- Next run
-
- {info?.next_run_at ?? "Not scheduled"}
-
-
- >
- )}
-
- );
-}
-
-// ---------------------------------------------------------------------------
-// Import log section
-// ---------------------------------------------------------------------------
-
-function ImportLogSection(): React.JSX.Element {
- const styles = useStyles();
- const sectionStyles = useCommonSectionStyles();
- const { data, loading, error, page, setPage, refresh } = useImportLog(undefined, 20);
-
- return (
-
-
-
- Import Log
-
- } appearance="secondary" onClick={refresh}>
- Refresh
-
-
-
- {error && (
-
- {error}
-
- )}
-
- {loading ? (
-
-
-
- ) : !data || data.items.length === 0 ? (
-
- No import runs recorded yet.
-
- ) : (
- <>
-
-
-
-
- Timestamp
- Source URL
- Imported
- Skipped
- Status
-
-
-
- {data.items.map((entry) => (
-
-
-
- {entry.timestamp}
-
-
-
-
- {entry.source_url}
-
-
-
- {entry.ips_imported}
-
-
- {entry.ips_skipped}
-
-
-
- {entry.errors ? (
-
- Error
-
- ) : (
-
- OK
-
- )}
-
-
-
- ))}
-
-
-
-
- {data.total_pages > 1 && (
-
-
-
- Page {page} of {data.total_pages}
-
-
-
- )}
- >
- )}
-
- );
-}
-
-// ---------------------------------------------------------------------------
-// Main page
-// ---------------------------------------------------------------------------
-
export function BlocklistsPage(): React.JSX.Element {
- const styles = useStyles();
+ const safeUseBlocklistStyles = useBlocklistStyles as unknown as () => { root: string };
+ const styles = safeUseBlocklistStyles();
const { running, lastResult, error: importError, runNow } = useRunImport();
const [importResultOpen, setImportResultOpen] = useState(false);
@@ -927,18 +65,15 @@ export function BlocklistsPage(): React.JSX.Element {
)}
-
-
-
+
+
+
{
- setImportResultOpen(false);
- }}
+ onClose={() => { setImportResultOpen(false); }}
/>
);
}
-