48 KiB
BanGUI — Task List
This document breaks the entire BanGUI project into development stages, ordered so that each stage builds on the previous one. Every task is described in prose with enough detail for a developer to begin work. References point to the relevant documentation.
Reference: Docs/Refactoring.md for full analysis of each issue.
Open Issues
1. Arbitrary File Read in preview_log()
Where: backend/app/services/log_service.py — the preview_log() function (around line 212). Called from backend/app/routers/config_misc.py via POST /api/config/preview-log.
Goal: The preview_log() function must validate the incoming log_path against an allowlist of safe directory prefixes before reading the file, exactly the way read_fail2ban_log() already does at line 131 using _SAFE_LOG_PREFIXES. After the fix, any path outside the allowed directories must be rejected with a ConfigOperationError.
Status: Completed.
Possible traps and issues:
- The allowlist may need to be broader than
_SAFE_LOG_PREFIXES(/var/log,/config/log) becausepreview_logis used with arbitrary log files that fail2ban monitors (e.g./var/log/auth.log,/var/log/nginx/access.log). Restricting too tightly could break the regex tester feature. - The path must be resolved (
.resolve()) before checking prefixes, otherwise symlink-based bypasses are possible. - Needs a test that confirms a path like
/etc/shadowor/proc/self/environis rejected.
Docs changes needed: None. This is an internal security hardening; the API contract does not change (invalid paths return an error response instead of file contents).
Why this is needed: An authenticated user can currently read the tail of any file readable by the backend process by sending a crafted log_path in the request body. This is an OWASP path traversal / arbitrary file read vulnerability. Even though it requires authentication, it violates the principle of least privilege — the endpoint should only read log files, not arbitrary system files.
2. Missing Repository Protocols for settings_repo and history_archive_repo
Status: Completed.
Where: backend/app/repositories/protocols.py defines protocols for 5 of the 7 repositories. The two missing are settings_repo.py and history_archive_repo.py.
Goal: Define SettingsRepository and HistoryArchiveRepository protocols in protocols.py that match the public functions of settings_repo (get_setting, set_setting, delete_setting, get_all_settings) and history_archive_repo (archive_ban_event, get_max_timeofban, get_archived_history). Add corresponding dependency providers in dependencies.py and typed aliases so these repositories can be injected the same way as the other five.
Possible traps and issues:
settings_repois imported directly bysetup_serviceandauth_service— these call sites need to be updated to accept the protocol type, or at minimum the direct import must remain compatible.history_archive_repois imported inline inhistory_service.pyandban_service.pyviafrom app.repositories.history_archive_repo import .... Converting these to dependency injection requires threading the repo through service function signatures, which touches multiple call sites in routers and tasks.- Changing function signatures is a breaking change for tests that call services directly.
Docs changes needed: Update Docs/Architekture.md section 2.2 (Repositories table) to list all 7 repositories as having protocols.
Why this is needed: The other 5 repositories all have protocol definitions, enabling clean test mocking via dependency injection. Without protocols, settings_repo and history_archive_repo are tightly coupled — tests must monkeypatch the module import rather than injecting a fake. This inconsistency makes the codebase harder to test and violates the project's own dependency inversion principle documented in section 10 of the architecture doc.
3. Remove Empty helpers/ Directory
Where: backend/app/helpers/ — contains only __init__.py and __pycache__/.
Goal: Delete the backend/app/helpers/ directory entirely. All utility code already lives in backend/app/utils/, which is the documented location for helper modules.
Possible traps and issues:
- Verify no import references
app.helpersanywhere in the codebase before deleting. - If any generated tooling or IDE configuration references
helpers/, that needs cleanup too.
Docs changes needed: The architecture doc (Docs/Architekture.md) section 2.1 lists helpers/ in the project structure tree. Remove that line.
Status: Completed.
Why this is needed: An empty package directory with no module inside it is confusing for developers. It suggests functionality should live there, when the project convention is to use utils/. Removing it eliminates ambiguity about where new helper code should go.
4. Add Path Validation to _geoip_reader Initialization
Status: Completed.
Where: backend/app/services/geo_service.py — the init_geoip() function (around line 249) sets the module-level _geoip_reader without acquiring _cache_lock.
Goal: Add a clear code comment documenting the startup-only assumption: init_geoip() must only be called during application startup (from startup.py) before request handling begins. Optionally, add an assertion or guard that prevents double-initialization.
Possible traps and issues:
- Wrapping
init_geoip()in_cache_lockwould require making itasync, which changes the call site instartup.py. Sincegeoip2.database.Reader()does blocking file I/O, a lock alone is not sufficient — it would also needrun_blocking. - The simplest safe approach is a module-level
boolflag (_geoip_initialized) checked at the top ofinit_geoip(), with aRuntimeErrorif called twice. - Over-engineering this (full lock + async) is not worth it for a function called exactly once.
Docs changes needed: None.
Why this is needed: A future developer unfamiliar with the startup sequence could call init_geoip() from a request handler or background task, introducing a data race on _geoip_reader. A guard and comment make the contract explicit and prevent silent bugs.
5. Add Concurrency Comment / Guard to RuntimeState
Where: backend/app/utils/runtime_state.py — the RuntimeState dataclass (line 47) and its mutation functions (process_health_probe_result, record_activation, clear_pending_recovery, etc.).
Goal: Add a module-level docstring section explicitly documenting the concurrency model: RuntimeState is safe only under single-worker asyncio with cooperative scheduling. Mutations must not span await points (no read-modify-write across suspension). If multi-worker deployment is ever needed, RuntimeState must be replaced with a shared backend (Redis, shared memory).
Possible traps and issues:
- Adding locks to
RuntimeStatewould be overkill for the current single-worker design and would complicate every read path. - The real risk is a future developer adding an
awaitbetween reading and writing aRuntimeStateattribute in a new function, introducing a TOCTOU race. Documentation is the correct mitigation here, not locks. process_health_probe_result()already does read-then-write onserver_statusandpending_recovery, but since it runs in a background task that completes without yielding between those operations, it is safe today.
Docs changes needed: Update Docs/Architekture.md section 6 (Authentication & Session Management) where the session cache is already documented as process-local. Add a parallel note about RuntimeState in the same section or in a new "Concurrency Model" subsection.
Why this is needed: The safety of RuntimeState depends on an implicit assumption (single-worker, no await between read and write) that is not written down anywhere. If the deployment model changes or a developer modifies state-mutating functions, silent data races could appear with no warning. Explicit documentation prevents this.
6. geo_service Calls db.commit() Directly Instead of Deferring to Repository
Where: backend/app/services/geo_service.py — approximately 5 locations (lines 417, 443, 455, 637, 813) where the service calls await db.commit() directly after repository operations.
Goal: Move transaction commit calls into the repository layer or into a unit-of-work pattern so that the service layer does not manage database transaction boundaries. The repository functions (upsert_entry, bulk_upsert_entries, etc.) should commit internally, or the caller should use an explicit context manager that commits on exit.
Possible traps and issues:
- The current pattern exists because
geo_servicewrites to the DB from both request handlers and background tasks, and commit timing differs between the two contexts. A blanket "always commit in the repo" approach could cause excessive commits during batch operations (e.g.,lookup_batchresolves 100 IPs — committing after eachupsert_entryinstead of once at the end would be 100x more disk I/O). - A pragmatic middle ground: keep
db.commit()in the service for batch operations, but document why this deviates from the general pattern. Alternatively, add abulk_upsert_and_commitrepo method that handles both. - Changing commit semantics can cause data loss if the commit is moved but an exception prevents it from running.
Docs changes needed: None required, but a comment in the architecture doc section 2.2 (Repositories) noting that geo cache bulk operations are an intentional exception to the "repositories own transactions" rule would help.
Why this is needed: Every other service delegates full DB lifecycle to the repository. geo_service is the only one calling db.commit() directly. This inconsistency makes it harder to reason about transaction boundaries and could confuse developers who expect the repository to handle commits.
7. Consider Splitting Large Service and Router Files
Where:
backend/app/services/jail_service.py— 1287 linesbackend/app/services/ban_service.py— 1068 linesbackend/app/services/action_config_service.py— 949 linesbackend/app/services/raw_config_io_service.py— 981 linesbackend/app/routers/file_config.py— 814 linesbackend/app/routers/jail_config.py— 763 lines
Goal: These files are cohesive today but trending toward sizes where navigation becomes difficult. raw_config_io_service.py in particular contains near-identical CRUD logic for three config file types (jail, filter, action). The goal is not to split them now, but to establish a size threshold (e.g., 800 lines) and plan extraction points:
raw_config_io_service.py→ extract shared_list_conf_files,_read_conf_file,_write_conf_file,_create_conf_fileinto a base helper, reducing the per-type functions to thin wrappers.file_config.pyrouter → split intojail_file_config.py,filter_file_config.py,action_file_config.py(matching the existingjail_config.py/filter_config.py/action_config.pysplit).ban_service.py→ extract aggregation/statistics functions (ban_trend,bans_by_country,bans_by_jail) into aban_stats_service.py.
Possible traps and issues:
- Splitting services changes import paths, which breaks any test that imports from the old location.
- Moving functions between modules can introduce circular imports if the extracted module needs to call back into the original.
- Splitting too early (before the file is genuinely hard to navigate) adds indirection without benefit.
raw_config_io_service.pydeduplication looks tempting but the per-type functions have subtle differences in path resolution and validation that a generic helper must account for.
Docs changes needed: Update Docs/Architekture.md section 2.1 (Project Structure) and section 2.2 (Module Purposes) to reflect any new modules created.
Why this is needed: Large files slow down code review, increase merge conflict probability, and make it harder to find relevant code. Proactively identifying split points before the files grow further ensures the architecture stays navigable. This is a maintenance concern, not a correctness issue.
8. Split api/config.ts into Domain-Specific API Modules
Where: frontend/src/api/config.ts — a single file that currently bundles socket-based jail config, file-based config (jail.d / filter.d / action.d), server settings, log preview, regex testing, and jail activation/deactivation. The endpoints.ts file references ENDPOINTS.health but no API module calls it.
Goal: Break api/config.ts into the modules the architecture specifies: api/file_config.ts (all file-based read/write for jail.d, filter.d, action.d), api/server.ts (server settings, log level, syslog socket, DB path, purge age), and api/health.ts (the health check call using ENDPOINTS.health). The socket-based jail/filter config functions can remain in api/config.ts but should be limited to that domain. Additionally, ban-related functions currently spread across api/dashboard.ts and api/jails.ts should be consolidated into api/bans.ts, and IP geolocation calls from api/jails.ts should move to api/geo.ts.
Possible traps and issues:
- Every hook that imports from
api/config.tsneeds its import path updated.useConfig.tsis the primary consumer; its six internal hooks already map to the split domains, making the update mechanical once the modules are created. api/health.tscurrently has no consumer — theENDPOINTS.healthconstant exists but is unused. Adding the module is straightforward but a correspondinguseHealthhook (or integration intouseServerStatus) is needed for it to be called.- Moving functions between modules changes the public surface; any test that imports API functions by path will fail and must be updated.
- Do not create stub files just to satisfy the architecture map — only create a module when there is at least one function to put in it.
Docs changes needed: Docs/Architekture.md section 3.2 (API Layer table) already lists the target modules; no text changes needed beyond verifying the table matches once the split is done.
Why this is needed: api/config.ts currently serves five conceptually unrelated domains. Developers looking for server settings functions or health check calls have no obvious place to find them, and the file will keep growing as new endpoints are added. Splitting it into focused modules makes the API layer match the documented architecture and makes new additions predictable.
9. Move Hooks Out of Provider Files
Where: frontend/src/providers/AuthProvider.tsx — exports useAuth() (defined at the bottom of the file, around line 111). frontend/src/providers/TimezoneProvider.tsx — exports useTimezone() (defined at the bottom of the file, around line 70).
Goal: Move useAuth() into its own file frontend/src/hooks/useAuth.ts and useTimezone() into frontend/src/hooks/useTimezone.ts. Each provider file should only define and export the context object and the provider component. The hooks should import the context from the provider file and re-export it from the hooks/ directory.
Possible traps and issues:
- Every file that currently imports
useAuthfromproviders/AuthProvidermust update its import path. Run a global search forfrom "../providers/AuthProvider"andfrom "../../providers/AuthProvider"to find all consumers before moving. - The context object (
AuthContext,TimezoneContext) must remain exported from the provider file so the hook can import it. Do not move the context — only the hook function. - The architecture rule is "each hook in its own file under
hooks/". The hook file should be a thin wrapper that callsuseContext(AuthContext)and throws if outside the provider — identical logic to what exists today, just at a different path.
Docs changes needed: None. The architecture doc already lists hooks/useAuth.ts as the canonical location.
Why this is needed: The rule is that all custom hooks live under hooks/, each in their own file. Hooks buried at the bottom of provider files are hard to discover and violate the separation between "provider component" (what wraps the app) and "consumer hook" (what components call). Developers looking for useAuth will search hooks/ and not find it.
10. Split Multi-Hook Files into Single-Hook Files
Where:
frontend/src/hooks/useBlocklist.ts— definesuseBlocklists,useSchedule,useImportLog,useRunImport(four hooks in one file).frontend/src/hooks/useConfig.ts— definesuseJailConfigs,useJailConfigDetail,useGlobalConfig,useServerSettings,useRegexTester,useLogPreview(six hooks in one file).frontend/src/hooks/useJails.ts— defines multiple hooks in one file.
Goal: Split each multi-hook file so that every hook lives in its own file following the hooks/<hookName>.ts naming convention. Create: useBlocklists.ts, useSchedule.ts, useImportLog.ts, useRunImport.ts, useJailConfigs.ts, useJailConfigDetail.ts, useGlobalConfig.ts, useServerSettings.ts, useRegexTester.ts, useLogPreview.ts, and whatever hooks are in useJails.ts. If hooks share internal utilities or types, extract those to a helper module — do not inline them in each new file.
Possible traps and issues:
- All existing import sites must be updated. Each page and component imports specific hooks by name from these files; the import path changes but the imported name stays the same.
- Hooks within the same file may share local helper functions or state logic that is not currently exported. Those helpers must be extracted into a shared internal module (e.g.,
hooks/_configHelpers.ts) or duplicated if they are truly trivial. - After splitting,
useConfig.tsanduseBlocklist.tsthemselves might become barrel re-export files. Decide upfront whether to keep them as barrels (for backward-compatible imports) or delete them entirely and update all import sites. useConfig.tsis the most complex —useJailConfigDetailmay depend on state set up byuseJailConfigs. If they share state, they must either be kept in the same file or refactored to communicate via props/context.
Docs changes needed: Docs/Architekture.md section 3.2 (Hooks table) already lists individual hooks; no text update needed, but verify the table is complete after the split.
Why this is needed: The rule is one hook per file. When six hooks are crammed into one file, the file grows to hundreds of lines and becomes a grab-bag. A developer looking for useLogPreview has no indication it lives inside useConfig.ts. Single-file hooks are immediately discoverable and separately testable.
11. Remove Direct API Calls from Components
Where: Eleven component files call the API layer directly, bypassing the required hooks layer:
frontend/src/components/config/JailsTab.tsx(~lines 36–43): imports and callsaddLogPath,deactivateJail,deleteJailLocalOverride,deleteLogPath,fetchInactiveJails,fetchJailConfigFileContent,updateJailConfigFile,validateJailConfig.frontend/src/components/config/FiltersTab.tsx(~74, ~82, ~130):fetchFilterFile,updateFilterFile,fetchFilters.frontend/src/components/config/ActionsTab.tsx(~100–200):fetchActionFile,updateActionFile,removeActionFromJail,fetchActions.frontend/src/components/config/ConfFilesTab.tsx(~60):fetchListinside auseEffect.frontend/src/components/config/ActivateJailDialog.tsx(~45, ~80):validateJailConfig,activateJail.frontend/src/components/config/AssignActionDialog.tsx(~30, ~70):fetchJails,assignActionToJail.frontend/src/components/config/AssignFilterDialog.tsx:fetchJails,assignFilterToJail.frontend/src/components/config/CreateActionDialog.tsx(~40):createAction.frontend/src/components/config/CreateFilterDialog.tsx:createFilter.frontend/src/components/config/CreateJailDialog.tsx:createJailConfigFile.frontend/src/components/config/ServerHealthSection.tsx(~45, ~80):fetchServiceStatus,fetchFail2BanLog.frontend/src/components/blocklist/BlocklistSourcesSection.tsx(~90):fetchPreviewinsidePreviewDialog.
Goal: Each component must receive its data and mutation callbacks via props or via a hook — it must never import from api/ directly. For each component above: identify which operations it performs, create or extend the appropriate hook in hooks/, and replace the inline API call with a hook call or a callback prop. Dialog components (ActivateJailDialog, AssignActionDialog, etc.) are the trickiest — they typically need a single async action; these can accept an onConfirm: () => Promise<void> callback prop where the caller (the parent tab) provides the hook-backed implementation.
Possible traps and issues:
ServerHealthSectioncombines polling, log streaming, and a one-shot reload into a single component body. The best split is a dedicateduseServerHealthhook that owns the polling state and exposes{ status, log, refresh, lineCount, setLineCount }.- Dialog components that call
validateJailConfigduringuseEffecton open (likeActivateJailDialog) need the validation result as a prop or via a passed-in async callback — not fetched internally. - Converting
fetchJailscalls inside dialogs to a prop means the parent tab must pass the jail list down. This is the correct data-flow direction per the architecture. ConfFilesTabdoes auseEffectfetch that is essentially a custom hook. Extracting it intouseConfFilesis the minimal fix.- After this change, many components will become purely presentational, which is the goal.
Docs changes needed: None. This task brings the code into conformance with the documented rules ("Components receive data via props only — they never call the API directly").
Why this is needed: Components that call the API directly are impossible to unit-test without mocking the network, cannot be reused with alternate data sources, and violate the layered architecture. The hook layer exists precisely to own data fetching so that the component layer stays declarative and testable.
12. Add AbortController Cleanup to All async useEffect Calls
Where: Multiple hooks and components create async fetch calls inside useEffect without an AbortController:
frontend/src/hooks/useBans.ts:doFetchhas no abort, allowing stale in-flight requests to race.frontend/src/hooks/useActionConfig.ts,useFilterConfig.ts,useJailFileConfig.ts: each passes anAbortSignalplaceholder touseConfigItembut the underlyingfetchFncallback ignores it — abort is silently dropped.frontend/src/hooks/useBlocklist.ts(useSchedule):fetchSchedulecalled with no abort.frontend/src/hooks/useMapColorThresholds.ts:loadcalled with no abort.frontend/src/hooks/useTimezoneData.ts:fetchTimezone()with no cleanup returned fromuseEffect.frontend/src/components/config/ConfFilesTab.tsx(~60):useEffectfetch, no cleanup.frontend/src/components/config/ActivateJailDialog.tsx(~45):validateJailConfiginuseEffect, no cleanup.frontend/src/components/config/AssignActionDialog.tsx(~30) andAssignFilterDialog.tsx:fetchJailsinuseEffect, no cleanup.frontend/src/components/blocklist/BlocklistScheduleSection.tsx(~60):setTimeoutreturn not stored in a ref — cannot be cancelled on unmount.
Goal: Every useEffect that initiates an async operation must create an AbortController, pass its signal to the fetch call, and return a cleanup function that calls controller.abort(). For setTimeout usage in components, store the timeout ID in a useRef and clear it in the cleanup. For the useConfigItem pattern, the fetchFn callback type must accept a signal: AbortSignal parameter and pass it through to the underlying API call.
Possible traps and issues:
useConfigItemis a shared internal hook consumed byuseActionConfig,useFilterConfig, anduseJailFileConfig. Changing itsfetchFnsignature to require asignalparameter means updating all three consumers simultaneously — make the change in one commit to avoid half-broken states.AbortErrormust be caught and silently ignored (it is not a real error — it is expected on unmount). Useif (err instanceof DOMException && err.name === "AbortError") return;in the catch block.- Fetch calls inside dialog components that are currently being fixed (Task 11) will migrate to hooks anyway — those abort fixes can be done as part of Task 11 rather than separately.
BlocklistScheduleSection'ssetTimeoutreplacement withuseRefis a one-liner but easy to forget to clear in the cleanup function.
Docs changes needed: None.
Why this is needed: Without abort cleanup, a component that unmounts mid-fetch will still call setState on the unmounted component, producing React "can't perform state update on unmounted component" warnings and, more critically, stale responses from a fast-then-slow request sequence can silently overwrite fresher data. This is a reliability issue in any view that mounts and unmounts frequently (dialogs, paginated tabs).
13. Split Multi-Component Files to One Component Per File
Where:
frontend/src/pages/JailsPage.tsx: definesJailOverviewSection,BanUnbanForm,IpLookupSection, andJailsPagein one file.frontend/src/pages/JailDetailPage.tsx: definesCodeList,JailInfoSection,PatternsSection,BantimeEscalationSection, andJailDetailPage— five components in one file.frontend/src/pages/BlocklistsPage.tsx: definesImportResultDialogandBlocklistsPagein one file.frontend/src/components/config/FiltersTab.tsx: definesFilterDetailandFiltersTab.frontend/src/components/config/ActionsTab.tsx: definesActionDetailandActionsTab.frontend/src/components/config/JailFileForm.tsx: definesStringListEditor,KVEditor,JailSectionPanel,JailFileFormInner, and the outer form component.frontend/src/components/config/FilterForm.tsx: definesKVEditorandFilterFormEditor.frontend/src/components/config/ActionForm.tsx: definesKVEditor,CommandField, andActionFormEditor.frontend/src/components/blocklist/BlocklistSourcesSection.tsx: definesSourceFormDialog,PreviewDialog, andBlocklistSourcesSection.
Goal: Move each sub-component to its own file following the rule "one component per file, filename matches component name". Sub-components that are only used by one parent and have no reuse value outside of it can live in a co-located subdirectory alongside the parent (e.g., pages/jails/BanUnbanForm.tsx). Dialog sub-components extracted from blocklists should go into components/blocklist/. KVEditor is duplicated in FilterForm.tsx and ActionForm.tsx — after extraction it should become a single shared components/config/KVEditor.tsx.
Possible traps and issues:
JailFileForm.tsxis the most complex with five components sharing local type definitions. Extract common prop types to atypes/file or the parent's file before splitting.ImportResultDialoginBlocklistsPage.tsxmust be replaced with a Fluent UI<Dialog>(see Task 14) — do Tasks 13 and 14 for that file in the same pass to avoid touching the file twice.KVEditorappears identically in bothFilterForm.tsxandActionForm.tsx. After extracting it to a shared file, delete both inlined copies and import from the shared location.CodeList,JailInfoSection, etc. inJailDetailPage.tsxare not reused elsewhere. They can live in apages/jail/subfolder. Ensure the main page import path stays valid.
Docs changes needed: Docs/Architekture.md section 3.1 project structure tree would eventually need updating if new subdirectories are created under pages/ or components/. Update section 3.2 Components table to list any newly extracted reusable components.
Why this is needed: Files with multiple components grow unbounded as features are added. A developer looking for BanUnbanForm finds no file by that name — they have to search and discover it is buried hundreds of lines into JailsPage.tsx. The single-component-per-file rule exists so that every component is immediately discoverable by name in the file tree.
14. Replace Custom Modal Overlay with Fluent UI <Dialog>
Where: frontend/src/pages/BlocklistsPage.tsx — the ImportResultDialog component (lines ~23–43) is a hand-built modal using position: fixed, a rgba(0,0,0,0.5) backdrop <div>, and custom inline styles. frontend/src/components/ErrorBoundary.tsx (line ~50) uses a raw <button type="button"> element with inline styles for the reload action in the error fallback UI.
Goal: Replace ImportResultDialog with a standard Fluent UI <Dialog> from @fluentui/react-components, using <DialogSurface>, <DialogTitle>, <DialogBody>, and <DialogActions> slots. For ErrorBoundary, replace the raw <button> with a Fluent UI <Button> and remove all inline style props from the fallback UI, replacing them with makeStyles rules.
Possible traps and issues:
ImportResultDialogusesposition: fixedscroll-locking that Fluent UI<Dialog>handles natively. After the switch, verify that the dialog renders above the navigation sidebar (check the z-index layer order configured intheme/tokens.ts).- The
ErrorBoundaryis a class component (required becausecomponentDidCatchis a class lifecycle method). Fluent UI<Button>andmakeStyleswork fine from class components —makeStylesreturns a hook but it can be called in a wrapper functional component that renders the fallback instead of inline in the classrender(). - After
ImportResultDialogis replaced, the parentBlocklistsPageshould passopenandonDismissprops to the Fluent dialog in the same way it currently controls the hand-built overlay. - This fix should be done in the same pass as Task 13 for
BlocklistsPageto avoid redundant edits to the same file.
Docs changes needed: None.
Why this is needed: The architecture explicitly forbids building custom modal overlays ("Use Fluent UI <Dialog> for modals and confirmations — never build custom modal overlays"). Hand-built modals bypass Fluent UI's accessibility tree (focus trap, ARIA roles, portal rendering) and duplicate boilerplate that the library provides. A Fluent <Dialog> is keyboard-accessible, screen-reader-compatible, and inherits the theme automatically.
15. Replace Inline Styles and Hardcoded Values with makeStyles and Design Tokens
Where: Inline style={...} props and hardcoded pixel/rem values appear throughout the codebase. Major concentrations:
frontend/src/pages/JailsPage.tsx:fontFamily: "Consolas, 'Courier New', monospace",fontSize: "0.85rem",marginLeft: "8px",gap: "6px".frontend/src/pages/JailDetailPage.tsx: multiplestyle={{ color: tokens.colorNeutralForeground3 }},fontFamily: "Consolas, ...", andmarginTop: tokens.spacingVerticalSas inline props.frontend/src/pages/HistoryPage.tsx: hardcodedfontSizestrings inmakeStyles("0.85rem","0.75rem","0.9rem","0.9em"), inline styles on timeline items.frontend/src/pages/MapPage.tsx: loading spinner, filter bar, pagination footer — all inline.frontend/src/components/config/RawConfigSection.tsx:<textarea style={{ fontFamily:"monospace", fontSize:"0.85rem", borderLeft:"3px solid…" }}>.frontend/src/components/config/AutoSaveIndicator.tsx:style={{ minWidth: 80 }},style={{ opacity, transform, transition }}.frontend/src/components/config/JailFileForm.tsx,FilterForm.tsx,ActionForm.tsx:style={{ gap: 4 }},style={{ marginBottom: 8 }},style={{ width: 140 }}, etc.frontend/src/components/config/blocklistStyles.ts:fontSize: "12px"(×2),gap: "2px".frontend/src/components/blocklist/BlocklistScheduleSection.tsxandBlocklistSourcesSection.tsx: multiple inline style props with hardcoded pixel strings.frontend/src/components/jail/BannedIpsSection.tsx:style={{ color: … }},style={{ minWidth:"80px" }}.frontend/src/components/BanTrendChart.tsx,JailDistributionChart.tsx,TopCountriesBarChart.tsx,TopCountriesPieChart.tsx: Recharts tooltip divs withfontSize: "13px",borderRadius: "4px",padding: "8px 12px"duplicated across all four files.frontend/src/components/WorldMap.tsx:strokeWidth: 0.75,fontSize: "9px"inmakeStyles, and tooltip positioned with inlinestyle={{ left: tooltip.x + 12, top: tooltip.y + 12 }}.frontend/src/components/SetupGuard.tsx: spinner container with inline flex styles.
Goal: All custom styling must use makeStyles from @fluentui/react-components. All size and colour values must use design tokens (tokens.spacingVerticalM, tokens.fontSizeBase200, tokens.fontFamilyMonospace, etc.) rather than literal pixel or rem strings. For the four chart tooltip components, extract a shared ChartTooltip wrapper component (or a chartTooltipStyle constant in utils/chartTheme.ts) to eliminate the duplication. Hardcoded values inside existing makeStyles calls ("0.85rem", "12px", "9px") must be replaced with the appropriate token.
Possible traps and issues:
tokens.fontFamilyMonospaceexists in Fluent UI v9 and should replace everyfontFamily: "Consolas, 'Courier New', monospace"andfontFamily: "monospace"occurrence.- For Recharts chart elements (
tick,activeDot,outerRadius,margin), Recharts does not accept Griffel class names — those numeric props must use named constants defined once inutils/chartTheme.ts(e.g.,CHART_TICK_FONT_SIZE = 12). This is the correct approach because Recharts bypasses the DOM styling entirely. - The
WorldMaptooltip position (left: x + 12, top: y + 12) is a dynamic inline style driven by runtime coordinates — this one is acceptable as a genuine dynamic value and does not needmakeStyles. Document the exemption with a comment. AutoSaveIndicator'sopacity,transform, andtransitionfor the save-state animation may be better expressed as Griffelselectorsor viamergeClasseswith conditional class names.minWidth: 80onAutoSaveIndicatorandminWidth: "80px"onBannedIpsSection<Dropdown>should use a namedmakeStylesconstant rather than a raw number, since there is no exact Fluent token for these values.
Docs changes needed: None.
Why this is needed: Inline styles bypass Griffel's atomic CSS engine, produce non-deduplicatable style nodes, and cannot respond to theme changes. Hardcoded pixel values break when the user switches to a different Fluent UI theme with different spacing scales. The project rule states that makeStyles with design tokens is the only permitted styling mechanism.
16. Deduplicate TimeRange Type
Where: types/ban.ts line ~13, types/map.ts line ~6, and types/history.ts line ~7 each contain an identical type alias: export type TimeRange = "24h" | "7d" | "30d" | "365d";.
Goal: Keep the single definition in types/ban.ts (it is the most general domain) and replace the duplicate definitions in types/map.ts and types/history.ts with import type { TimeRange } from "./ban" followed by a re-export if consumers import TimeRange from those files. Verify that all import sites resolve correctly after the change.
Possible traps and issues:
- Search for all
import { TimeRange }andimport type { TimeRange }occurrences to identify every consumer. If a consumer imports fromtypes/historyortypes/map, either update that import to point totypes/ban, or re-export from the domain file withexport type { TimeRange } from "./ban"to maintain backward-compatible imports without duplication. TIME_RANGE_LABELSintypes/ban.ts(a runtime object mappingTimeRangekeys to display strings) must also move toutils/as part of Task 17 — coordinate these two changes to avoid touchingtypes/ban.tstwice.
Docs changes needed: None.
Why this is needed: Three identical type definitions will diverge the moment someone changes the set of allowed time ranges in one file but forgets the others. The project rule is "define it once, import everywhere."
17. Move Runtime Constants Out of types/
Where: frontend/src/types/ban.ts — exports BAN_ORIGIN_FILTER_LABELS (a Record<string, string> mapping ban origin keys to display strings) and TIME_RANGE_LABELS (a Record<TimeRange, string> mapping time-range keys to display strings), both declared with as const. These are runtime JavaScript objects, not type declarations.
Goal: Move BAN_ORIGIN_FILTER_LABELS and TIME_RANGE_LABELS to frontend/src/utils/constants.ts (create the file if it does not exist, or add to an existing constants utility). Update all import sites. The types/ban.ts file should contain only interface, type, and enum declarations with no runtime-evaluatable code.
Possible traps and issues:
- Search for every file that imports
BAN_ORIGIN_FILTER_LABELSorTIME_RANGE_LABELSfromtypes/banand update the import path toutils/constants. - If
utils/constants.tsdoes not yet exist, create it. If it already exists, append the constants to it — do not create a second constants file. - After the move,
types/ban.tsshould not need to import fromutils/— the dependency direction isutils/→types/(utils import types), never the reverse.
Docs changes needed: None.
Why this is needed: The types/ directory is meant to contain zero runtime code — only TypeScript type declarations that are erased at compile time. A runtime const object in a types file is confusing, breaks the "no runtime code in types/" rule, and pollutes the bundle with a lookup table alongside pure type transpile artifacts.
18. Fix Type-Unsafe Casts and Missing import type Qualifiers
Where:
frontend/src/pages/BlocklistsPage.tsx(~line 50):const safeUseBlocklistStyles = useBlocklistStyles as unknown as () => { root: string }— a double cast that discards the real return type.frontend/src/hooks/useDashboardCountryData.ts:setBans(data.bans as DashboardBanItem[])— an unchecked cast on an API response.frontend/src/pages/HistoryPage.tsx(~line 31):TableColumnDefinitionis a TypeScript-only type imported without thetypemodifier in a mixed import from@fluentui/react-components.frontend/src/components/jail/BannedIpsSection.tsx(~line 5):TableColumnDefinitioninline-typed in a value import.frontend/src/components/BanTable.tsx(~line 4): same pattern.
Goal: Remove the as unknown as cast in BlocklistsPage.tsx — if useBlocklistStyles has the wrong return type signature, fix the makeStyles definition rather than casting around it. Replace data.bans as DashboardBanItem[] in useDashboardCountryData.ts with a type guard function that validates the shape at runtime (or remove the cast if TypeScript already infers the correct type from a typed API call). Convert all mixed value+type imports that include pure types to either use the inline type modifier (import { type TableColumnDefinition, createTableColumn }) or a separate import type { TableColumnDefinition } statement.
Possible traps and issues:
- The
as unknown ascast inBlocklistsPage.tsxlikely exists becauseblocklistStyles.tsexportsuseBlocklistStyleswith a type signature that does not match how it is consumed. The fix is to correct themakeStylesdefinitions inblocklistStyles.tsso the return type is accurate — then the cast is unnecessary. - A runtime type guard for
DashboardBanItem[]does not need to be exhaustive — checking thatdata.bansis an array and that the first element has anipstring field is sufficient to catch serialization bugs without adding heavy validation overhead. - The
import typechange is a zero-risk change that only affects the TypeScript compiler output (unused import elimination); it never changes runtime behavior.
Docs changes needed: None.
Why this is needed: as unknown as T is the TypeScript equivalent of any — it silences the type checker without any safety guarantee. If the actual runtime value does not match T, the error is deferred to a cryptic downstream failure. The import type issue is a code hygiene violation — it prevents tree-shaking tools from safely dropping type-only imports and misleads readers into thinking a type has a runtime value.
19. Move Utility Functions Out of Page Modules
Where: frontend/src/pages/HistoryPage.tsx — the areHistoryQueriesEqual() function (lines ~135–146) is a pure comparison utility defined inside the page module. It takes two query objects and returns a boolean — it has no React dependency and no side effects.
Goal: Move areHistoryQueriesEqual to frontend/src/utils/ (either into a new queryUtils.ts or an existing utility file if a suitable one exists). Import it back into HistoryPage.tsx from that location. Verify the function has no implicit dependency on page-local types — if it relies on HistoryQuery from types/history.ts, it can still live in utils by importing that type.
Possible traps and issues:
- This is a mechanical move with no behavioral change. The only risk is a stale import if the function is used in more than one place (confirm with a search before moving).
- If similar utility functions exist in other page files (a search for
functiondeclarations inside page files is worthwhile), extract them in the same pass.
Docs changes needed: None.
Why this is needed: The architecture rule is that pages contain "no business logic — only orchestration." A pure utility function with no JSX and no React dependency does not belong in a page module. It is invisible to other developers who might need the same comparison, cannot be independently unit-tested, and adds noise to the page's module surface.
20. Relocate Misplaced Files
Where:
frontend/src/data/isoNumericToAlpha2.ts— a pure static lookup table (Record<number, string>) with no React dependency. Thedata/directory is not in the architecture specification.frontend/src/api/map.test.ts— a test file sitting directly inapi/among source modules.frontend/src/theme/commonStyles.ts— exportsuseCommonSectionStylesanduseCardStyles, which aremakeStyles-based component style hooks, not theme token definitions.frontend/src/components/config/configStyles.ts— a standalonemakeStylesfile for config components instead of styles being co-located in each component file.frontend/src/components/blocklist/blocklistStyles.ts— same issue.
Goal:
- Move
data/isoNumericToAlpha2.tstoutils/isoNumericToAlpha2.ts. Delete thedata/directory. - Move
api/map.test.tstoapi/__tests__/map.test.ts(create the__tests__directory if needed). - Move
theme/commonStyles.ts: if the styles in it are shared across multiple components with no better home, move them to the component directory that uses them most, or rename the file tocomponents/commonStyles.ts. Thetheme/directory must contain onlycustomTheme.tsand token-related files. - For
configStyles.tsandblocklistStyles.ts: inline each exportedmakeStyleshook into the component file that uses it, or keep the file in the same directory as the components but document that it is a style helper rather than a theme definition. The architecture rule is "co-locate styles in the same file as the component" — a shared styles file is acceptable only if documented as an explicit exception for styles used by multiple components in the same subdirectory.
Possible traps and issues:
- Moving
isoNumericToAlpha2.tschanges its import path inWorldMap.tsxand any other consumer — update all import sites. - Moving
api/map.test.tsto a__tests__subdirectory requires the test runner config (vitest.config.ts) to includeapi/__tests__/if it only scans__tests__/top-level directories — verify the glob pattern first. theme/commonStyles.tsis imported by multiple components. Moving it requires updating all consumers. Verify by grepping forcommonStylesbefore touching it.configStyles.tsis imported by several files incomponents/config/. Moving its contents inline into each component file means duplicating styles that happen to be identical — evaluate whether a shared file is a justified exception before breaking it up.
Docs changes needed: Docs/Architekture.md section 3.1 (Project Structure) shows theme/ with two files; update to reflect the correct contents after the move.
Why this is needed: Files placed in the wrong directory mislead developers about where to find things and where to put new related code. A data/ directory that is not in the specification is a dead-end — future developers may put unrelated lookup tables there, or avoid it entirely because its purpose is unclear. Test files mixed alongside source modules are harder to exclude from coverage and harder to find.
21. Remove Test Scaffolding from Production Hooks
Where: frontend/src/hooks/useMapData.ts — exports getLastArgs() and setMapData() as named exports. These functions are test-only scaffolding (they expose internal state for assertion in tests) and have no legitimate use in production code.
Goal: Remove getLastArgs and setMapData from useMapData.ts. If the tests that use them cannot function without this scaffolding, refactor those tests to test the hook's public return value and side effects via renderHook from React Testing Library instead of reading internal state directly.
Possible traps and issues:
- Find every test that imports
getLastArgsorsetMapDatafromuseMapDataand rewrite them before removing the exports. Removing the exports first will cause a type error that points to the test files. renderHookfrom@testing-library/reactis the correct tool for testing hook return values without exposing internals.- If
setMapDatais used to inject test data, the test should instead mock the API layer (api/map.ts) so that whenuseMapDatacalls the API, it gets controlled data — no internal exposure required.
Docs changes needed: None.
Why this is needed: Test scaffolding exported from production hooks ships in the production bundle, pollutes the module's public API, and creates a false dependency between the hook's internal implementation and its tests. If the internal structure of useMapData changes, tests that rely on getLastArgs/setMapData will break even if the observable behavior is unchanged. Testing via the public interface (return values, rendered output) makes refactoring safe.
22. Surface Error State Instead of console.warn in useSetup
Where: frontend/src/hooks/useSetup.ts (~line 60) — a console.warn("Setup status check failed:", ...) is the only response to a failed setup status fetch. The error state variable is not set.
Goal: Replace the console.warn with setError(err instanceof Error ? err.message : "Unknown error") so that the error is surfaced to the consuming component via the hook's public interface. Remove the console.warn entirely — the hook already has an error state for this purpose.
Possible traps and issues:
- Verify that
SetupPage.tsx(or whatever consumesuseSetup) already renders theerrorstate. If it does not, add an error message display (a Fluent UI<MessageBar intent="error">) so the surfaced error is actually visible to the user. console.warnin hooks that ship to production will produce console noise for end users and may appear in error monitoring tools as unexpected warnings. The rule is to handle errors in state, not in the console.
Docs changes needed: None.
Why this is needed: A silent console.warn means the first sign of a setup check failure is a spinner that never resolves — the user has no feedback and no actionable message. Error state in hooks exists precisely to enable the UI layer to display a meaningful error to the user.