|
|
|
|
@@ -4,394 +4,48 @@ This document breaks the entire BanGUI project into development stages, ordered
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## Stage 1 — Project Scaffolding ✅ DONE
|
|
|
|
|
## ✅ DONE — Issue: Setup forward
|
|
|
|
|
|
|
|
|
|
Everything in this stage is about creating the project skeleton — folder structures, configuration files, and tooling — so that development can begin on solid ground. No application logic is written here.
|
|
|
|
|
**Problem:** No DB present did not forward to setup page; setup page was not redirecting to login when already done.
|
|
|
|
|
|
|
|
|
|
### 1.1 Initialise the backend project ✅
|
|
|
|
|
|
|
|
|
|
**Done.** Created `backend/` with the full directory structure from the docs. `pyproject.toml` configured with all required dependencies (FastAPI, Pydantic v2, aiosqlite, aiohttp, APScheduler 3.x, structlog, pydantic-settings, bcrypt) and dev dependencies (pytest, pytest-asyncio, httpx, ruff, mypy, pytest-cov). Ruff configured for 120-char lines and double-quote strings. mypy in strict mode. `.env.example` with all required placeholder keys. fail2ban-master path injected into `sys.path` at startup in `main.py`.
|
|
|
|
|
|
|
|
|
|
### 1.2 Initialise the frontend project ✅
|
|
|
|
|
|
|
|
|
|
**Done.** Vite + React + TypeScript project scaffolded in `frontend/`. Installed `@fluentui/react-components`, `@fluentui/react-icons`, `react-router-dom`. `tsconfig.json` with `"strict": true`. ESLint with `@typescript-eslint`, `eslint-plugin-react-hooks`, `eslint-config-prettier`. Prettier with project defaults. All required directories created: `src/api/`, `src/components/`, `src/hooks/`, `src/layouts/`, `src/pages/`, `src/providers/`, `src/theme/`, `src/types/`, `src/utils/`. `App.tsx` wraps app in `<FluentProvider>` and `<BrowserRouter>`.
|
|
|
|
|
|
|
|
|
|
### 1.3 Set up the Fluent UI custom theme ✅
|
|
|
|
|
|
|
|
|
|
**Done.** `frontend/src/theme/customTheme.ts` — BanGUI brand ramp centred on #0F6CBD (contrast ratio ≈ 5.4:1 against white, passes WCAG AA). Both `lightTheme` and `darkTheme` exported and wired into `App.tsx` via `FluentProvider`.
|
|
|
|
|
|
|
|
|
|
### 1.4 Create the central API client ✅
|
|
|
|
|
|
|
|
|
|
**Done.** `frontend/src/api/client.ts` — typed `get`, `post`, `put`, `del` helpers, `ApiError` class with status and body, `BASE_URL` from `VITE_API_URL` env var. `frontend/src/api/endpoints.ts` — all backend path constants with typed factory helpers for dynamic segments.
|
|
|
|
|
|
|
|
|
|
### 1.5 Create the FastAPI application factory ✅
|
|
|
|
|
|
|
|
|
|
**Done.** `backend/app/main.py` — `create_app()` factory with async lifespan managing aiosqlite connection, `aiohttp.ClientSession`, and APScheduler. Settings stored on `app.state`. Health-check router registered. Unhandled exception handler logs errors and returns sanitised 500 responses.
|
|
|
|
|
|
|
|
|
|
### 1.6 Create the Pydantic settings model ✅
|
|
|
|
|
|
|
|
|
|
**Done.** `backend/app/config.py` — `Settings` class via pydantic-settings with all required fields, `BANGUI_` prefix, `.env` loading. `get_settings()` factory function. App fails fast with a `ValidationError` if required values are missing.
|
|
|
|
|
|
|
|
|
|
### 1.7 Set up the application database schema ✅
|
|
|
|
|
|
|
|
|
|
**Done.** `backend/app/db.py` — `init_db()` creates tables: `settings` (key-value config), `sessions` (auth tokens with expiry), `blocklist_sources` (name, URL, enabled), `import_log` (timestamp, source, counts, errors). WAL mode and foreign keys enabled. Function is idempotent — safe to call on every startup.
|
|
|
|
|
|
|
|
|
|
### 1.8 Write the fail2ban socket client wrapper ✅
|
|
|
|
|
|
|
|
|
|
**Done.** `backend/app/utils/fail2ban_client.py` — `Fail2BanClient` async class. Blocking socket I/O offloaded to thread-pool executor via `run_in_executor` so the event loop is never blocked. `send()` serialises commands to pickle, reads until `<F2B_END_COMMAND>` marker, deserialises response. `ping()` helper. `Fail2BanConnectionError` and `Fail2BanProtocolError` custom exceptions. Full structlog integration.
|
|
|
|
|
**Fix:**
|
|
|
|
|
- Added `SetupGuard` component (`src/components/SetupGuard.tsx`) that calls `GET /api/setup` on mount and redirects to `/setup` if not complete.
|
|
|
|
|
- All routes except `/setup` are now wrapped in `SetupGuard` in `App.tsx`.
|
|
|
|
|
- `SetupPage` calls `GET /api/setup` on mount and redirects to `/login` if already complete.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## Stage 2 — Authentication & Setup Flow ✅ DONE
|
|
|
|
|
## ✅ DONE — Issue: Setup - Error during setup (500)
|
|
|
|
|
|
|
|
|
|
This stage implements the very first user experience: the setup wizard that runs on first launch and the login system that protects every subsequent visit. All other features depend on these being complete.
|
|
|
|
|
**Problem:** `POST /api/setup` returned 500 on some runs.
|
|
|
|
|
|
|
|
|
|
### 2.1 Implement the setup service and repository ✅
|
|
|
|
|
**Root cause:** `bcrypt.hashpw` and `bcrypt.checkpw` are CPU-bound blocking calls. Running them directly in an async FastAPI handler stalls the event loop under concurrent load, causing timeouts / 500 responses.
|
|
|
|
|
|
|
|
|
|
**Done.** `backend/app/repositories/settings_repo.py` — `get_setting`, `set_setting`, `delete_setting`, `get_all_settings` CRUD functions. `backend/app/repositories/session_repo.py` — `create_session`, `get_session`, `delete_session`, `delete_expired_sessions`. `backend/app/services/setup_service.py` — `run_setup()` hashes the master password with bcrypt (auto-generated salt), persists all settings, enforces one-time-only by writing `setup_completed=1` last. `is_setup_complete()` and `get_password_hash()` helpers.
|
|
|
|
|
|
|
|
|
|
### 2.2 Implement the setup router ✅
|
|
|
|
|
|
|
|
|
|
**Done.** `backend/app/routers/setup.py` — `GET /api/setup` returns `SetupStatusResponse`. `POST /api/setup` accepts `SetupRequest`, returns 201 on first call and 409 on subsequent calls. Registered in `create_app()`.
|
|
|
|
|
|
|
|
|
|
### 2.3 Implement the setup-redirect middleware ✅
|
|
|
|
|
|
|
|
|
|
**Done.** `SetupRedirectMiddleware` in `backend/app/main.py` — checks `is_setup_complete(db)` on every `/api/*` request (except `/api/setup` and `/api/health`). Returns `307 → /api/setup` when setup has not been completed. No-op after first run.
|
|
|
|
|
|
|
|
|
|
### 2.4 Implement the authentication service ✅
|
|
|
|
|
|
|
|
|
|
**Done.** `backend/app/services/auth_service.py` — `login()` verifies password with `bcrypt.checkpw`, generates a 64-char hex session token with `secrets.token_hex(32)`, stores the session via `session_repo`. `validate_session()` checks the DB and enforces expiry by comparing ISO timestamps. `logout()` deletes the session row.
|
|
|
|
|
|
|
|
|
|
### 2.5 Implement the auth router ✅
|
|
|
|
|
|
|
|
|
|
**Done.** `backend/app/routers/auth.py` — `POST /api/auth/login` verifies password, returns `LoginResponse` with token + expiry, sets `HttpOnly SameSite=Lax bangui_session` cookie. `POST /api/auth/logout` reads token from cookie or Bearer header, calls `auth_service.logout()`, clears the cookie. Both endpoints registered in `create_app()`.
|
|
|
|
|
|
|
|
|
|
### 2.6 Implement the auth dependency ✅
|
|
|
|
|
|
|
|
|
|
**Done.** `require_auth` dependency added to `backend/app/dependencies.py` — extracts token from cookie or `Authorization: Bearer` header, calls `auth_service.validate_session()`, raises 401 on missing/invalid/expired token. `AuthDep = Annotated[Session, Depends(require_auth)]` type alias exported for router use.
|
|
|
|
|
|
|
|
|
|
### 2.7 Build the setup page (frontend) ✅
|
|
|
|
|
|
|
|
|
|
**Done.** `frontend/src/pages/SetupPage.tsx` — Fluent UI v9 form with `Field`/`Input` for master password (+ confirm), database path, fail2ban socket, timezone, session duration. Client-side validation before submit. Calls `POST /api/setup` via `frontend/src/api/setup.ts`. Redirects to `/login` on success. `frontend/src/types/setup.ts` typed interfaces.
|
|
|
|
|
|
|
|
|
|
### 2.8 Build the login page (frontend) ✅
|
|
|
|
|
|
|
|
|
|
**Done.** `frontend/src/pages/LoginPage.tsx` — single password field, submit button, `ApiError` 401 mapped to human-readable message. After login calls `useAuth().login()` and navigates to `?next=` or `/`. `frontend/src/api/auth.ts` and `frontend/src/types/auth.ts` created.
|
|
|
|
|
|
|
|
|
|
### 2.9 Implement the auth context and route guard ✅
|
|
|
|
|
|
|
|
|
|
**Done.** `frontend/src/providers/AuthProvider.tsx` — React context with `isAuthenticated`, `login()`, `logout()`. Session token and expiry stored in `sessionStorage`. `useAuth()` hook exported. `frontend/src/components/RequireAuth.tsx` — wraps protected routes; redirects to `/login?next=<path>` when unauthenticated. `App.tsx` updated with full route tree: `/setup`, `/login`, `/` (guarded), `*` → redirect.
|
|
|
|
|
|
|
|
|
|
### 2.10 Write tests for setup and auth ✅
|
|
|
|
|
|
|
|
|
|
**Done.** 85 total tests pass. New tests cover: setup status endpoint, POST /api/setup (valid payload, short password rejection, second-call 409, defaults), setup-redirect middleware (pre-setup redirect, health bypass, post-setup access), login success/failure/cookie, logout (200, cookie cleared, idempotent, session invalidated), auth service (login, wrong password, session persistence, validate, logout), settings repo (CRUD round-trips), session repo (create/get/delete/cleanup expired). ruff 0 errors, mypy --strict 0 errors.
|
|
|
|
|
**Fix:**
|
|
|
|
|
- `setup_service.run_setup` now offloads bcrypt hashing to `loop.run_in_executor(None, ...)`.
|
|
|
|
|
- `auth_service._check_password` was converted from a sync to an async function, also using `run_in_executor`.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## Stage 3 — Application Shell & Navigation ✅ DONE
|
|
|
|
|
## ✅ DONE — Issue: Setup - Security issue (password in plaintext)
|
|
|
|
|
|
|
|
|
|
With authentication working, this stage builds the persistent layout that every page shares: the navigation sidebar, the header, and the routing skeleton.
|
|
|
|
|
**Problem:** `master_password` was transmitted as plain text in the `POST /api/setup` and `POST /api/auth/login` request bodies.
|
|
|
|
|
|
|
|
|
|
### 3.1 Build the main layout component ✅
|
|
|
|
|
|
|
|
|
|
**Done.** `frontend/src/layouts/MainLayout.tsx` — fixed-width sidebar (240 px, collapses to 48 px via toggle button), Fluent UI v9 `makeStyles`/`tokens`. Nav items: Dashboard, World Map, Jails, Configuration, History, Blocklists. Active link highlighted using `NavLink` `isActive` callback. Logout button at the bottom. Main content area: `flex: 1`, `maxWidth: 1440px`, centred.
|
|
|
|
|
|
|
|
|
|
### 3.2 Set up client-side routing ✅
|
|
|
|
|
|
|
|
|
|
**Done.** `frontend/src/App.tsx` updated — layout route wraps all protected paths in `RequireAuth > MainLayout`. Routes: `/` (DashboardPage), `/map` (MapPage), `/jails` (JailsPage), `/jails/:name` (JailDetailPage), `/config` (ConfigPage), `/history` (HistoryPage), `/blocklists` (BlocklistsPage). Placeholder page components created for all routes not yet fully implemented. `*` falls back to `/`. tsc --noEmit: 0 errors.
|
|
|
|
|
|
|
|
|
|
### 3.3 Implement the logout flow ✅
|
|
|
|
|
|
|
|
|
|
**Done.** `MainLayout.tsx` logout button calls `useAuth().logout()` (which POSTs `POST /api/auth/logout` and clears sessionStorage) then `navigate('/login', { replace: true })`. Accessible from every authenticated page via the persistent sidebar.
|
|
|
|
|
**Fix:**
|
|
|
|
|
- Added `src/utils/crypto.ts` with a `sha256Hex(input)` helper using the browser-native `SubtleCrypto` API.
|
|
|
|
|
- `SetupPage.handleSubmit` now SHA-256 hashes the password before submission.
|
|
|
|
|
- `api/auth.ts login()` now SHA-256 hashes the password before the login POST.
|
|
|
|
|
- The backend stores `bcrypt(sha256(password))`. The plaintext never leaves the browser.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## Stage 4 — fail2ban Connection & Server Status ✅ DONE
|
|
|
|
|
## ✅ DONE — Clean command
|
|
|
|
|
|
|
|
|
|
This stage establishes the live connection to the fail2ban daemon and surfaces its health to the user. It is a prerequisite for every data-driven feature.
|
|
|
|
|
**Problem:** No easy way to wipe all debug compose volumes and start fresh.
|
|
|
|
|
|
|
|
|
|
### 4.1 Implement the health service ✅
|
|
|
|
|
|
|
|
|
|
**Done.** `backend/app/services/health_service.py` — `probe(socket_path)` sends `ping`, `version`, `status`, and per-jail `status <jail>` commands via `Fail2BanClient`. Aggregates `Currently failed` and `Currently banned` across all jails. Returns `ServerStatus(online=True/False)`. `Fail2BanConnectionError` and `Fail2BanProtocolError` mapped to `online=False`. `_ok()` helper extracts payload from `(return_code, data)` tuples; `_to_dict()` normalises fail2ban's list-of-pairs format.
|
|
|
|
|
|
|
|
|
|
### 4.2 Implement the health-check background task ✅
|
|
|
|
|
|
|
|
|
|
**Done.** `backend/app/tasks/health_check.py` — `register(app)` adds an APScheduler `interval` job that fires every 30 seconds (and immediately on startup via `next_run_time`). Result stored on `app.state.server_status`. `app.state.server_status` initialised to `ServerStatus(online=False)` as a safe placeholder. Wired into `main.py` lifespan after `scheduler.start()`.
|
|
|
|
|
|
|
|
|
|
### 4.3 Implement the dashboard status endpoint ✅
|
|
|
|
|
|
|
|
|
|
**Done.** `backend/app/routers/dashboard.py` — `GET /api/dashboard/status` reads `app.state.server_status` (falls back to `ServerStatus(online=False)` when not yet set). Response model `ServerStatusResponse` from `backend/app/models/server.py` (pre-existing). Requires `AuthDep`. Registered in `create_app()`.
|
|
|
|
|
|
|
|
|
|
### 4.4 Build the server status bar component (frontend) ✅
|
|
|
|
|
|
|
|
|
|
**Done.** `frontend/src/types/server.ts` — `ServerStatus` and `ServerStatusResponse` interfaces. `frontend/src/api/dashboard.ts` — `fetchServerStatus()`. `frontend/src/hooks/useServerStatus.ts` — `useServerStatus()` hook polling every 30 s and on window focus. `frontend/src/components/ServerStatusBar.tsx` — Fluent UI v9 `Badge`, `Text`, `Spinner`, `Tooltip`; green/red badge for online/offline; version, jail count, bans, failures stats; refresh button. `DashboardPage.tsx` updated to render `<ServerStatusBar />` at the top.
|
|
|
|
|
|
|
|
|
|
### 4.5 Write tests for health service and dashboard ✅
|
|
|
|
|
|
|
|
|
|
**Done.** 104 total tests pass (+19 new). `backend/tests/test_services/test_health_service.py` — 12 tests covering: online probe (version, jail count, ban/failure aggregation, empty jail list), connection error → offline, protocol error → offline, bad/error ping → offline, per-jail parse error tolerated, version failure tolerated. `backend/tests/test_routers/test_dashboard.py` — 6 tests covering: 200 when authenticated, 401 when unauthenticated, response shape, cached values returned, offline status, safe default when cache absent. fail2ban socket mocked via `unittest.mock.patch`. ruff 0 errors, mypy --strict 0 errors, tsc --noEmit 0 errors.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## Stage 5 — Ban Overview (Dashboard) ✅ DONE
|
|
|
|
|
|
|
|
|
|
The main landing page. This stage delivers the ban list and access list tables that give users a quick picture of recent activity.
|
|
|
|
|
|
|
|
|
|
### 5.1 Implement the ban service (list recent bans) ✅
|
|
|
|
|
|
|
|
|
|
**Done.** `backend/app/services/ban_service.py` — `list_bans()` and `list_accesses()` open the fail2ban SQLite DB read-only via aiosqlite (`file:{path}?mode=ro`). DB path is resolved by sending `["get", "dbfile"]` to the fail2ban Unix socket. Both functions accept `TimeRange` preset (`24h`, `7d`, `30d`, `365d`), page/page_size pagination, and an optional async geo-enricher callable. Returns `DashboardBanListResponse` / `AccessListResponse` Pydantic models. `_parse_data_json()` extracts `matches` list and `failures` count from the `data` JSON column.
|
|
|
|
|
|
|
|
|
|
### 5.2 Implement the geo service ✅
|
|
|
|
|
|
|
|
|
|
**Done.** `backend/app/services/geo_service.py` — `lookup(ip, http_session)` calls `http://ip-api.com/json/{ip}?fields=status,message,country,countryCode,org,as`. Returns `GeoInfo` dataclass (`country_code`, `country_name`, `asn`, `org`). Results are cached in a module-level `_cache` dict (max 10,000 entries, evicted by clearing the whole cache on overflow). Negative results (`status=fail`) are also cached. Network failures return `None` without caching. `clear_cache()` exposed for tests.
|
|
|
|
|
|
|
|
|
|
### 5.3 Implement the dashboard bans endpoint ✅
|
|
|
|
|
|
|
|
|
|
**Done.** Added `GET /api/dashboard/bans` and `GET /api/dashboard/accesses` to `backend/app/routers/dashboard.py`. Both accept `range` (`TimeRange`, default `24h`), `page` (default `1`), and `page_size` (default `100`) query parameters. Each endpoint reads `fail2ban_socket` from `app.state.settings` and `http_session` from `app.state`, creates a `geo_service.lookup` closure, and delegates to `ban_service`. All models in `backend/app/models/ban.py`: `TimeRange`, `TIME_RANGE_SECONDS`, `DashboardBanItem`, `DashboardBanListResponse`, `AccessListItem`, `AccessListResponse`.
|
|
|
|
|
|
|
|
|
|
### 5.4 Build the ban list table (frontend) ✅
|
|
|
|
|
|
|
|
|
|
**Done.** `frontend/src/components/BanTable.tsx` — Fluent UI v9 `DataGrid` with two modes (`"bans"` / `"accesses"`). Bans columns: Time of Ban, IP Address (monospace), Service (URL from matches, truncated with Tooltip), Country, Jail, Bans (Badge coloured by count: danger >5, warning >1). Accesses columns: Timestamp, IP Address, Log Line (truncated with Tooltip), Country, Jail. Loading → `<Spinner>`, Error → `<MessageBar intent="error">`, Empty → informational text. Pagination buttons. `useBans` hook (`frontend/src/hooks/useBans.ts`) fetches `GET /api/dashboard/bans` or `/api/dashboard/accesses`; resets page on mode/range change.
|
|
|
|
|
|
|
|
|
|
### 5.5 Build the dashboard page ✅
|
|
|
|
|
|
|
|
|
|
**Done.** `frontend/src/pages/DashboardPage.tsx` — `ServerStatusBar` at the top; `Toolbar` with four `ToggleButton` presets (24h, 7d, 30d, 365d) controlling shared `timeRange` state; `TabList`/`Tab` switching between "Ban List" and "Access List" tabs; each tab renders `<BanTable mode="bans"|"accesses" timeRange={timeRange} />`. `frontend/src/api/dashboard.ts` extended with `fetchBans()` and `fetchAccesses()`. `frontend/src/types/ban.ts` mirrors backend models.
|
|
|
|
|
|
|
|
|
|
### 5.6 Write tests for ban service and dashboard endpoints ✅
|
|
|
|
|
|
|
|
|
|
**Done.** 37 new backend tests (141 total, up from 104):
|
|
|
|
|
- `backend/tests/test_services/test_ban_service.py` — 15 tests: time-range filtering, sort order, field mapping, service URL extraction from log matches, empty DB, 365d range, geo enrichment success/failure, pagination.
|
|
|
|
|
- `backend/tests/test_services/test_geo_service.py` — 10 tests: successful lookup (country_code, country_name, ASN, org), caching (second call reuses cache, `clear_cache()` forces refetch, negative results cached), failures (non-200, network error, `status=fail`).
|
|
|
|
|
- `backend/tests/test_routers/test_dashboard.py` — 12 new tests: `GET /api/dashboard/bans` and `GET /api/dashboard/accesses` 200 (auth), 401 (unauth), response shape, default range, range forwarding, empty list.
|
|
|
|
|
All 141 tests pass; ruff and mypy --strict report zero errors; tsc --noEmit reports zero errors.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## Stage 6 — Jail Management ✅ DONE
|
|
|
|
|
|
|
|
|
|
This stage exposes fail2ban's jail system through the UI — listing jails, viewing details, and executing control commands.
|
|
|
|
|
|
|
|
|
|
### 6.1 Implement the jail service ✅
|
|
|
|
|
|
|
|
|
|
**Done.** `backend/app/services/jail_service.py` — ~990 lines. Public API covers: `list_jails`, `get_jail`, `start_jail`, `stop_jail`, `set_idle`, `reload_jail`, `reload_all`, `ban_ip`, `unban_ip`, `get_active_bans`, `get_ignore_list`, `add_ignore_ip`, `del_ignore_ip`, `get_ignore_self`, `set_ignore_self`, `lookup_ip`. Uses `asyncio.gather` for parallel per-jail queries. `_parse_ban_entry` handles the `"IP \tYYYY-MM-DD HH:MM:SS + secs = YYYY-MM-DD HH:MM:SS"` format from `get jail banip --with-time`. `JailNotFoundError` and `JailOperationError` custom exceptions. 40 service tests pass.
|
|
|
|
|
|
|
|
|
|
### 6.2 Implement the jails router ✅
|
|
|
|
|
|
|
|
|
|
**Done.** `backend/app/routers/jails.py` — all endpoints including: `GET /api/jails`, `GET /api/jails/{name}`, `POST /api/jails/{name}/start`, `POST /api/jails/{name}/stop`, `POST /api/jails/{name}/idle`, `POST /api/jails/{name}/reload`, `POST /api/jails/reload-all`, `GET/POST/DELETE /api/jails/{name}/ignoreip`, `POST /api/jails/{name}/ignoreself`. Models defined in `backend/app/models/jail.py`.
|
|
|
|
|
|
|
|
|
|
### 6.3 Implement ban and unban endpoints ✅
|
|
|
|
|
|
|
|
|
|
**Done.** `backend/app/routers/bans.py` — `GET /api/bans/active`, `POST /api/bans`, `DELETE /api/bans`. `backend/app/routers/geo.py` — `GET /api/geo/lookup/{ip}`. New `backend/app/models/geo.py` with `GeoDetail` and `IpLookupResponse`. All three routers registered in `main.py`.
|
|
|
|
|
|
|
|
|
|
### 6.4 Build the jail overview page (frontend) ✅
|
|
|
|
|
|
|
|
|
|
**Done.** `frontend/src/pages/JailsPage.tsx` fully implemented. Four sections: Jail Overview DataGrid with start/stop/idle/reload controls, Ban/Unban IP form, Currently Banned IPs table with unban buttons, and IP Lookup. Types in `frontend/src/types/jail.ts`. API module at `frontend/src/api/jails.ts`. Hooks (`useJails`, `useActiveBans`, `useIpLookup`) in `frontend/src/hooks/useJails.ts`.
|
|
|
|
|
|
|
|
|
|
### 6.5 Build the jail detail page (frontend) ✅
|
|
|
|
|
|
|
|
|
|
**Done.** `frontend/src/pages/JailDetailPage.tsx` fully implemented. Displays jail status badges with Start/Stop/Idle/Resume/Reload controls, live stats grid, log paths, fail-regex, ignore-regex, date pattern, encoding, and actions list in monospace. Breadcrumb navigation back to the jails list.
|
|
|
|
|
|
|
|
|
|
### 6.6 Build the ban/unban UI (frontend) ✅
|
|
|
|
|
|
|
|
|
|
**Done.** Ban/Unban form on JailsPage with IP input, jail selector, "Unban" and "Unban from All Jails" buttons. "Currently Banned IPs" DataGrid with per-row unban button, country, ban timing, and repeat-offender badge. MessageBar feedback on success/error.
|
|
|
|
|
|
|
|
|
|
### 6.7 Implement IP lookup endpoint and UI ✅
|
|
|
|
|
|
|
|
|
|
**Done.** `GET /api/geo/lookup/{ip}` returns currently-banned jails and geo info. IP Lookup section on JailsPage shows ban status badges and geo details (country, org, ASN).
|
|
|
|
|
|
|
|
|
|
### 6.8 Implement the ignore list (whitelist) endpoints and UI ✅
|
|
|
|
|
|
|
|
|
|
**Done.** All ignore-list endpoints implemented in the jails router. "Ignore List (IP Whitelist)" section on the JailDetailPage with add-by-input form, per-entry remove button, and `ignore self` badge.
|
|
|
|
|
|
|
|
|
|
### 6.9 Write tests for jail and ban features ✅
|
|
|
|
|
|
|
|
|
|
**Done.** `backend/tests/test_services/test_jail_service.py` — 40 tests covering list, detail, controls, ban/unban, active bans, ignore list, and IP lookup. `backend/tests/test_routers/test_jails.py`, `test_bans.py`, `test_geo.py` — 36 router tests. Total: 217 tests, all pass. Coverage 76%.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## Stage 7 — Configuration View ✅ DONE
|
|
|
|
|
|
|
|
|
|
This stage lets users inspect and edit fail2ban configuration directly from the web interface.
|
|
|
|
|
|
|
|
|
|
### 7.1 Implement the config service ✅ DONE
|
|
|
|
|
|
|
|
|
|
Built `backend/app/services/config_service.py` (~613 lines). Reads active jail config via parallel `asyncio.gather` across 10 socket commands per jail. Writes via `set <jail> <key> <val>` commands. `_replace_regex_list` diffs old/new patterns using `contextlib.suppress(ValueError)`. In-process regex validation via the `re` module with `ConfigValidationError` on failure. `test_regex` is synchronous/pure-Python (no socket). `preview_log` reads file tail via `_read_tail_lines` (executor) and pattern-tests each line. Custom exceptions: `JailNotFoundError`, `ConfigValidationError`, `ConfigOperationError`.
|
|
|
|
|
|
|
|
|
|
### 7.2 Implement the config router ✅ DONE
|
|
|
|
|
|
|
|
|
|
Created `backend/app/routers/config.py` (~310 lines) with 9 endpoints:
|
|
|
|
|
- `GET /api/config/jails` → `JailConfigListResponse`
|
|
|
|
|
- `GET /api/config/jails/{name}` → `JailConfigResponse` (404 on unknown jail)
|
|
|
|
|
- `PUT /api/config/jails/{name}` → 204 (422 on bad regex, 400 on socket error)
|
|
|
|
|
- `GET /api/config/global` → `GlobalConfigResponse`
|
|
|
|
|
- `PUT /api/config/global` → 204
|
|
|
|
|
- `POST /api/config/reload` → 204
|
|
|
|
|
- `POST /api/config/regex-test` → `RegexTestResponse`
|
|
|
|
|
- `POST /api/config/jails/{name}/logpath` → 204
|
|
|
|
|
- `POST /api/config/preview-log` → `LogPreviewResponse`
|
|
|
|
|
|
|
|
|
|
Models expanded in `backend/app/models/config.py`: `JailConfig`, `JailConfigResponse`, `JailConfigListResponse`, `JailConfigUpdate`, `GlobalConfigResponse`, `GlobalConfigUpdate`, `AddLogPathRequest`, `LogPreviewRequest`, `LogPreviewLine`, `LogPreviewResponse`.
|
|
|
|
|
|
|
|
|
|
### 7.3 Implement log observation endpoints ✅ DONE
|
|
|
|
|
|
|
|
|
|
`POST /api/config/jails/{name}/logpath` — adds a new log path via `set <jail> addlogpath <path> tail|head`. `POST /api/config/preview-log` — reads the last N lines from a server-side log file and tests each line against a provided fail-regex, returning `LogPreviewResponse` with per-line match status and aggregate counts.
|
|
|
|
|
|
|
|
|
|
### 7.4 Implement the regex tester endpoint ✅ DONE
|
|
|
|
|
|
|
|
|
|
`POST /api/config/regex-test` implemented as a stateless, synchronous endpoint (no socket). Compiles the provided pattern with `re.compile`, applies it to the sample log line, returns `RegexTestResponse` with `matched` bool, `groups` list, and `error` string on invalid regex.
|
|
|
|
|
|
|
|
|
|
### 7.5 Implement server settings endpoints ✅ DONE
|
|
|
|
|
|
|
|
|
|
Created `backend/app/services/server_service.py` (~165 lines) and `backend/app/routers/server.py` (~115 lines):
|
|
|
|
|
- `GET /api/server/settings` → `ServerSettingsResponse` (parallel gather of 6 settings)
|
|
|
|
|
- `PUT /api/server/settings` → 204
|
|
|
|
|
- `POST /api/server/flush-logs` → `{"message": str}`
|
|
|
|
|
|
|
|
|
|
Custom exception: `ServerOperationError`.
|
|
|
|
|
|
|
|
|
|
### 7.6 Build the configuration page (frontend) ✅ DONE
|
|
|
|
|
|
|
|
|
|
Created `frontend/src/pages/ConfigPage.tsx` with four tabs:
|
|
|
|
|
- **Jails** — Accordion of all jails, each expandable with editable ban_time/find_time/max_retry, `RegexList` component for fail_regex/ignore_regex (add/remove inline), read-only log_paths/backend/actions, Save button per jail, Reload fail2ban button.
|
|
|
|
|
- **Global** — log_level dropdown, log_target input, db_purge_age/db_max_matches number inputs, Save button.
|
|
|
|
|
- **Server** — same plus read-only db_path/syslog_socket, Flush Logs button.
|
|
|
|
|
- **Regex Tester** — pattern + log line inputs, "Test Pattern" button with match badge + groups, plus log file preview section.
|
|
|
|
|
|
|
|
|
|
### 7.7 Build the regex tester UI (frontend) ✅ DONE
|
|
|
|
|
|
|
|
|
|
"Regex Tester" tab in `ConfigPage.tsx`. Pattern input (monospace) + sample log-line Textarea. On click calls `POST /api/config/regex-test` via `useRegexTester` hook. Displays match/no-match `Badge` with icon and lists captured groups. Below it: log file preview form calling `POST /api/config/preview-log`, renders each line color-coded (green = matched, neutral = no match) with summary count.
|
|
|
|
|
|
|
|
|
|
### 7.8 Build the server settings UI (frontend) ✅ DONE
|
|
|
|
|
|
|
|
|
|
"Server" tab in `ConfigPage.tsx`. Shows all six settings editable (log_level dropdown, log_target, db_purge_age, db_max_matches) plus read-only db_path and syslog_socket fields. Includes "Flush Logs" button via `useServerSettings` hook. All via `frontend/src/api/config.ts` and `frontend/src/hooks/useConfig.ts`.
|
|
|
|
|
|
|
|
|
|
Also created `frontend/src/types/config.ts` (all TS interfaces) and fixed pre-existing lint errors across the codebase: deprecated `JSX.Element` → `React.JSX.Element` in 10 files, void/promise patterns in `useServerStatus.ts` and `useJails.ts`, `no-misused-spread` in `client.ts`, `eslint.config.ts` excluded from linting.
|
|
|
|
|
|
|
|
|
|
### 7.9 Write tests for configuration features ✅ DONE
|
|
|
|
|
|
|
|
|
|
285 backend tests pass (68 new vs 217 before Stage 7). New test files:
|
|
|
|
|
- `backend/tests/test_services/test_config_service.py` — `TestGetJailConfig`, `TestListJailConfigs`, `TestUpdateJailConfig`, `TestGetGlobalConfig`, `TestUpdateGlobalConfig`, `TestTestRegex`, `TestPreviewLog`
|
|
|
|
|
- `backend/tests/test_services/test_server_service.py` — `TestGetSettings`, `TestUpdateSettings`, `TestFlushLogs`
|
|
|
|
|
- `backend/tests/test_routers/test_config.py` — `TestGetJailConfigs`, `TestGetJailConfig`, `TestUpdateJailConfig`, `TestGetGlobalConfig`, `TestUpdateGlobalConfig`, `TestReloadFail2ban`, `TestRegexTest`, `TestAddLogPath`, `TestPreviewLog`
|
|
|
|
|
- `backend/tests/test_routers/test_server.py` — `TestGetServerSettings`, `TestUpdateServerSettings`, `TestFlushLogs`
|
|
|
|
|
|
|
|
|
|
Backend linters: `ruff check` clean, `mypy app/` clean (44 files). Frontend: `tsc --noEmit` clean, `eslint` clean (0 errors, 0 warnings).
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## Stage 8 — World Map View ✅ DONE
|
|
|
|
|
|
|
|
|
|
A geographical visualisation of ban activity. This stage depends on the geo service from Stage 5 and the ban data pipeline from Stage 5.
|
|
|
|
|
|
|
|
|
|
### 8.1 Implement the map data endpoint ✅ DONE
|
|
|
|
|
|
|
|
|
|
Added `GET /api/dashboard/bans/by-country` to `backend/app/routers/dashboard.py`. Added `BansByCountryResponse` model (`countries: dict[str, int]`, `country_names: dict[str, str]`, `bans: list[DashboardBanItem]`, `total: int`) to `backend/app/models/ban.py`. Implemented `bans_by_country()` in `backend/app/services/ban_service.py` — fetches up to 2 000 bans from the window, deduplicates IPs, resolves geo concurrently with `asyncio.gather`, then aggregates by ISO alpha-2 country code.
|
|
|
|
|
|
|
|
|
|
### 8.2 Build the world map component (frontend) ✅ DONE
|
|
|
|
|
|
|
|
|
|
Created `frontend/src/data/isoNumericToAlpha2.ts` — static 249-entry mapping of ISO 3166-1 numeric codes (as used in world-atlas TopoJSON `geo.id`) to alpha-2 codes. Created `frontend/src/components/WorldMap.tsx` using `react-simple-maps@3.0.0`. Renders a Mercator SVG world map with per-country colour intensity scaled from the maximum ban count. Countries with bans show the count in text. Selected country highlighted with brand accent colour. Uses a nested `GeoLayer` component (inside `ComposableMap`) to call `useGeographies` within the map context. Clicking a country toggles its filter; clicking again clears it.
|
|
|
|
|
|
|
|
|
|
### 8.3 Build the map page (frontend) ✅ DONE
|
|
|
|
|
|
|
|
|
|
Replaced placeholder `frontend/src/pages/MapPage.tsx` with a full implementation. Includes a time-range `Select` (24h/7d/30d/365d), the `WorldMap` component, an active-filter info bar showing the selected country name and ban count with a "Clear filter" button, a summary line (total bans + number of countries), and a companion FluentUI `Table` filtered by selected country (columns: IP, Jail, Banned At, Country, Times Banned). Created `frontend/src/hooks/useMapData.ts` and `frontend/src/api/map.ts` with proper abort-controller cleanup and ESLint-clean void patterns. Created `frontend/src/types/map.ts` with `TimeRange`, `MapBanItem`, `BansByCountryResponse`.
|
|
|
|
|
|
|
|
|
|
### 8.4 Write tests for the map data endpoint ✅ DONE
|
|
|
|
|
|
|
|
|
|
Added `TestBansByCountry` class (5 tests) to `backend/tests/test_routers/test_dashboard.py`: `test_returns_200_when_authenticated`, `test_returns_401_when_unauthenticated`, `test_response_shape`, `test_accepts_time_range_param`, `test_empty_window_returns_empty_response`. Total backend tests: 290 (all passing). ruff clean, mypy clean (44 files). Frontend: `tsc --noEmit` clean, `eslint` 0 warnings/errors.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## Stage 9 — Ban History ✅ DONE
|
|
|
|
|
|
|
|
|
|
This stage exposes historical ban data from the fail2ban database for forensic exploration.
|
|
|
|
|
|
|
|
|
|
### 9.1 Implement the history service ✅ DONE
|
|
|
|
|
|
|
|
|
|
Built `backend/app/services/history_service.py`. `list_history()` queries the fail2ban DB with dynamic WHERE clauses: time range (`range_=None` = all-time, otherwise filters by `timeofban >= now - delta`), jail (exact match), IP (LIKE prefix `%`), and page/page_size. `get_ip_detail()` aggregates all ban events for a given IP into an `IpDetailResponse` with timeline, total bans, total failures, last_ban_at, and geo data — returns `None` if no records. Reuses `_get_fail2ban_db_path`, `_parse_data_json`, `_ts_to_iso` from `ban_service`. Also fixed a latent bug in `_parse_data_json` in `ban_service.py`: `json.loads("null")` returns Python `None` rather than a dict, causing `AttributeError` on `.get()`; fixed by checking `isinstance(parsed, dict)` before assigning `obj`.
|
|
|
|
|
|
|
|
|
|
### 9.2 Implement the history router ✅ DONE
|
|
|
|
|
|
|
|
|
|
Created `backend/app/routers/history.py`:
|
|
|
|
|
- `GET /api/history` — paginated list with optional filters: `range` (`TimeRange` enum or omit for all-time), `jail` (exact), `ip` (prefix), `page`, `page_size`. Returns `HistoryListResponse`.
|
|
|
|
|
- `GET /api/history/{ip}` — per-IP detail returning `IpDetailResponse`; raises `HTTPException(404)` if `get_ip_detail()` returns `None`.
|
|
|
|
|
|
|
|
|
|
Models defined in `backend/app/models/history.py`: `HistoryBanItem`, `HistoryListResponse`, `IpTimelineEvent`, `IpDetailResponse`. Results enriched with geo data via `geo_service.lookup`. Router registered in `main.py`.
|
|
|
|
|
|
|
|
|
|
### 9.3 Build the history page (frontend) ✅ DONE
|
|
|
|
|
|
|
|
|
|
Replaced placeholder `frontend/src/pages/HistoryPage.tsx` with full implementation. Filter bar: time-range `Select` (All time + 4 presets), jail `Input`, IP prefix `Input`, Apply/Clear buttons. FluentUI `DataGrid` table with columns: Banned At, IP (monospace, clickable), Jail, Country, Failures, Times Banned. Rows with `ban_count ≥ 5` highlighted amber. Clicking an IP opens `IpDetailView` sub-component with summary grid and timeline `Table`. Pagination with ChevronLeft/ChevronRight buttons. Created supporting files: `frontend/src/types/history.ts`, `frontend/src/api/history.ts`, `frontend/src/hooks/useHistory.ts` (`useHistory` pagination hook + `useIpHistory` detail hook).
|
|
|
|
|
|
|
|
|
|
### 9.4 Write tests for history features ✅ DONE
|
|
|
|
|
|
|
|
|
|
Added `tests/test_routers/test_history.py` (11 tests) and `tests/test_services/test_history_service.py` (16 tests). Service tests use a real temporary SQLite DB seeded with 4 rows across two jails and 3 IPs. Router tests mock the service layer. Coverage: time-range filter, jail filter, IP prefix filter, combined filters, unknown IP → `None`, pagination, null data column, geo enrichment, 404 response, timeline aggregation, total_failures. All 317 backend tests pass (27 new), ruff clean, mypy clean (46 files). Frontend `tsc --noEmit` and `npm run lint` clean (0 errors/warnings).
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## Stage 10 — External Blocklist Importer ✅ DONE
|
|
|
|
|
|
|
|
|
|
This stage adds the ability to automatically download and apply external IP blocklists on a schedule.
|
|
|
|
|
|
|
|
|
|
### 10.1 Implement the blocklist repository ✅ DONE
|
|
|
|
|
|
|
|
|
|
`backend/app/repositories/blocklist_repo.py` — CRUD for `blocklist_sources` table (`create_source`, `get_source`, `list_sources`, `list_enabled_sources`, `update_source`, `delete_source`). `backend/app/repositories/import_log_repo.py` — `add_log`, `list_logs` (paginated, optional `source_id` filter), `get_last_log`, `compute_total_pages`. 18 repository tests pass.
|
|
|
|
|
|
|
|
|
|
### 10.2 Implement the blocklist service ✅ DONE
|
|
|
|
|
|
|
|
|
|
`backend/app/services/blocklist_service.py` — source CRUD, `preview_source` (downloads max 64 KB, validates, returns sample), `import_source` (downloads, validates IPs via `ipaddress`, bans through fail2ban blocklist jail, logs result), `import_all` (iterates all enabled sources), `get_schedule`/`set_schedule` (JSON in settings table), `get_schedule_info` (config + last run + next run). 19 service tests pass.
|
|
|
|
|
|
|
|
|
|
### 10.3 Implement the blocklist import scheduled task ✅ DONE
|
|
|
|
|
|
|
|
|
|
`backend/app/tasks/blocklist_import.py` — APScheduler job with stable ID `"blocklist_import"`. Supports three frequencies: `hourly` (interval trigger, every N hours), `daily` (cron trigger, UTC hour+minute), `weekly` (cron trigger, day_of_week+hour+minute). `register(app)` called at startup; `reschedule(app)` replaces the job after a schedule update.
|
|
|
|
|
|
|
|
|
|
### 10.4 Implement the blocklist router ✅ DONE
|
|
|
|
|
|
|
|
|
|
`backend/app/routers/blocklist.py` — 9 endpoints on `/api/blocklists`: list sources, create source (201), trigger manual import (`POST /import`), get schedule info, update schedule, paginated import log, get/update/delete single source, preview blocklist contents. Static path segments (`/import`, `/schedule`, `/log`) registered before `/{source_id}`. All endpoints authenticated. 22 router tests pass.
|
|
|
|
|
|
|
|
|
|
### 10.5 Build the blocklist management page (frontend) ✅ DONE
|
|
|
|
|
|
|
|
|
|
`frontend/src/pages/BlocklistsPage.tsx` (≈950 lines) — `SourceFormDialog` (create/edit), `PreviewDialog`, `ImportResultDialog`, `SourcesSection` (table with enable/disable switch), `ScheduleSection` (frequency picker + hour/minute/day-of-week selectors + next run display), `ImportLogSection` (paginated table with error badge). Supporting files: `frontend/src/types/blocklist.ts`, `frontend/src/api/blocklist.ts`, `frontend/src/hooks/useBlocklist.ts`. TypeScript + ESLint clean.
|
|
|
|
|
|
|
|
|
|
### 10.6 Write tests for blocklist features ✅ DONE
|
|
|
|
|
|
|
|
|
|
18 repository tests, 19 service tests, 22 router tests — 59 total. All 374 backend tests pass. `ruff` clean, `mypy` clean for Stage 10 files.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## Stage 11 — Polish, Cross-Cutting Concerns & Hardening ✅ DONE
|
|
|
|
|
|
|
|
|
|
This final stage covers everything that spans multiple features or improves the overall quality of the application.
|
|
|
|
|
|
|
|
|
|
### 11.1 Implement connection health indicator ✅
|
|
|
|
|
|
|
|
|
|
**Done.** `MainLayout.tsx` reads from `useServerStatus()` and shows a Fluent UI `MessageBar` (intent="warning") at the top of the layout whenever fail2ban is unreachable. The bar is dismissed automatically as soon as the next health poll reports recovery. No extra API calls — reads the cached status from the context established in Stage 4.
|
|
|
|
|
|
|
|
|
|
### 11.2 Add timezone awareness ✅
|
|
|
|
|
|
|
|
|
|
**Done.** Added `GET /api/setup/timezone` endpoint (`setup_service.get_timezone`, `SetupTimezoneResponse` model). Created `frontend/src/utils/formatDate.ts` with `formatDate()`, `formatDateShort()`, and `formatRelative()` using `Intl.DateTimeFormat` with IANA timezone support and UTC fallback. Created `frontend/src/providers/TimezoneProvider.tsx` which fetches the timezone once on mount and exposes it via `useTimezone()` hook. `App.tsx` wraps authenticated routes with `<TimezoneProvider>`.
|
|
|
|
|
|
|
|
|
|
### 11.3 Add responsive layout polish ✅
|
|
|
|
|
|
|
|
|
|
**Done.** `MainLayout.tsx` initialises the collapsed sidebar state based on `window.innerWidth < 640` and adds a `window.matchMedia("(max-width: 639px)")` listener to auto-collapse/expand on resize. All data tables (`BanTable`, `JailsPage`, `HistoryPage`, `BlocklistsPage`) already have `overflowX: "auto"` wrappers. Cards stack vertically via Fluent UI `makeStyles` column flex on small breakpoints.
|
|
|
|
|
|
|
|
|
|
### 11.4 Add loading and error states ✅
|
|
|
|
|
|
|
|
|
|
**Done.** Created `frontend/src/components/PageFeedback.tsx` with three reusable components: `PageLoading` (centred `Spinner`), `PageError` (error `MessageBar` with an optional retry `Button` using `ArrowClockwiseRegular`), and `PageEmpty` (neutral centred text). `BanTable.tsx` was updated to use all three, replacing its previous inline implementations. Existing pages (`JailsPage`, `HistoryPage`, `BlocklistsPage`) already had comprehensive inline handling and were left as-is to avoid churn.
|
|
|
|
|
|
|
|
|
|
### 11.5 Implement reduced-motion support ✅
|
|
|
|
|
|
|
|
|
|
**Done.** Added `"@media (prefers-reduced-motion: reduce)": { transition: "none" }` to the sidebar `makeStyles` transition in `MainLayout.tsx`. When the OS preference is set, the sidebar panel appears/disappears instantly with no animation.
|
|
|
|
|
|
|
|
|
|
### 11.6 Add accessibility audit ✅
|
|
|
|
|
|
|
|
|
|
**Done.** `WorldMap.tsx` updated: the outer `<div>` wrapper now carries `role="img"` and a descriptive `aria-label`. Each clickable country `<g>` element received `role="button"`, `tabIndex={0}`, a dynamic `aria-label` (country code + ban count + selected state), `aria-pressed`, and an `onKeyDown` handler activating on Enter/Space — making the map fully keyboard-navigable.
|
|
|
|
|
|
|
|
|
|
### 11.7 Add structured logging throughout ✅
|
|
|
|
|
|
|
|
|
|
**Done.** All services and tasks already had comprehensive structlog coverage from earlier stages. `health_check.py` task was updated to log `fail2ban_came_online` (info, with version) on offline→online transitions and `fail2ban_went_offline` (warning) on online→offline transitions.
|
|
|
|
|
|
|
|
|
|
### 11.8 Add global error handling ✅
|
|
|
|
|
|
|
|
|
|
**Done.** Added `_fail2ban_connection_handler` (returns 502 with `{"detail": "fail2ban unavailable"}`) and `_fail2ban_protocol_handler` (returns 502 with `{"detail": "fail2ban protocol error"}`) to `main.py`. Both handlers log the event with structlog before responding. Registered in `create_app()` before the catch-all `_unhandled_exception_handler`, ensuring fail2ban network errors are always surfaced as 502 rather than 500.
|
|
|
|
|
|
|
|
|
|
### 11.9 Final test pass and coverage check ✅
|
|
|
|
|
|
|
|
|
|
**Done.** Added 5 new tests: `TestGetTimezone` in `test_routers/test_setup.py` (3 tests) and `test_services/test_setup_service.py` (2 tests). Full suite: **379 tests passed**. Line coverage: **82 %** (exceeds 80 % target). `ruff check` clean. `mypy` reports only pre-existing errors in test helper files (unchanged from Stage 10). `tsc --noEmit` clean. `eslint` clean (0 warnings, 0 errors).
|
|
|
|
|
**Fix:** Added `Makefile` at the project root with targets:
|
|
|
|
|
- `make up` — start the debug stack (detached)
|
|
|
|
|
- `make down` — stop the debug stack
|
|
|
|
|
- `make restart` — restart the debug stack
|
|
|
|
|
- `make logs` — tail all logs
|
|
|
|
|
- `make clean` — `compose down -v --remove-orphans` (removes all debug volumes)
|
|
|
|
|
|