From a1f97bd78f571bfbd14a5395625ab4304e23f218 Mon Sep 17 00:00:00 2001 From: Lukas Date: Sun, 22 Mar 2026 14:08:20 +0100 Subject: [PATCH] Refactor BlocklistsPage into section components and fix frontend lint issues --- Docs/Tasks.md | 2 +- .../blocklist/BlocklistImportLogSection.tsx | 105 ++ .../blocklist/BlocklistScheduleSection.tsx | 175 ++++ .../blocklist/BlocklistSourcesSection.tsx | 392 ++++++++ .../components/blocklist/blocklistStyles.ts | 62 ++ .../src/components/jail/BannedIpsSection.tsx | 12 +- frontend/src/hooks/useConfigItem.ts | 2 +- frontend/src/pages/BlocklistsPage.tsx | 915 +----------------- 8 files changed, 766 insertions(+), 899 deletions(-) create mode 100644 frontend/src/components/blocklist/BlocklistImportLogSection.tsx create mode 100644 frontend/src/components/blocklist/BlocklistScheduleSection.tsx create mode 100644 frontend/src/components/blocklist/BlocklistSourcesSection.tsx create mode 100644 frontend/src/components/blocklist/blocklistStyles.ts 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 + + +
+ + {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 + + +
+ + {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 ( + { + if (!data.open) onClose(); + }} + > + + + {mode === "add" ? "Add Blocklist Source" : "Edit Blocklist Source"} + +
+ {error && ( + + {error} + + )} + + { setValues((p) => ({ ...p, name: d.value })); }} + placeholder="e.g. Blocklist.de — All" + /> + + + { setValues((p) => ({ ...p, url: d.value })); }} + placeholder="https://lists.blocklist.de/lists/all.txt" + /> + + { setValues((p) => ({ ...p, enabled: d.checked })); }} + /> +
+
+ + + + +
+
+
+ ); +} + +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 ( + { + if (!d.open) onClose(); + }} + > + + + Preview — {source?.name ?? ""} + + {loading && ( +
+ +
+ )} + {error && ( + + {error} + + )} + {data && ( +
+ + {data.valid_count} valid IPs / {data.skipped_count} skipped of {data.total_lines} total lines. Showing first {data.entries.length}: + +
+ {data.entries.map((entry) => ( +
{entry}
+ ))} +
+
+ )} +
+ + + +
+
+
+ ); +} + +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 + +
+ + + +
+
+ + {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"} + /> + + +
+ + + +
+
+
+ ))} +
+
+
+ )} + + { 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 ( - { - if (!data.open) onClose(); - }} - > - - - {mode === "add" ? "Add Blocklist Source" : "Edit Blocklist Source"} - -
- {error && ( - - {error} - - )} - - { - setValues((p) => ({ ...p, name: d.value })); - }} - placeholder="e.g. Blocklist.de — All" - /> - - - { - setValues((p) => ({ ...p, url: d.value })); - }} - placeholder="https://lists.blocklist.de/lists/all.txt" - /> - - { - setValues((p) => ({ ...p, enabled: d.checked })); - }} - /> -
-
- - - - -
-
-
- ); -} - -// --------------------------------------------------------------------------- -// 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 ( - { - if (!d.open) onClose(); - }} - > - - - Preview — {source?.name ?? ""} - - {loading && ( -
- -
- )} - {error && ( - - {error} - - )} - {data && ( -
- - {data.valid_count} valid IPs / {data.skipped_count} skipped of{" "} - {data.total_lines} total lines. Showing first {data.entries.length}: - -
- {data.entries.map((entry) => ( -
{entry}
- ))} -
-
- )} -
- - - -
-
-
- ); -} - -// --------------------------------------------------------------------------- -// 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 ( - { - if (!d.open) onClose(); - }} - > - - - Import Complete - -
- - Total imported: {result.total_imported}  |  Skipped: - {result.total_skipped}  |  Sources with errors: {result.errors_count} - - {result.results.map((r, i) => ( -
- - {r.source_url} - -
- - Imported: {r.ips_imported} | Skipped: {r.ips_skipped} - {r.error ? ` | Error: ${r.error}` : ""} - -
- ))} -
-
- - - -
-
-
- ); -} - -// --------------------------------------------------------------------------- -// 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 -
- - -
- - {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"} - /> - - -
- - - -
-
-
- ))} -
-
-
- )} - - { - 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 - - -
- - {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 - - -
- - {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); }} />
); } -