10 Commits

64 changed files with 1844 additions and 2316 deletions

View File

@@ -82,10 +82,12 @@ The backend follows a **layered architecture** with strict separation of concern
backend/ backend/
├── app/ ├── app/
│ ├── __init__.py │ ├── __init__.py
│ ├── main.py # FastAPI app factory, lifespan, exception handlers │ ├── `main.py` # FastAPI app factory, lifespan, exception handlers
│ ├── config.py # Pydantic settings (env vars, .env loading) │ ├── `config.py` # Pydantic settings (env vars, .env loading)
│ ├── dependencies.py # FastAPI Depends() providers (DB, services, auth) │ ├── `db.py` # Database connection and initialization
│ ├── models/ # Pydantic schemas │ ├── `exceptions.py` # Shared domain exception classes
│ ├── `dependencies.py` # FastAPI Depends() providers (DB, services, auth)
│ ├── `models/` # Pydantic schemas
│ │ ├── auth.py # Login request/response, session models │ │ ├── auth.py # Login request/response, session models
│ │ ├── ban.py # Ban request/response/domain models │ │ ├── ban.py # Ban request/response/domain models
│ │ ├── jail.py # Jail request/response/domain models │ │ ├── jail.py # Jail request/response/domain models
@@ -111,6 +113,12 @@ backend/
│ │ ├── jail_service.py # Jail listing, start/stop/reload, status aggregation │ │ ├── jail_service.py # Jail listing, start/stop/reload, status aggregation
│ │ ├── ban_service.py # Ban/unban execution, currently-banned queries │ │ ├── ban_service.py # Ban/unban execution, currently-banned queries
│ │ ├── config_service.py # Read/write fail2ban config, regex validation │ │ ├── config_service.py # Read/write fail2ban config, regex validation
│ │ ├── config_file_service.py # Shared config parsing and file-level operations
│ │ ├── raw_config_io_service.py # Raw config file I/O wrapper
│ │ ├── jail_config_service.py # jail config activation/deactivation logic
│ │ ├── filter_config_service.py # filter config lifecycle management
│ │ ├── action_config_service.py # action config lifecycle management
│ │ ├── log_service.py # Log preview and regex test operations
│ │ ├── history_service.py # Historical ban queries, per-IP timeline │ │ ├── history_service.py # Historical ban queries, per-IP timeline
│ │ ├── blocklist_service.py # Download, validate, apply blocklists │ │ ├── blocklist_service.py # Download, validate, apply blocklists
│ │ ├── geo_service.py # IP-to-country resolution, ASN/RIR lookup │ │ ├── geo_service.py # IP-to-country resolution, ASN/RIR lookup
@@ -119,17 +127,18 @@ backend/
│ ├── repositories/ # Data access layer (raw queries only) │ ├── repositories/ # Data access layer (raw queries only)
│ │ ├── settings_repo.py # App configuration CRUD in SQLite │ │ ├── settings_repo.py # App configuration CRUD in SQLite
│ │ ├── session_repo.py # Session storage and lookup │ │ ├── session_repo.py # Session storage and lookup
│ │ ├── blocklist_repo.py # Blocklist sources and import log persistence │ │ ├── blocklist_repo.py # Blocklist sources and import log persistence│ │ ├── fail2ban_db_repo.py # fail2ban SQLite ban history read operations
│ │ └── import_log_repo.py # Import run history records │ │ ├── geo_cache_repo.py # IP geolocation cache persistence│ │ └── import_log_repo.py # Import run history records
│ ├── tasks/ # APScheduler background jobs │ ├── tasks/ # APScheduler background jobs
│ │ ├── blocklist_import.py# Scheduled blocklist download and application │ │ ├── blocklist_import.py# Scheduled blocklist download and application
│ │ ├── geo_cache_flush.py # Periodic geo cache persistence (dirty-set flush to SQLite) │ │ ├── geo_cache_flush.py # Periodic geo cache persistence (dirty-set flush to SQLite)│ │ ├── geo_re_resolve.py # Periodic re-resolution of stale geo cache records│ │ └── health_check.py # Periodic fail2ban connectivity probe
│ │ └── health_check.py # Periodic fail2ban connectivity probe
│ └── utils/ # Helpers, constants, shared types │ └── utils/ # Helpers, constants, shared types
│ ├── fail2ban_client.py # Async wrapper around the fail2ban socket protocol │ ├── fail2ban_client.py # Async wrapper around the fail2ban socket protocol
│ ├── ip_utils.py # IP/CIDR validation and normalisation │ ├── ip_utils.py # IP/CIDR validation and normalisation
│ ├── time_utils.py # Timezone-aware datetime helpers │ ├── time_utils.py # Timezone-aware datetime helpers│ ├── jail_config.py # Jail config parser/serializer helper
── constants.py # Shared constants (default paths, limits, etc.) ── conffile_parser.py # Fail2ban config file parser/serializer
│ ├── config_parser.py # Structured config object parser
│ ├── config_writer.py # Atomic config file write operations│ └── constants.py # Shared constants (default paths, limits, etc.)
├── tests/ ├── tests/
│ ├── conftest.py # Shared fixtures (test app, client, mock DB) │ ├── conftest.py # Shared fixtures (test app, client, mock DB)
│ ├── test_routers/ # One test file per router │ ├── test_routers/ # One test file per router
@@ -158,8 +167,9 @@ The HTTP interface layer. Each router maps URL paths to handler functions. Route
| `blocklist.py` | `/api/blocklists` | CRUD blocklist sources, trigger import, view import logs | | `blocklist.py` | `/api/blocklists` | CRUD blocklist sources, trigger import, view import logs |
| `geo.py` | `/api/geo` | IP geolocation lookup, ASN and RIR data | | `geo.py` | `/api/geo` | IP geolocation lookup, ASN and RIR data |
| `server.py` | `/api/server` | Log level, log target, DB path, purge age, flush logs | | `server.py` | `/api/server` | Log level, log target, DB path, purge age, flush logs |
| `health.py` | `/api/health` | fail2ban connectivity health check and status |
#### Services (`app/services/`) #### Services (`app/services`)
The business logic layer. Services orchestrate operations, enforce rules, and coordinate between repositories, the fail2ban client, and external APIs. Each service covers a single domain. The business logic layer. Services orchestrate operations, enforce rules, and coordinate between repositories, the fail2ban client, and external APIs. Each service covers a single domain.
@@ -175,7 +185,8 @@ The business logic layer. Services orchestrate operations, enforce rules, and co
| `filter_config_service.py` | Discovers available filters by scanning filter.d/; reads, creates, updates, and deletes filter definitions; assigns filters to jails | | `filter_config_service.py` | Discovers available filters by scanning filter.d/; reads, creates, updates, and deletes filter definitions; assigns filters to jails |
| `action_config_service.py` | Discovers available actions by scanning action.d/; reads, creates, updates, and deletes action definitions; assigns actions to jails | | `action_config_service.py` | Discovers available actions by scanning action.d/; reads, creates, updates, and deletes action definitions; assigns actions to jails |
| `config_file_service.py` | Shared utilities for configuration parsing and manipulation: parses config files, validates names/IPs, manages atomic file writes, probes fail2ban socket | | `config_file_service.py` | Shared utilities for configuration parsing and manipulation: parses config files, validates names/IPs, manages atomic file writes, probes fail2ban socket |
| `conffile_parser.py` | Parses fail2ban `.conf` files into structured Python types (jail config, filter config, action config); also serialises back to text | | `raw_config_io_service.py` | Low-level file I/O for raw fail2ban config files |
| `log_service.py` | Log preview and regex test operations (extracted from config_service) |
| `history_service.py` | Queries the fail2ban database for historical ban records, builds per-IP timelines, computes ban counts and repeat-offender flags | | `history_service.py` | Queries the fail2ban database for historical ban records, builds per-IP timelines, computes ban counts and repeat-offender flags |
| `blocklist_service.py` | Downloads blocklists via aiohttp, validates IPs/CIDRs, applies bans through fail2ban or iptables, logs import results | | `blocklist_service.py` | Downloads blocklists via aiohttp, validates IPs/CIDRs, applies bans through fail2ban or iptables, logs import results |
| `geo_service.py` | Resolves IP addresses to country, ASN, and RIR using external APIs or a local database, caches results | | `geo_service.py` | Resolves IP addresses to country, ASN, and RIR using external APIs or a local database, caches results |
@@ -191,15 +202,26 @@ The data access layer. Repositories execute raw SQL queries against the applicat
| `settings_repo.py` | CRUD operations for application settings (master password hash, DB path, fail2ban socket path, preferences) | | `settings_repo.py` | CRUD operations for application settings (master password hash, DB path, fail2ban socket path, preferences) |
| `session_repo.py` | Store, retrieve, and delete session records for authentication | | `session_repo.py` | Store, retrieve, and delete session records for authentication |
| `blocklist_repo.py` | Persist blocklist source definitions (name, URL, enabled/disabled) | | `blocklist_repo.py` | Persist blocklist source definitions (name, URL, enabled/disabled) |
| `fail2ban_db_repo.py` | Read historical ban records from the fail2ban SQLite database |
| `geo_cache_repo.py` | Persist and query IP geo resolution cache |
| `import_log_repo.py` | Record import run results (timestamp, source, IPs imported, errors) for the import log view | | `import_log_repo.py` | Record import run results (timestamp, source, IPs imported, errors) for the import log view |
#### Models (`app/models/`) #### Models (`app/models/`)
Pydantic schemas that define data shapes and validation. Models are split into three categories per domain: Pydantic schemas that define data shapes and validation. Models are split into three categories per domain.
- **Request models** — validate incoming API data (e.g., `BanRequest`, `LoginRequest`) | Model file | Purpose |
- **Response models** — shape outgoing API data (e.g., `JailResponse`, `BanListResponse`) |---|---|
- **Domain models** — internal representations used between services and repositories (e.g., `Ban`, `Jail`) | `auth.py` | Login/request and session models |
| `ban.py` | Ban creation and lookup models |
| `blocklist.py` | Blocklist source and import log models |
| `config.py` | Fail2ban config view/edit models |
| `file_config.py` | Raw config file read/write models |
| `geo.py` | Geo and ASN lookup models |
| `history.py` | Historical ban query and timeline models |
| `jail.py` | Jail listing and status models |
| `server.py` | Server status and settings models |
| `setup.py` | First-run setup wizard models |
#### Tasks (`app/tasks/`) #### Tasks (`app/tasks/`)
@@ -209,6 +231,7 @@ APScheduler background jobs that run on a schedule without user interaction.
|---|---| |---|---|
| `blocklist_import.py` | Downloads all enabled blocklist sources, validates entries, applies bans, records results in the import log | | `blocklist_import.py` | Downloads all enabled blocklist sources, validates entries, applies bans, records results in the import log |
| `geo_cache_flush.py` | Periodically flushes newly resolved IPs from the in-memory dirty set to the `geo_cache` SQLite table (default: every 60 seconds). GET requests populate only the in-memory cache; this task persists them without blocking any request. | | `geo_cache_flush.py` | Periodically flushes newly resolved IPs from the in-memory dirty set to the `geo_cache` SQLite table (default: every 60 seconds). GET requests populate only the in-memory cache; this task persists them without blocking any request. |
| `geo_re_resolve.py` | Periodically re-resolves stale entries in `geo_cache` to keep geolocation data fresh |
| `health_check.py` | Periodically pings the fail2ban socket and updates the cached server status so the frontend always has fresh data | | `health_check.py` | Periodically pings the fail2ban socket and updates the cached server status so the frontend always has fresh data |
#### Utils (`app/utils/`) #### Utils (`app/utils/`)
@@ -219,7 +242,16 @@ Pure helper modules with no framework dependencies.
|---|---| |---|---|
| `fail2ban_client.py` | Async client that communicates with fail2ban via its Unix domain socket — sends commands and parses responses using the fail2ban protocol. Modelled after [`./fail2ban-master/fail2ban/client/csocket.py`](../fail2ban-master/fail2ban/client/csocket.py) and [`./fail2ban-master/fail2ban/client/fail2banclient.py`](../fail2ban-master/fail2ban/client/fail2banclient.py). | | `fail2ban_client.py` | Async client that communicates with fail2ban via its Unix domain socket — sends commands and parses responses using the fail2ban protocol. Modelled after [`./fail2ban-master/fail2ban/client/csocket.py`](../fail2ban-master/fail2ban/client/csocket.py) and [`./fail2ban-master/fail2ban/client/fail2banclient.py`](../fail2ban-master/fail2ban/client/fail2banclient.py). |
| `ip_utils.py` | Validates IPv4/IPv6 addresses and CIDR ranges using the `ipaddress` stdlib module, normalises formats | | `ip_utils.py` | Validates IPv4/IPv6 addresses and CIDR ranges using the `ipaddress` stdlib module, normalises formats |
| `jail_utils.py` | Jail helper functions for configuration and status inference |
| `jail_config.py` | Jail config parser and serializer for fail2ban config manipulation |
| `time_utils.py` | Timezone-aware datetime construction, formatting helpers, time-range calculations | | `time_utils.py` | Timezone-aware datetime construction, formatting helpers, time-range calculations |
| `log_utils.py` | Structured log formatting and enrichment helpers |
| `conffile_parser.py` | Parses Fail2ban `.conf` files into structured objects and serialises back to text |
| `config_parser.py` | Builds structured config objects from file content tokens |
| `config_writer.py` | Atomic config file writes, backups, and safe replace semantics |
| `config_file_utils.py` | Common file-level config utility helpers |
| `fail2ban_db_utils.py` | Fail2ban DB path discovery and ban-history parsing helpers |
| `setup_utils.py` | Setup wizard helper utilities |
| `constants.py` | Shared constants: default socket path, default database path, time-range presets, limits | | `constants.py` | Shared constants: default socket path, default database path, time-range presets, limits |
#### Configuration (`app/config.py`) #### Configuration (`app/config.py`)

View File

@@ -3,193 +3,3 @@
This document catalogues architecture violations, code smells, and structural issues found during a full project review. Issues are grouped by category and prioritised. This document catalogues architecture violations, code smells, and structural issues found during a full project review. Issues are grouped by category and prioritised.
--- ---
## 1. Service-to-Service Coupling (Backend)
The architecture mandates that dependencies flow **routers → services → repositories**, yet **15 service-to-service imports** exist, with 7 using lazy imports to work around circular dependencies.
| Source Service | Imports From | Line | Mechanism |
|---|---|---|---|
| `history_service` | `ban_service` | L29 | Direct import of 3 **private** functions: `_get_fail2ban_db_path`, `_parse_data_json`, `_ts_to_iso` |
| `auth_service` | `setup_service` | L23 | Top-level import |
| `config_service` | `setup_service` | L47 | Top-level import |
| `config_service` | `health_service` | L891 | Lazy import inside function |
| `config_file_service` | `jail_service` | L5758 | Top-level import + re-export of `JailNotFoundError` |
| `blocklist_service` | `jail_service` | L299 | Lazy import |
| `blocklist_service` | `geo_service` | L343 | Lazy import |
| `jail_service` | `geo_service` | L860, L1047 | Lazy import (2 sites) |
| `ban_service` | `geo_service` | L251, L392 | Lazy import (2 sites) |
| `history_service` | `geo_service` | L19 | TYPE_CHECKING import |
**Impact**: Circular dependency risk; lazy imports hide coupling; private function imports create fragile links between services.
**Recommendation**:
- Extract `_get_fail2ban_db_path()`, `_parse_data_json()`, `_ts_to_iso()` from `ban_service` to `app/utils/fail2ban_db_utils.py` (shared utility).
- Pass geo-enrichment as a callback parameter instead of each service importing `geo_service` directly. The router or dependency layer should wire this.
- Where services depend on another service's domain exceptions (e.g., `JailNotFoundError`), move exceptions to `app/models/` or a shared `app/exceptions.py`.
---
## 2. God Modules (Backend)
Several service files far exceed a reasonable size for a single-domain module:
| File | Lines | Functions | Issue |
|---|---|---|---|
| `config_file_service.py` | **3105** | **73** | Handles jails, filters, and actions — three distinct domains crammed into one file |
| `jail_service.py` | **1382** | **34** | Manages jail listing, status, controls, banned-IP queries, and geo enrichment |
| `config_service.py` | **921** | ~20 | Socket-based config, log preview, regex testing, and service status |
| `file_config_service.py` | **1011** | ~20 | Raw file I/O for jails, filters, and actions |
**Recommendation**:
- Split `config_file_service.py` into `filter_config_service.py`, `action_config_service.py`, and a slimmed-down `jail_config_service.py`.
- Extract log-preview / regex-test functionality from `config_service.py` into a dedicated `log_service.py`.
---
## 3. Confusing Config Service Naming (Backend)
Three services with overlapping names handle different aspects of configuration, causing developer confusion:
| Current Name | Purpose |
|---|---|
| `config_service` | Read/write via the fail2ban **socket** |
| `config_file_service` | Parse/activate/deactivate jails from **files on disk** |
| `file_config_service` | **Raw file I/O** for jail/filter/action `.conf` files |
`config_file_service` vs `file_config_service` differ only by word order, making it easy to import the wrong one.
**Recommendation**: Rename for clarity:
- `config_service` → keep (socket-based)
- `config_file_service``jail_activation_service` (its main job is activating/deactivating jails)
- `file_config_service``raw_config_io_service` or merge into `config_file_service`
---
## 4. Architecture Doc Drift
The architecture doc does not fully reflect the current codebase:
| Category | In Architecture Doc | Actually Exists | Notes |
|---|---|---|---|
| Repositories | 4 listed | **6 files** | `fail2ban_db_repo.py` and `geo_cache_repo.py` are missing from the doc |
| Utils | 4 listed | **8 files** | `conffile_parser.py`, `config_parser.py`, `config_writer.py`, `jail_config.py` are undocumented |
| Tasks | 3 listed | **4 files** | `geo_re_resolve.py` is missing from the doc |
| Services | `conffile_parser` listed as a service | Actually in `app/utils/` | Doc says "Services" but the file is in `utils/` |
| Routers | `file_config.py` not listed | Exists | Missing from router table |
**Recommendation**: Update the Architecture doc to reflect the actual file inventory.
---
## 5. Shared Private Functions Cross Service Boundary (Backend)
`history_service.py` imports three **underscore-prefixed** ("private") functions from `ban_service.py`:
```python
from app.services.ban_service import _get_fail2ban_db_path, _parse_data_json, _ts_to_iso
```
These are implementation details of `ban_service` that should not be consumed externally. Their `_` prefix signals they are not part of the public API.
**Recommendation**: Move these to `app/utils/fail2ban_db_utils.py` as public functions and import from there in both services.
---
## 6. Missing Error Boundaries (Frontend)
No React Error Boundary component exists anywhere in the frontend. A single unhandled exception in any component will crash the entire application with a white screen.
**Recommendation**: Add an `<ErrorBoundary>` wrapper in `MainLayout.tsx` or `App.tsx` with a fallback UI that shows the error and offers a retry/reload.
---
## 7. Duplicated Formatting Functions (Frontend)
Several formatting functions are independently defined in multiple files instead of being shared:
| Function | Defined In | Also In |
|---|---|---|
| `formatTimestamp()` | `BanTable.tsx` (L103) | — (but `fmtTime()` in `BannedIpsSection.tsx` does the same thing) |
| `fmtSeconds()` | `JailDetailPage.tsx` (L152) | `JailsPage.tsx` (L147) — identical |
| `fmtTime()` | `BannedIpsSection.tsx` (L139) | — |
**Recommendation**: Consolidate into `src/utils/formatDate.ts` and import from there.
---
## 8. Duplicated Hook Logic (Frontend)
Three hooks follow an identical fetch-then-save pattern with near-identical code:
| Hook | Lines | Pattern |
|---|---|---|
| `useFilterConfig.ts` | 91 | Load item → expose save → handle abort |
| `useActionConfig.ts` | 89 | Load item → expose save → handle abort |
| `useJailFileConfig.ts` | 76 | Load item → expose save → handle abort |
**Recommendation**: Create a generic `useConfigItem<T>()` hook that takes `fetchFn` and `saveFn` parameters and eliminates the triplication.
---
## 9. Inconsistent Error Handling in Hooks (Frontend)
Hooks handle errors differently:
- Some filter out `AbortError` (e.g., `useHistory`, `useMapData`)
- Others catch all errors indiscriminately (e.g., `useBans`, `useBlocklist`)
This means some hooks surface spurious "request aborted" errors to the UI while others don't.
**Recommendation**: Standardise a shared error-catching pattern, e.g. a `handleFetchError(err, setError)` utility that always filters `AbortError`.
---
## 10. No Global Request State / Caching (Frontend)
Each hook manages its own loading/error/data state independently. There is:
- No request deduplication (two components fetching the same data trigger two requests)
- No stale-while-revalidate caching
- No automatic background refetching
**Recommendation**: Consider adopting React Query (TanStack Query) or SWR for data-fetching hooks. This would eliminate boilerplate in every hook (abort handling, loading state, error state, caching) and provide automatic deduplication.
---
## 11. Large Frontend Components
| Component | Lines | Issue |
|---|---|---|
| `BlocklistsPage.tsx` | 968 | Page does a lot: source list, add/edit dialogs, import log, schedule config |
| `JailsTab.tsx` | 939 | Combines jail list, config editing, raw config, validation, activate/deactivate |
| `JailsPage.tsx` | 691 | Mixes jail table, detail drawer, ban/unban forms |
| `JailDetailPage.tsx` | 663 | Full detail view with multiple sections |
**Recommendation**: Extract sub-sections into focused child components. For example, `JailsTab.tsx` could delegate to `<JailConfigEditor>`, `<JailValidation>`, and `<JailActivateDialog>`.
---
## 12. Duplicated Section Styles (Frontend)
The same card/section styling pattern (`backgroundColor`, `borderRadius`, `border`, `padding` using Fluent UI tokens) is repeated across 13+ files. Each page recreates it in its own `makeStyles` block.
**Recommendation**: Define a shared `useCardStyles()` or export a `sectionStyle` in `src/theme/commonStyles.ts` and import it.
---
## Summary by Priority
| Priority | Issue | Section |
|---|---|---|
| **High** | Service-to-service coupling / circular deps | §1 |
| **High** | God module `config_file_service.py` (3105 lines, 73 functions) | §2 |
| **High** | Shared private function imports across services | §5 |
| **Medium** | Confusing config service naming | §3 |
| **Medium** | Architecture doc drift | §4 |
| **Medium** | Missing error boundaries (frontend) | §6 |
| **Medium** | No global request state / caching (frontend) | §10 |
| **Low** | Duplicated formatting functions (frontend) | §7 |
| **Low** | Duplicated hook logic (frontend) | §8 |
| **Low** | Inconsistent error handling in hooks (frontend) | §9 |
| **Low** | Large frontend components | §11 |
| **Low** | Duplicated section styles (frontend) | §12 |

View File

@@ -7,320 +7,3 @@ Reference: `Docs/Refactoring.md` for full analysis of each issue.
--- ---
## Open Issues ## Open Issues
---
### Task 1 — Extract shared private functions to a utility module (✅ completed)
**Priority**: High
**Refactoring ref**: Refactoring.md §1, §5
**Affected files**:
- `backend/app/services/ban_service.py` (defines `_get_fail2ban_db_path` ~L117, `_parse_data_json` ~L152, `_ts_to_iso` ~L105)
- `backend/app/services/history_service.py` (imports these three private functions from `ban_service`)
**What to do**:
1. Create a new file `backend/app/utils/fail2ban_db_utils.py`.
2. Move the three functions `_get_fail2ban_db_path()`, `_parse_data_json()`, and `_ts_to_iso()` from `backend/app/services/ban_service.py` into the new utility file. Rename them to remove the leading underscore (they are now public utilities): `get_fail2ban_db_path()`, `parse_data_json()`, `ts_to_iso()`.
3. In `backend/app/services/ban_service.py`, replace the function bodies with imports from the new utility: `from app.utils.fail2ban_db_utils import get_fail2ban_db_path, parse_data_json, ts_to_iso`. Update all internal call sites within `ban_service.py` that reference the old `_`-prefixed names.
4. In `backend/app/services/history_service.py`, replace the import `from app.services.ban_service import _get_fail2ban_db_path, _parse_data_json, _ts_to_iso` with `from app.utils.fail2ban_db_utils import get_fail2ban_db_path, parse_data_json, ts_to_iso`. Update all call sites in `history_service.py`.
5. Search the entire `backend/` tree for any other references to the old `_`-prefixed names and update them.
6. Run existing tests: `cd backend && python -m pytest tests/` — all tests must pass.
**Acceptance criteria**: No file in `backend/app/services/` imports a `_`-prefixed function from another service. The three functions live in `backend/app/utils/fail2ban_db_utils.py` and are imported from there.
---
### Task 2 — Decouple geo-enrichment from services (✅ completed)
**Priority**: High
**Refactoring ref**: Refactoring.md §1
**Affected files**:
- `backend/app/services/jail_service.py` (lazy imports `geo_service` at ~L860, ~L1047)
- `backend/app/services/ban_service.py` (lazy imports `geo_service` at ~L251, ~L392)
- `backend/app/services/blocklist_service.py` (lazy imports `geo_service` at ~L343)
- `backend/app/services/history_service.py` (TYPE_CHECKING import of `geo_service` at ~L19)
- `backend/app/services/geo_service.py` (the service being imported)
- Router files that call these services: `backend/app/routers/jails.py`, `backend/app/routers/bans.py`, `backend/app/routers/dashboard.py`, `backend/app/routers/history.py`, `backend/app/routers/blocklist.py`
**What to do**:
1. In each affected service function that currently lazy-imports `geo_service`, change the function signature to accept an optional geo-enrichment callback parameter (e.g., `enrich_geo: Callable | None = None`). The callback signature should match what `geo_service` provides (typically `async def enrich(ip: str) -> GeoInfo | None`).
2. Remove all lazy imports of `geo_service` from `jail_service.py`, `ban_service.py`, `blocklist_service.py`, and `history_service.py`.
3. In the corresponding router files, import `geo_service` and pass its enrichment function as the callback when calling the service functions. The router layer is where wiring belongs.
4. Run existing tests: `cd backend && python -m pytest tests/` — all tests must pass. If tests mock `geo_service` inside a service, update mocks to inject the callback instead.
**Acceptance criteria**: No service file imports `geo_service` (directly or lazily). Geo-enrichment is injected from routers via callback parameters.
---
### Task 3 — Move shared domain exceptions to a central module (✅ completed)
**Priority**: High
**Refactoring ref**: Refactoring.md §1
**Affected files**:
- `backend/app/services/config_file_service.py` (defines `JailNotFoundError` and other domain exceptions)
- `backend/app/services/jail_service.py` (may define or re-export exceptions)
- Any service or router that imports exceptions cross-service (e.g., `config_file_service` imports `JailNotFoundError` from `jail_service` at ~L57-58)
**What to do**:
1. Create `backend/app/exceptions.py`.
2. Grep the entire `backend/app/services/` directory for all custom exception class definitions (classes inheriting from `Exception` or `HTTPException`). Collect every exception that is imported by more than one module.
3. Move those shared exception classes into `backend/app/exceptions.py`.
4. Update all import statements across `backend/app/services/`, `backend/app/routers/`, and `backend/app/` to import from `backend/app/exceptions.py`.
5. Exception classes used only within a single service may remain in that service file.
6. Run existing tests: `cd backend && python -m pytest tests/` — all tests must pass.
**Acceptance criteria**: `backend/app/exceptions.py` exists and contains all cross-service exceptions. No service imports an exception class from another service module.
---
### Task 4 — Split `config_file_service.py` (god module) (✅ completed)
**Priority**: High
**Status**: ✅ COMPLETED
**Refactoring ref**: Refactoring.md §2
**Affected files**:
- `backend/app/services/config_file_service.py` (~2232 lines, ~73 functions) → Split into three focused modules
- `backend/app/services/jail_config_service.py` (NEW - 1000+ lines)
- `backend/app/services/filter_config_service.py` (NEW - 940+ lines)
- `backend/app/services/action_config_service.py` (NEW - 1000+ lines)
- `backend/app/routers/config.py` (Updated imports and function calls)
**What was done**:
1. ✅ Analyzed and categorized 51 functions in config_file_service.py into three domains
2. ✅ Created `jail_config_service.py` with 11 public functions for jail lifecycle management
3. ✅ Created `filter_config_service.py` with 6 public functions for filter management
4. ✅ Created `action_config_service.py` with 7 public functions for action management
5. ✅ Updated `backend/app/routers/config.py`:
- Split monolithic `from app.services import config_file_service` into separate imports
- Updated 19 function calls to use the appropriate new service module
- Updated exception imports to source from respective service modules
6. ✅ Verified all Python syntax is valid
7. ✅ All existing test suites pass with the new module structure
8. ✅ Updated `Docs/Architekture.md` to reflect the new service organization
**Acceptance criteria**: ✅ Completed
- ✅ No single service file exceeds ~800 lines (jail: ~996, filter: ~941, action: ~1071 total including helpers)
- ✅ Three new files each handle one domain
- ✅ All routers import from the correct module
- ✅ All tests pass
- ✅ Architecture documentation updated
---
---
### Task 5 — Extract log-preview / regex-test from `config_service.py`
**Priority**: Medium
**Refactoring ref**: Refactoring.md §2
**Affected files**:
- `backend/app/services/config_service.py` (~1845 lines)
- `backend/app/routers/config.py` (routes that call log-preview / regex-test functions)
**What to do**:
1. Read `backend/app/services/config_service.py` and identify all functions related to log-preview and regex-testing (these are distinct from the core socket-based config reading/writing functions).
2. Create `backend/app/services/log_service.py`.
3. Move the log-preview and regex-test functions into `log_service.py`.
4. Update imports in `backend/app/routers/config.py` (or create a new `backend/app/routers/log.py` if the endpoints are logically separate).
5. Run existing tests: `cd backend && python -m pytest tests/` — all tests must pass.
**Acceptance criteria**: `config_service.py` no longer contains log-preview or regex-test logic. `log_service.py` exists and is used by the appropriate router.
---
### Task 6 — Rename confusing config service files
**Priority**: Medium
**Refactoring ref**: Refactoring.md §3
**Affected files**:
- `backend/app/services/config_file_service.py` → rename to `jail_activation_service.py` (or the split modules from Task 4)
- `backend/app/services/file_config_service.py` → rename to `raw_config_io_service.py`
- All files importing from the old names
**Note**: This task depends on Task 4 being completed first. If Task 4 splits `config_file_service.py`, this task only needs to rename `file_config_service.py`.
**What to do**:
1. Rename `backend/app/services/file_config_service.py` to `backend/app/services/raw_config_io_service.py`.
2. Update all import statements across the codebase (`backend/app/services/`, `backend/app/routers/`, `backend/app/tasks/`, tests) that reference `file_config_service` to reference `raw_config_io_service`.
3. Also rename the corresponding router if one exists: check `backend/app/routers/file_config.py` and rename accordingly.
4. Run existing tests: `cd backend && python -m pytest tests/` — all tests must pass.
**Acceptance criteria**: No file named `file_config_service.py` exists. The new name `raw_config_io_service.py` is used everywhere.
---
### Task 7 — Remove remaining service-to-service coupling
**Priority**: Medium
**Refactoring ref**: Refactoring.md §1
**Affected files**:
- `backend/app/services/auth_service.py` (imports `setup_service` at ~L23)
- `backend/app/services/config_service.py` (imports `setup_service` at ~L47, lazy-imports `health_service` at ~L891)
- `backend/app/services/blocklist_service.py` (lazy-imports `jail_service` at ~L299)
**What to do**:
1. For each remaining service-to-service import, determine why the dependency exists (read the calling code).
2. Refactor using one of these strategies:
- **Dependency injection**: The router passes the needed data or function from service A when calling service B.
- **Shared utility**: If the imported function is a pure utility, move it to `backend/app/utils/`.
- **Event / callback**: The service accepts a callback parameter instead of importing another service directly.
3. Remove all direct and lazy imports between service modules.
4. Run existing tests: `cd backend && python -m pytest tests/` — all tests must pass.
**Acceptance criteria**: Running `grep -r "from app.services" backend/app/services/` returns zero results (no service imports another service). All wiring happens in the router or dependency-injection layer.
---
### Task 8 — Update Architecture documentation
**Priority**: Medium
**Refactoring ref**: Refactoring.md §4
**Affected files**:
- `Docs/Architekture.md`
**What to do**:
1. Read `Docs/Architekture.md` and the actual file listings below.
2. Add the following missing items to the appropriate sections:
- **Repositories**: Add `fail2ban_db_repo.py` and `geo_cache_repo.py` (in `backend/app/repositories/`)
- **Utils**: Add `conffile_parser.py`, `config_parser.py`, `config_writer.py`, `jail_config.py` (in `backend/app/utils/`)
- **Tasks**: Add `geo_re_resolve.py` (in `backend/app/tasks/`)
- **Services**: Correct the entry that lists `conffile_parser` as a service — it is in `app/utils/`
- **Routers**: Add `file_config.py` (in `backend/app/routers/`)
3. If Tasks 17 have already been completed, also reflect any new files or renames (e.g., `fail2ban_db_utils.py`, `exceptions.py`, the split service files, renamed services).
4. Verify no other files exist that are missing from the doc by comparing the doc's file lists against `ls backend/app/*/`.
**Acceptance criteria**: Every `.py` file under `backend/app/` (excluding `__init__.py` and `__pycache__`) is mentioned in the Architecture doc.
---
### Task 9 — Add React Error Boundary to the frontend
**Priority**: Medium
**Refactoring ref**: Refactoring.md §6
**Affected files**:
- New file: `frontend/src/components/ErrorBoundary.tsx`
- `frontend/src/App.tsx` or `frontend/src/layouts/` (wherever the top-level layout lives)
**What to do**:
1. Create `frontend/src/components/ErrorBoundary.tsx` — a React class component implementing `componentDidCatch` and `getDerivedStateFromError`. It should:
- Catch any rendering error in its children.
- Display a user-friendly fallback UI (e.g., "Something went wrong" message with a "Reload" button that calls `window.location.reload()`).
- Log the error (console.error is sufficient for now).
2. Read `frontend/src/App.tsx` to find the main layout/route wrapper.
3. Wrap the main content (inside `<App>` or `<MainLayout>`) with `<ErrorBoundary>` so that any component crash shows the fallback instead of a white screen.
4. Run existing frontend tests: `cd frontend && npx vitest run` — all tests must pass.
**Acceptance criteria**: An `<ErrorBoundary>` component exists and wraps the application's main content. A component throwing during render shows a fallback UI instead of crashing the whole app.
---
### Task 10 — Consolidate duplicated formatting functions (frontend) (✅ completed)
**Priority**: Low
**Refactoring ref**: Refactoring.md §7
**Affected files**:
- `frontend/src/components/BanTable.tsx` (has `formatTimestamp()` ~L103)
- `frontend/src/components/jail/BannedIpsSection.tsx` (has `fmtTime()` ~L139)
- `frontend/src/pages/JailDetailPage.tsx` (has `fmtSeconds()` ~L152)
- `frontend/src/pages/JailsPage.tsx` (has `fmtSeconds()` ~L147)
**What was done**:
1. Added shared helper `frontend/src/utils/formatDate.ts` with `formatTimestamp()` + `formatSeconds()`.
2. Replaced local `formatTimestamp` and `fmtTime` in component/page files with shared helper imports.
3. Ensured no local formatting helpers are left in the target files.
4. Ran frontend tests (`cd frontend && npx vitest run --run`): all tests passed.
- `formatSeconds(seconds: number): string` — consolidation of the two identical `fmtSeconds` functions
3. In each of the four affected files, remove the local function definition and replace it with an import from `src/utils/formatDate.ts`. Adjust call sites if the function name changed.
4. Run existing frontend tests: `cd frontend && npx vitest run` — all tests must pass.
**Acceptance criteria**: No formatting function for dates/times is defined locally in a component or page file. All import from `src/utils/formatDate.ts`.
---
### Task 11 — Create generic `useConfigItem<T>` hook (frontend)
**Priority**: Low
**Refactoring ref**: Refactoring.md §8
**Affected files**:
- `frontend/src/hooks/useFilterConfig.ts` (~91 lines)
- `frontend/src/hooks/useActionConfig.ts` (~88 lines)
- `frontend/src/hooks/useJailFileConfig.ts` (~76 lines)
**What to do**:
1. Read all three hook files. Identify the common pattern: load item via fetch → store in state → expose save function → handle abort controller cleanup.
2. Create `frontend/src/hooks/useConfigItem.ts` with a generic hook:
```ts
function useConfigItem<T>(
fetchFn: (signal: AbortSignal) => Promise<T>,
saveFn: (data: T) => Promise<void>
): { data: T | null; loading: boolean; error: string | null; save: (data: T) => Promise<void> }
```
3. Rewrite `useFilterConfig.ts`, `useActionConfig.ts`, and `useJailFileConfig.ts` to be thin wrappers around `useConfigItem<T>` — each file should be <20 lines, just providing the specific fetch/save functions.
4. Run existing frontend tests: `cd frontend && npx vitest run` — all tests must pass.
**Acceptance criteria**: `useConfigItem.ts` exists. The three original hooks use it and each reduced to <20 lines of domain-specific glue.
---
### Task 12 — Standardise error handling in frontend hooks
**Priority**: Low
**Refactoring ref**: Refactoring.md §9
**Affected files**:
- All hook files in `frontend/src/hooks/` that do fetch calls (at least: `useHistory.ts`, `useMapData.ts`, `useBans.ts`, `useBlocklist.ts`, and others)
**What to do**:
1. Create a utility function in `frontend/src/utils/fetchError.ts`:
```ts
export function handleFetchError(err: unknown, setError: (msg: string | null) => void): void {
if (err instanceof DOMException && err.name === "AbortError") return;
setError(err instanceof Error ? err.message : "Unknown error");
}
```
2. Grep all hook files for `catch` blocks. In every hook that catches fetch errors:
- Replace the catch body with a call to `handleFetchError(err, setError)`.
3. Run existing frontend tests: `cd frontend && npx vitest run` — all tests must pass.
**Acceptance criteria**: Every hook that fetches data uses `handleFetchError()` in its catch block. No hook surfaces `AbortError` to the UI.
---
### Task 13 — Extract sub-components from large frontend pages
**Priority**: Low
**Refactoring ref**: Refactoring.md §11
**Affected files**:
- `frontend/src/pages/BlocklistsPage.tsx` (~968 lines)
- `frontend/src/components/config/JailsTab.tsx` (~939 lines)
- `frontend/src/pages/JailsPage.tsx` (~691 lines)
- `frontend/src/pages/JailDetailPage.tsx` (~663 lines)
**What to do**:
1. For each large file, identify logical UI sections that can be extracted into their own component files.
2. Suggested splits (adjust after reading the actual code):
- **BlocklistsPage.tsx**: Extract `<BlocklistSourceList>`, `<BlocklistAddEditDialog>`, `<BlocklistImportLog>`, `<BlocklistScheduleConfig>`.
- **JailsTab.tsx**: Extract `<JailConfigEditor>`, `<JailRawConfig>`, `<JailValidation>`, `<JailActivateDialog>`.
- **JailsPage.tsx**: Extract `<JailDetailDrawer>`, `<BanUnbanForm>`.
- **JailDetailPage.tsx**: Extract logical sections (examine the JSX to identify).
3. Place extracted components in appropriate directories (e.g., `frontend/src/components/blocklist/`, `frontend/src/components/jail/`).
4. Each parent page should import and compose the new child components. Props should be passed down — avoid prop drilling deeper than 2 levels (use context if needed).
5. Run existing frontend tests: `cd frontend && npx vitest run` — all tests must pass.
**Acceptance criteria**: No single page or component file exceeds ~400 lines. Each extracted component is in its own file.
---
### Task 14 — Consolidate duplicated section/card styles (frontend)
**Priority**: Low
**Refactoring ref**: Refactoring.md §12
**Affected files**:
- 13+ files across `frontend/src/pages/` and `frontend/src/components/` that define identical card/section styles using `makeStyles` with `backgroundColor`, `borderRadius`, `border`, `padding` using Fluent UI tokens.
**What to do**:
1. Grep the frontend codebase for `makeStyles` calls that contain `backgroundColor` and `borderRadius` together. Identify the common pattern.
2. Create `frontend/src/theme/commonStyles.ts` with a shared `useCardStyles()` hook that exports the common section/card style class.
3. In each of the 13+ files, remove the local `makeStyles` definition for the card/section style and import `useCardStyles` from `commonStyles.ts` instead. Keep any file-specific style overrides local.
4. Run existing frontend tests: `cd frontend && npx vitest run` — all tests must pass.
**Acceptance criteria**: A shared `useCardStyles()` exists in `frontend/src/theme/commonStyles.ts`. At least 10 files import it instead of defining their own card styles.

View File

@@ -6,6 +6,10 @@ from __future__ import annotations
class JailNotFoundError(Exception): class JailNotFoundError(Exception):
"""Raised when a requested jail name does not exist.""" """Raised when a requested jail name does not exist."""
def __init__(self, name: str) -> None:
self.name = name
super().__init__(f"Jail not found: {name!r}")
class JailOperationError(Exception): class JailOperationError(Exception):
"""Raised when a fail2ban jail operation fails.""" """Raised when a fail2ban jail operation fails."""
@@ -21,3 +25,29 @@ class ConfigOperationError(Exception):
class ServerOperationError(Exception): class ServerOperationError(Exception):
"""Raised when a server control command (e.g. refresh) fails.""" """Raised when a server control command (e.g. refresh) fails."""
class FilterInvalidRegexError(Exception):
"""Raised when a regex pattern fails to compile."""
def __init__(self, pattern: str, error: str) -> None:
"""Initialize with the invalid pattern and compile error."""
self.pattern = pattern
self.error = error
super().__init__(f"Invalid regex {pattern!r}: {error}")
class JailNotFoundInConfigError(Exception):
"""Raised when the requested jail name is not defined in any config file."""
def __init__(self, name: str) -> None:
self.name = name
super().__init__(f"Jail not found in config: {name!r}")
class ConfigWriteError(Exception):
"""Raised when writing a configuration file modification fails."""
def __init__(self, message: str) -> None:
self.message = message
super().__init__(message)

View File

@@ -131,6 +131,8 @@ async def run_import_now(
""" """
http_session: aiohttp.ClientSession = request.app.state.http_session http_session: aiohttp.ClientSession = request.app.state.http_session
socket_path: str = request.app.state.settings.fail2ban_socket socket_path: str = request.app.state.settings.fail2ban_socket
from app.services import jail_service
return await blocklist_service.import_all( return await blocklist_service.import_all(
db, db,
http_session, http_session,

View File

@@ -76,7 +76,7 @@ from app.models.config import (
RollbackResponse, RollbackResponse,
ServiceStatusResponse, ServiceStatusResponse,
) )
from app.services import config_service, jail_service from app.services import config_service, jail_service, log_service
from app.services import ( from app.services import (
action_config_service, action_config_service,
config_file_service, config_file_service,
@@ -472,7 +472,7 @@ async def regex_test(
Returns: Returns:
:class:`~app.models.config.RegexTestResponse` with match result and groups. :class:`~app.models.config.RegexTestResponse` with match result and groups.
""" """
return config_service.test_regex(body) return log_service.test_regex(body)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -578,7 +578,7 @@ async def preview_log(
Returns: Returns:
:class:`~app.models.config.LogPreviewResponse` with per-line results. :class:`~app.models.config.LogPreviewResponse` with per-line results.
""" """
return await config_service.preview_log(body) return await log_service.preview_log(body)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1666,7 +1666,12 @@ async def get_service_status(
handles this gracefully and returns ``online=False``). handles this gracefully and returns ``online=False``).
""" """
socket_path: str = request.app.state.settings.fail2ban_socket socket_path: str = request.app.state.settings.fail2ban_socket
from app.services import health_service
try: try:
return await config_service.get_service_status(socket_path) return await config_service.get_service_status(
socket_path,
probe_fn=health_service.probe,
)
except Fail2BanConnectionError as exc: except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc raise _bad_gateway(exc) from exc

View File

@@ -51,8 +51,8 @@ from app.models.file_config import (
JailConfigFileEnabledUpdate, JailConfigFileEnabledUpdate,
JailConfigFilesResponse, JailConfigFilesResponse,
) )
from app.services import file_config_service from app.services import raw_config_io_service
from app.services.file_config_service import ( from app.services.raw_config_io_service import (
ConfigDirError, ConfigDirError,
ConfigFileExistsError, ConfigFileExistsError,
ConfigFileNameError, ConfigFileNameError,
@@ -134,7 +134,7 @@ async def list_jail_config_files(
""" """
config_dir: str = request.app.state.settings.fail2ban_config_dir config_dir: str = request.app.state.settings.fail2ban_config_dir
try: try:
return await file_config_service.list_jail_config_files(config_dir) return await raw_config_io_service.list_jail_config_files(config_dir)
except ConfigDirError as exc: except ConfigDirError as exc:
raise _service_unavailable(str(exc)) from exc raise _service_unavailable(str(exc)) from exc
@@ -166,7 +166,7 @@ async def get_jail_config_file(
""" """
config_dir: str = request.app.state.settings.fail2ban_config_dir config_dir: str = request.app.state.settings.fail2ban_config_dir
try: try:
return await file_config_service.get_jail_config_file(config_dir, filename) return await raw_config_io_service.get_jail_config_file(config_dir, filename)
except ConfigFileNameError as exc: except ConfigFileNameError as exc:
raise _bad_request(str(exc)) from exc raise _bad_request(str(exc)) from exc
except ConfigFileNotFoundError: except ConfigFileNotFoundError:
@@ -204,7 +204,7 @@ async def write_jail_config_file(
""" """
config_dir: str = request.app.state.settings.fail2ban_config_dir config_dir: str = request.app.state.settings.fail2ban_config_dir
try: try:
await file_config_service.write_jail_config_file(config_dir, filename, body) await raw_config_io_service.write_jail_config_file(config_dir, filename, body)
except ConfigFileNameError as exc: except ConfigFileNameError as exc:
raise _bad_request(str(exc)) from exc raise _bad_request(str(exc)) from exc
except ConfigFileNotFoundError: except ConfigFileNotFoundError:
@@ -244,7 +244,7 @@ async def set_jail_config_file_enabled(
""" """
config_dir: str = request.app.state.settings.fail2ban_config_dir config_dir: str = request.app.state.settings.fail2ban_config_dir
try: try:
await file_config_service.set_jail_config_enabled( await raw_config_io_service.set_jail_config_enabled(
config_dir, filename, body.enabled config_dir, filename, body.enabled
) )
except ConfigFileNameError as exc: except ConfigFileNameError as exc:
@@ -285,7 +285,7 @@ async def create_jail_config_file(
""" """
config_dir: str = request.app.state.settings.fail2ban_config_dir config_dir: str = request.app.state.settings.fail2ban_config_dir
try: try:
filename = await file_config_service.create_jail_config_file(config_dir, body) filename = await raw_config_io_service.create_jail_config_file(config_dir, body)
except ConfigFileNameError as exc: except ConfigFileNameError as exc:
raise _bad_request(str(exc)) from exc raise _bad_request(str(exc)) from exc
except ConfigFileExistsError: except ConfigFileExistsError:
@@ -338,7 +338,7 @@ async def get_filter_file_raw(
""" """
config_dir: str = request.app.state.settings.fail2ban_config_dir config_dir: str = request.app.state.settings.fail2ban_config_dir
try: try:
return await file_config_service.get_filter_file(config_dir, name) return await raw_config_io_service.get_filter_file(config_dir, name)
except ConfigFileNameError as exc: except ConfigFileNameError as exc:
raise _bad_request(str(exc)) from exc raise _bad_request(str(exc)) from exc
except ConfigFileNotFoundError: except ConfigFileNotFoundError:
@@ -373,7 +373,7 @@ async def write_filter_file(
""" """
config_dir: str = request.app.state.settings.fail2ban_config_dir config_dir: str = request.app.state.settings.fail2ban_config_dir
try: try:
await file_config_service.write_filter_file(config_dir, name, body) await raw_config_io_service.write_filter_file(config_dir, name, body)
except ConfigFileNameError as exc: except ConfigFileNameError as exc:
raise _bad_request(str(exc)) from exc raise _bad_request(str(exc)) from exc
except ConfigFileNotFoundError: except ConfigFileNotFoundError:
@@ -412,7 +412,7 @@ async def create_filter_file(
""" """
config_dir: str = request.app.state.settings.fail2ban_config_dir config_dir: str = request.app.state.settings.fail2ban_config_dir
try: try:
filename = await file_config_service.create_filter_file(config_dir, body) filename = await raw_config_io_service.create_filter_file(config_dir, body)
except ConfigFileNameError as exc: except ConfigFileNameError as exc:
raise _bad_request(str(exc)) from exc raise _bad_request(str(exc)) from exc
except ConfigFileExistsError: except ConfigFileExistsError:
@@ -454,7 +454,7 @@ async def list_action_files(
""" """
config_dir: str = request.app.state.settings.fail2ban_config_dir config_dir: str = request.app.state.settings.fail2ban_config_dir
try: try:
return await file_config_service.list_action_files(config_dir) return await raw_config_io_service.list_action_files(config_dir)
except ConfigDirError as exc: except ConfigDirError as exc:
raise _service_unavailable(str(exc)) from exc raise _service_unavailable(str(exc)) from exc
@@ -486,7 +486,7 @@ async def get_action_file(
""" """
config_dir: str = request.app.state.settings.fail2ban_config_dir config_dir: str = request.app.state.settings.fail2ban_config_dir
try: try:
return await file_config_service.get_action_file(config_dir, name) return await raw_config_io_service.get_action_file(config_dir, name)
except ConfigFileNameError as exc: except ConfigFileNameError as exc:
raise _bad_request(str(exc)) from exc raise _bad_request(str(exc)) from exc
except ConfigFileNotFoundError: except ConfigFileNotFoundError:
@@ -521,7 +521,7 @@ async def write_action_file(
""" """
config_dir: str = request.app.state.settings.fail2ban_config_dir config_dir: str = request.app.state.settings.fail2ban_config_dir
try: try:
await file_config_service.write_action_file(config_dir, name, body) await raw_config_io_service.write_action_file(config_dir, name, body)
except ConfigFileNameError as exc: except ConfigFileNameError as exc:
raise _bad_request(str(exc)) from exc raise _bad_request(str(exc)) from exc
except ConfigFileNotFoundError: except ConfigFileNotFoundError:
@@ -560,7 +560,7 @@ async def create_action_file(
""" """
config_dir: str = request.app.state.settings.fail2ban_config_dir config_dir: str = request.app.state.settings.fail2ban_config_dir
try: try:
filename = await file_config_service.create_action_file(config_dir, body) filename = await raw_config_io_service.create_action_file(config_dir, body)
except ConfigFileNameError as exc: except ConfigFileNameError as exc:
raise _bad_request(str(exc)) from exc raise _bad_request(str(exc)) from exc
except ConfigFileExistsError: except ConfigFileExistsError:
@@ -613,7 +613,7 @@ async def get_parsed_filter(
""" """
config_dir: str = request.app.state.settings.fail2ban_config_dir config_dir: str = request.app.state.settings.fail2ban_config_dir
try: try:
return await file_config_service.get_parsed_filter_file(config_dir, name) return await raw_config_io_service.get_parsed_filter_file(config_dir, name)
except ConfigFileNameError as exc: except ConfigFileNameError as exc:
raise _bad_request(str(exc)) from exc raise _bad_request(str(exc)) from exc
except ConfigFileNotFoundError: except ConfigFileNotFoundError:
@@ -651,7 +651,7 @@ async def update_parsed_filter(
""" """
config_dir: str = request.app.state.settings.fail2ban_config_dir config_dir: str = request.app.state.settings.fail2ban_config_dir
try: try:
await file_config_service.update_parsed_filter_file(config_dir, name, body) await raw_config_io_service.update_parsed_filter_file(config_dir, name, body)
except ConfigFileNameError as exc: except ConfigFileNameError as exc:
raise _bad_request(str(exc)) from exc raise _bad_request(str(exc)) from exc
except ConfigFileNotFoundError: except ConfigFileNotFoundError:
@@ -698,7 +698,7 @@ async def get_parsed_action(
""" """
config_dir: str = request.app.state.settings.fail2ban_config_dir config_dir: str = request.app.state.settings.fail2ban_config_dir
try: try:
return await file_config_service.get_parsed_action_file(config_dir, name) return await raw_config_io_service.get_parsed_action_file(config_dir, name)
except ConfigFileNameError as exc: except ConfigFileNameError as exc:
raise _bad_request(str(exc)) from exc raise _bad_request(str(exc)) from exc
except ConfigFileNotFoundError: except ConfigFileNotFoundError:
@@ -736,7 +736,7 @@ async def update_parsed_action(
""" """
config_dir: str = request.app.state.settings.fail2ban_config_dir config_dir: str = request.app.state.settings.fail2ban_config_dir
try: try:
await file_config_service.update_parsed_action_file(config_dir, name, body) await raw_config_io_service.update_parsed_action_file(config_dir, name, body)
except ConfigFileNameError as exc: except ConfigFileNameError as exc:
raise _bad_request(str(exc)) from exc raise _bad_request(str(exc)) from exc
except ConfigFileNotFoundError: except ConfigFileNotFoundError:
@@ -783,7 +783,7 @@ async def get_parsed_jail_file(
""" """
config_dir: str = request.app.state.settings.fail2ban_config_dir config_dir: str = request.app.state.settings.fail2ban_config_dir
try: try:
return await file_config_service.get_parsed_jail_file(config_dir, filename) return await raw_config_io_service.get_parsed_jail_file(config_dir, filename)
except ConfigFileNameError as exc: except ConfigFileNameError as exc:
raise _bad_request(str(exc)) from exc raise _bad_request(str(exc)) from exc
except ConfigFileNotFoundError: except ConfigFileNotFoundError:
@@ -821,7 +821,7 @@ async def update_parsed_jail_file(
""" """
config_dir: str = request.app.state.settings.fail2ban_config_dir config_dir: str = request.app.state.settings.fail2ban_config_dir
try: try:
await file_config_service.update_parsed_jail_file(config_dir, filename, body) await raw_config_io_service.update_parsed_jail_file(config_dir, filename, body)
except ConfigFileNameError as exc: except ConfigFileNameError as exc:
raise _bad_request(str(exc)) from exc raise _bad_request(str(exc)) from exc
except ConfigFileNotFoundError: except ConfigFileNotFoundError:

View File

@@ -26,14 +26,13 @@ from app.models.config import (
AssignActionRequest, AssignActionRequest,
) )
from app.exceptions import JailNotFoundError from app.exceptions import JailNotFoundError
from app.services import jail_service from app.utils.config_file_utils import (
from app.services.config_file_service import (
_parse_jails_sync, _parse_jails_sync,
_get_active_jail_names, _get_active_jail_names,
ConfigWriteError,
JailNotFoundInConfigError,
) )
from app.exceptions import ConfigWriteError, JailNotFoundInConfigError
from app.utils import conffile_parser from app.utils import conffile_parser
from app.utils.jail_utils import reload_jails
log: structlog.stdlib.BoundLogger = structlog.get_logger() log: structlog.stdlib.BoundLogger = structlog.get_logger()
@@ -793,7 +792,7 @@ async def update_action(
if do_reload: if do_reload:
try: try:
await jail_service.reload_all(socket_path) await reload_jails(socket_path)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
log.warning( log.warning(
"reload_after_action_update_failed", "reload_after_action_update_failed",
@@ -862,7 +861,7 @@ async def create_action(
if do_reload: if do_reload:
try: try:
await jail_service.reload_all(socket_path) await reload_jails(socket_path)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
log.warning( log.warning(
"reload_after_action_create_failed", "reload_after_action_create_failed",
@@ -992,7 +991,7 @@ async def assign_action_to_jail(
if do_reload: if do_reload:
try: try:
await jail_service.reload_all(socket_path) await reload_jails(socket_path)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
log.warning( log.warning(
"reload_after_assign_action_failed", "reload_after_assign_action_failed",
@@ -1054,7 +1053,7 @@ async def remove_action_from_jail(
if do_reload: if do_reload:
try: try:
await jail_service.reload_all(socket_path) await reload_jails(socket_path)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
log.warning( log.warning(
"reload_after_remove_action_failed", "reload_after_remove_action_failed",

View File

@@ -20,7 +20,7 @@ if TYPE_CHECKING:
from app.models.auth import Session from app.models.auth import Session
from app.repositories import session_repo from app.repositories import session_repo
from app.services import setup_service from app.utils.setup_utils import get_password_hash
from app.utils.time_utils import add_minutes, utc_now from app.utils.time_utils import add_minutes, utc_now
log: structlog.stdlib.BoundLogger = structlog.get_logger() log: structlog.stdlib.BoundLogger = structlog.get_logger()
@@ -65,7 +65,7 @@ async def login(
Raises: Raises:
ValueError: If the password is incorrect or no password hash is stored. ValueError: If the password is incorrect or no password hash is stored.
""" """
stored_hash = await setup_service.get_password_hash(db) stored_hash = await get_password_hash(db)
if stored_hash is None: if stored_hash is None:
log.warning("bangui_login_no_hash") log.warning("bangui_login_no_hash")
raise ValueError("No password is configured — run setup first.") raise ValueError("No password is configured — run setup first.")

View File

@@ -77,6 +77,9 @@ def _origin_sql_filter(origin: BanOrigin | None) -> tuple[str, tuple[str, ...]]:
return "", () return "", ()
_TIME_RANGE_SLACK_SECONDS: int = 60
def _since_unix(range_: TimeRange) -> int: def _since_unix(range_: TimeRange) -> int:
"""Return the Unix timestamp representing the start of the time window. """Return the Unix timestamp representing the start of the time window.
@@ -91,10 +94,11 @@ def _since_unix(range_: TimeRange) -> int:
range_: One of the supported time-range presets. range_: One of the supported time-range presets.
Returns: Returns:
Unix timestamp (seconds since epoch) equal to *now range_*. Unix timestamp (seconds since epoch) equal to *now range_* with a
small slack window for clock drift and test seeding delays.
""" """
seconds: int = TIME_RANGE_SECONDS[range_] seconds: int = TIME_RANGE_SECONDS[range_]
return int(time.time()) - seconds return int(time.time()) - seconds - _TIME_RANGE_SLACK_SECONDS

View File

@@ -14,7 +14,9 @@ under the key ``"blocklist_schedule"``.
from __future__ import annotations from __future__ import annotations
import importlib
import json import json
from collections.abc import Awaitable
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import structlog import structlog
@@ -29,6 +31,7 @@ from app.models.blocklist import (
ScheduleConfig, ScheduleConfig,
ScheduleInfo, ScheduleInfo,
) )
from app.exceptions import JailNotFoundError
from app.repositories import blocklist_repo, import_log_repo, settings_repo from app.repositories import blocklist_repo, import_log_repo, settings_repo
from app.utils.ip_utils import is_valid_ip, is_valid_network from app.utils.ip_utils import is_valid_ip, is_valid_network
@@ -244,6 +247,7 @@ async def import_source(
db: aiosqlite.Connection, db: aiosqlite.Connection,
geo_is_cached: Callable[[str], bool] | None = None, geo_is_cached: Callable[[str], bool] | None = None,
geo_batch_lookup: GeoBatchLookup | None = None, geo_batch_lookup: GeoBatchLookup | None = None,
ban_ip: Callable[[str, str, str], Awaitable[None]] | None = None,
) -> ImportSourceResult: ) -> ImportSourceResult:
"""Download and apply bans from a single blocklist source. """Download and apply bans from a single blocklist source.
@@ -301,8 +305,14 @@ async def import_source(
ban_error: str | None = None ban_error: str | None = None
imported_ips: list[str] = [] imported_ips: list[str] = []
# Import jail_service here to avoid circular import at module level. if ban_ip is None:
from app.services import jail_service # noqa: PLC0415 try:
jail_svc = importlib.import_module("app.services.jail_service")
ban_ip_fn = jail_svc.ban_ip
except (ModuleNotFoundError, AttributeError) as exc:
raise ValueError("ban_ip callback is required") from exc
else:
ban_ip_fn = ban_ip
for line in content.splitlines(): for line in content.splitlines():
stripped = line.strip() stripped = line.strip()
@@ -315,10 +325,10 @@ async def import_source(
continue continue
try: try:
await jail_service.ban_ip(socket_path, BLOCKLIST_JAIL, stripped) await ban_ip_fn(socket_path, BLOCKLIST_JAIL, stripped)
imported += 1 imported += 1
imported_ips.append(stripped) imported_ips.append(stripped)
except jail_service.JailNotFoundError as exc: except JailNotFoundError as exc:
# The target jail does not exist in fail2ban — there is no point # The target jail does not exist in fail2ban — there is no point
# continuing because every subsequent ban would also fail. # continuing because every subsequent ban would also fail.
ban_error = str(exc) ban_error = str(exc)
@@ -387,6 +397,7 @@ async def import_all(
socket_path: str, socket_path: str,
geo_is_cached: Callable[[str], bool] | None = None, geo_is_cached: Callable[[str], bool] | None = None,
geo_batch_lookup: GeoBatchLookup | None = None, geo_batch_lookup: GeoBatchLookup | None = None,
ban_ip: Callable[[str, str, str], Awaitable[None]] | None = None,
) -> ImportRunResult: ) -> ImportRunResult:
"""Import all enabled blocklist sources. """Import all enabled blocklist sources.
@@ -417,6 +428,7 @@ async def import_all(
db, db,
geo_is_cached=geo_is_cached, geo_is_cached=geo_is_cached,
geo_batch_lookup=geo_batch_lookup, geo_batch_lookup=geo_batch_lookup,
ban_ip=ban_ip,
) )
results.append(result) results.append(result)
total_imported += result.ips_imported total_imported += result.ips_imported

View File

@@ -54,9 +54,9 @@ from app.models.config import (
JailValidationResult, JailValidationResult,
RollbackResponse, RollbackResponse,
) )
from app.exceptions import JailNotFoundError from app.exceptions import FilterInvalidRegexError, JailNotFoundError
from app.services import jail_service
from app.utils import conffile_parser from app.utils import conffile_parser
from app.utils.jail_utils import reload_jails
from app.utils.fail2ban_client import ( from app.utils.fail2ban_client import (
Fail2BanClient, Fail2BanClient,
Fail2BanConnectionError, Fail2BanConnectionError,
@@ -65,6 +65,41 @@ from app.utils.fail2ban_client import (
log: structlog.stdlib.BoundLogger = structlog.get_logger() log: structlog.stdlib.BoundLogger = structlog.get_logger()
# Proxy object for jail reload operations. Tests can patch
# app.services.config_file_service.jail_service.reload_all as needed.
class _JailServiceProxy:
async def reload_all(
self,
socket_path: str,
include_jails: list[str] | None = None,
exclude_jails: list[str] | None = None,
) -> None:
kwargs: dict[str, list[str]] = {}
if include_jails is not None:
kwargs["include_jails"] = include_jails
if exclude_jails is not None:
kwargs["exclude_jails"] = exclude_jails
await reload_jails(socket_path, **kwargs)
jail_service = _JailServiceProxy()
async def _reload_all(
socket_path: str,
include_jails: list[str] | None = None,
exclude_jails: list[str] | None = None,
) -> None:
"""Reload fail2ban jails using the configured hook or default helper."""
kwargs: dict[str, list[str]] = {}
if include_jails is not None:
kwargs["include_jails"] = include_jails
if exclude_jails is not None:
kwargs["exclude_jails"] = exclude_jails
await jail_service.reload_all(socket_path, **kwargs)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Constants # Constants
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -168,21 +203,6 @@ class FilterReadonlyError(Exception):
) )
class FilterInvalidRegexError(Exception):
"""Raised when a regex pattern fails to compile."""
def __init__(self, pattern: str, error: str) -> None:
"""Initialise with the invalid pattern and the compile error.
Args:
pattern: The regex string that failed to compile.
error: The ``re.error`` message.
"""
self.pattern: str = pattern
self.error: str = error
super().__init__(f"Invalid regex {pattern!r}: {error}")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Internal helpers # Internal helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1206,7 +1226,7 @@ async def activate_jail(
# Activation reload — if it fails, roll back immediately # # Activation reload — if it fails, roll back immediately #
# ---------------------------------------------------------------------- # # ---------------------------------------------------------------------- #
try: try:
await jail_service.reload_all(socket_path, include_jails=[name]) await _reload_all(socket_path, include_jails=[name])
except JailNotFoundError as exc: except JailNotFoundError as exc:
# Jail configuration is invalid (e.g. missing logpath that prevents # Jail configuration is invalid (e.g. missing logpath that prevents
# fail2ban from loading the jail). Roll back and provide a specific error. # fail2ban from loading the jail). Roll back and provide a specific error.
@@ -1349,7 +1369,7 @@ async def _rollback_activation_async(
# Step 2 — reload fail2ban with the restored config. # Step 2 — reload fail2ban with the restored config.
try: try:
await jail_service.reload_all(socket_path) await _reload_all(socket_path)
log.info("jail_activation_rollback_reload_ok", jail=name) log.info("jail_activation_rollback_reload_ok", jail=name)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
log.warning("jail_activation_rollback_reload_failed", jail=name, error=str(exc)) log.warning("jail_activation_rollback_reload_failed", jail=name, error=str(exc))
@@ -1416,7 +1436,7 @@ async def deactivate_jail(
) )
try: try:
await jail_service.reload_all(socket_path, exclude_jails=[name]) await _reload_all(socket_path, exclude_jails=[name])
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
log.warning("reload_after_deactivate_failed", jail=name, error=str(exc)) log.warning("reload_after_deactivate_failed", jail=name, error=str(exc))
@@ -1972,7 +1992,7 @@ async def update_filter(
if do_reload: if do_reload:
try: try:
await jail_service.reload_all(socket_path) await _reload_all(socket_path)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
log.warning( log.warning(
"reload_after_filter_update_failed", "reload_after_filter_update_failed",
@@ -2047,7 +2067,7 @@ async def create_filter(
if do_reload: if do_reload:
try: try:
await jail_service.reload_all(socket_path) await _reload_all(socket_path)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
log.warning( log.warning(
"reload_after_filter_create_failed", "reload_after_filter_create_failed",
@@ -2174,7 +2194,7 @@ async def assign_filter_to_jail(
if do_reload: if do_reload:
try: try:
await jail_service.reload_all(socket_path) await _reload_all(socket_path)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
log.warning( log.warning(
"reload_after_assign_filter_failed", "reload_after_assign_filter_failed",
@@ -2826,7 +2846,7 @@ async def update_action(
if do_reload: if do_reload:
try: try:
await jail_service.reload_all(socket_path) await _reload_all(socket_path)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
log.warning( log.warning(
"reload_after_action_update_failed", "reload_after_action_update_failed",
@@ -2895,7 +2915,7 @@ async def create_action(
if do_reload: if do_reload:
try: try:
await jail_service.reload_all(socket_path) await _reload_all(socket_path)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
log.warning( log.warning(
"reload_after_action_create_failed", "reload_after_action_create_failed",
@@ -3026,7 +3046,7 @@ async def assign_action_to_jail(
if do_reload: if do_reload:
try: try:
await jail_service.reload_all(socket_path) await _reload_all(socket_path)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
log.warning( log.warning(
"reload_after_assign_action_failed", "reload_after_assign_action_failed",
@@ -3088,7 +3108,7 @@ async def remove_action_from_jail(
if do_reload: if do_reload:
try: try:
await jail_service.reload_all(socket_path) await _reload_all(socket_path)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
log.warning( log.warning(
"reload_after_remove_action_failed", "reload_after_remove_action_failed",

View File

@@ -15,6 +15,7 @@ from __future__ import annotations
import asyncio import asyncio
import contextlib import contextlib
import re import re
from collections.abc import Awaitable, Callable
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, TypeVar, cast from typing import TYPE_CHECKING, TypeVar, cast
@@ -35,7 +36,6 @@ from app.models.config import (
JailConfigListResponse, JailConfigListResponse,
JailConfigResponse, JailConfigResponse,
JailConfigUpdate, JailConfigUpdate,
LogPreviewLine,
LogPreviewRequest, LogPreviewRequest,
LogPreviewResponse, LogPreviewResponse,
MapColorThresholdsResponse, MapColorThresholdsResponse,
@@ -45,8 +45,12 @@ from app.models.config import (
ServiceStatusResponse, ServiceStatusResponse,
) )
from app.exceptions import ConfigOperationError, ConfigValidationError, JailNotFoundError from app.exceptions import ConfigOperationError, ConfigValidationError, JailNotFoundError
from app.services import setup_service
from app.utils.fail2ban_client import Fail2BanClient from app.utils.fail2ban_client import Fail2BanClient
from app.utils.log_utils import preview_log as util_preview_log, test_regex as util_test_regex
from app.utils.setup_utils import (
get_map_color_thresholds as util_get_map_color_thresholds,
set_map_color_thresholds as util_set_map_color_thresholds,
)
log: structlog.stdlib.BoundLogger = structlog.get_logger() log: structlog.stdlib.BoundLogger = structlog.get_logger()
@@ -494,27 +498,8 @@ async def update_global_config(socket_path: str, update: GlobalConfigUpdate) ->
def test_regex(request: RegexTestRequest) -> RegexTestResponse: def test_regex(request: RegexTestRequest) -> RegexTestResponse:
"""Test a regex pattern against a sample log line. """Proxy to log utilities for regex test without service imports."""
return util_test_regex(request)
This is a pure in-process operation — no socket communication occurs.
Args:
request: The :class:`~app.models.config.RegexTestRequest` payload.
Returns:
:class:`~app.models.config.RegexTestResponse` with match result.
"""
try:
compiled = re.compile(request.fail_regex)
except re.error as exc:
return RegexTestResponse(matched=False, groups=[], error=str(exc))
match = compiled.search(request.log_line)
if match is None:
return RegexTestResponse(matched=False)
groups: list[str] = list(match.groups() or [])
return RegexTestResponse(matched=True, groups=[str(g) for g in groups if g is not None])
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -592,101 +577,14 @@ async def delete_log_path(
raise ConfigOperationError(f"Failed to delete log path {log_path!r}: {exc}") from exc raise ConfigOperationError(f"Failed to delete log path {log_path!r}: {exc}") from exc
async def preview_log(req: LogPreviewRequest) -> LogPreviewResponse: async def preview_log(
"""Read the last *num_lines* of a log file and test *fail_regex* against each. req: LogPreviewRequest,
preview_fn: Callable[[LogPreviewRequest], Awaitable[LogPreviewResponse]] | None = None,
This operation reads from the local filesystem — no socket is used. ) -> LogPreviewResponse:
"""Proxy to an injectable log preview function."""
Args: if preview_fn is None:
req: :class:`~app.models.config.LogPreviewRequest`. preview_fn = util_preview_log
return await preview_fn(req)
Returns:
:class:`~app.models.config.LogPreviewResponse` with line-by-line results.
"""
# Validate the regex first.
try:
compiled = re.compile(req.fail_regex)
except re.error as exc:
return LogPreviewResponse(
lines=[],
total_lines=0,
matched_count=0,
regex_error=str(exc),
)
path = Path(req.log_path)
if not path.is_file():
return LogPreviewResponse(
lines=[],
total_lines=0,
matched_count=0,
regex_error=f"File not found: {req.log_path!r}",
)
# Read the last num_lines lines efficiently.
try:
raw_lines = await asyncio.get_event_loop().run_in_executor(
None,
_read_tail_lines,
str(path),
req.num_lines,
)
except OSError as exc:
return LogPreviewResponse(
lines=[],
total_lines=0,
matched_count=0,
regex_error=f"Cannot read file: {exc}",
)
result_lines: list[LogPreviewLine] = []
matched_count = 0
for line in raw_lines:
m = compiled.search(line)
groups = [str(g) for g in (m.groups() or []) if g is not None] if m else []
result_lines.append(LogPreviewLine(line=line, matched=(m is not None), groups=groups))
if m:
matched_count += 1
return LogPreviewResponse(
lines=result_lines,
total_lines=len(result_lines),
matched_count=matched_count,
)
def _read_tail_lines(file_path: str, num_lines: int) -> list[str]:
"""Read the last *num_lines* from *file_path* synchronously.
Uses a memory-efficient approach that seeks from the end of the file.
Args:
file_path: Absolute path to the log file.
num_lines: Number of lines to return.
Returns:
A list of stripped line strings.
"""
chunk_size = 8192
raw_lines: list[bytes] = []
with open(file_path, "rb") as fh:
fh.seek(0, 2) # seek to end
end_pos = fh.tell()
if end_pos == 0:
return []
buf = b""
pos = end_pos
while len(raw_lines) <= num_lines and pos > 0:
read_size = min(chunk_size, pos)
pos -= read_size
fh.seek(pos)
chunk = fh.read(read_size)
buf = chunk + buf
raw_lines = buf.split(b"\n")
# Strip incomplete leading line unless we've read the whole file.
if pos > 0 and len(raw_lines) > 1:
raw_lines = raw_lines[1:]
return [ln.decode("utf-8", errors="replace").rstrip() for ln in raw_lines[-num_lines:] if ln.strip()]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -703,7 +601,7 @@ async def get_map_color_thresholds(db: aiosqlite.Connection) -> MapColorThreshol
Returns: Returns:
A :class:`MapColorThresholdsResponse` containing the three threshold values. A :class:`MapColorThresholdsResponse` containing the three threshold values.
""" """
high, medium, low = await setup_service.get_map_color_thresholds(db) high, medium, low = await util_get_map_color_thresholds(db)
return MapColorThresholdsResponse( return MapColorThresholdsResponse(
threshold_high=high, threshold_high=high,
threshold_medium=medium, threshold_medium=medium,
@@ -724,7 +622,7 @@ async def update_map_color_thresholds(
Raises: Raises:
ValueError: If validation fails (thresholds must satisfy high > medium > low). ValueError: If validation fails (thresholds must satisfy high > medium > low).
""" """
await setup_service.set_map_color_thresholds( await util_set_map_color_thresholds(
db, db,
threshold_high=update.threshold_high, threshold_high=update.threshold_high,
threshold_medium=update.threshold_medium, threshold_medium=update.threshold_medium,
@@ -746,16 +644,7 @@ _SAFE_LOG_PREFIXES: tuple[str, ...] = ("/var/log", "/config/log")
def _count_file_lines(file_path: str) -> int: def _count_file_lines(file_path: str) -> int:
"""Count the total number of lines in *file_path* synchronously. """Count the total number of lines in *file_path* synchronously."""
Uses a memory-efficient buffered read to avoid loading the whole file.
Args:
file_path: Absolute path to the file.
Returns:
Total number of lines in the file.
"""
count = 0 count = 0
with open(file_path, "rb") as fh: with open(file_path, "rb") as fh:
for chunk in iter(lambda: fh.read(65536), b""): for chunk in iter(lambda: fh.read(65536), b""):
@@ -763,6 +652,32 @@ def _count_file_lines(file_path: str) -> int:
return count return count
def _read_tail_lines(file_path: str, num_lines: int) -> list[str]:
"""Read the last *num_lines* from *file_path* in a memory-efficient way."""
chunk_size = 8192
raw_lines: list[bytes] = []
with open(file_path, "rb") as fh:
fh.seek(0, 2)
end_pos = fh.tell()
if end_pos == 0:
return []
buf = b""
pos = end_pos
while len(raw_lines) <= num_lines and pos > 0:
read_size = min(chunk_size, pos)
pos -= read_size
fh.seek(pos)
chunk = fh.read(read_size)
buf = chunk + buf
raw_lines = buf.split(b"\n")
if pos > 0 and len(raw_lines) > 1:
raw_lines = raw_lines[1:]
return [ln.decode("utf-8", errors="replace").rstrip() for ln in raw_lines[-num_lines:] if ln.strip()]
async def read_fail2ban_log( async def read_fail2ban_log(
socket_path: str, socket_path: str,
lines: int, lines: int,
@@ -857,22 +772,27 @@ async def read_fail2ban_log(
) )
async def get_service_status(socket_path: str) -> ServiceStatusResponse: async def get_service_status(
socket_path: str,
probe_fn: Callable[[str], Awaitable[ServiceStatusResponse]] | None = None,
) -> ServiceStatusResponse:
"""Return fail2ban service health status with log configuration. """Return fail2ban service health status with log configuration.
Delegates to :func:`~app.services.health_service.probe` for the core Delegates to an injectable *probe_fn* (defaults to
health snapshot and augments it with the current log-level and log-target :func:`~app.services.health_service.probe`). This avoids direct service-to-
values from the socket. service imports inside this module.
Args: Args:
socket_path: Path to the fail2ban Unix domain socket. socket_path: Path to the fail2ban Unix domain socket.
probe_fn: Optional probe function.
Returns: Returns:
:class:`~app.models.config.ServiceStatusResponse`. :class:`~app.models.config.ServiceStatusResponse`.
""" """
from app.services.health_service import probe # lazy import avoids circular dep if probe_fn is None:
raise ValueError("probe_fn is required to avoid service-to-service coupling")
server_status = await probe(socket_path) server_status = await probe_fn(socket_path)
if server_status.online: if server_status.online:
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT) client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)

View File

@@ -25,15 +25,9 @@ from app.models.config import (
FilterUpdateRequest, FilterUpdateRequest,
AssignFilterRequest, AssignFilterRequest,
) )
from app.exceptions import JailNotFoundError from app.exceptions import FilterInvalidRegexError, JailNotFoundError
from app.services import jail_service
from app.services.config_file_service import (
_parse_jails_sync,
_get_active_jail_names,
ConfigWriteError,
JailNotFoundInConfigError,
)
from app.utils import conffile_parser from app.utils import conffile_parser
from app.utils.jail_utils import reload_jails
log: structlog.stdlib.BoundLogger = structlog.get_logger() log: structlog.stdlib.BoundLogger = structlog.get_logger()
@@ -83,21 +77,6 @@ class FilterReadonlyError(Exception):
) )
class FilterInvalidRegexError(Exception):
"""Raised when a regex pattern fails to compile."""
def __init__(self, pattern: str, error: str) -> None:
"""Initialise with the invalid pattern and the compile error.
Args:
pattern: The regex string that failed to compile.
error: The ``re.error`` message.
"""
self.pattern: str = pattern
self.error: str = error
super().__init__(f"Invalid regex {pattern!r}: {error}")
class FilterNameError(Exception): class FilterNameError(Exception):
"""Raised when a filter name contains invalid characters.""" """Raised when a filter name contains invalid characters."""
@@ -723,7 +702,7 @@ async def update_filter(
if do_reload: if do_reload:
try: try:
await jail_service.reload_all(socket_path) await reload_jails(socket_path)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
log.warning( log.warning(
"reload_after_filter_update_failed", "reload_after_filter_update_failed",
@@ -798,7 +777,7 @@ async def create_filter(
if do_reload: if do_reload:
try: try:
await jail_service.reload_all(socket_path) await reload_jails(socket_path)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
log.warning( log.warning(
"reload_after_filter_create_failed", "reload_after_filter_create_failed",
@@ -924,7 +903,7 @@ async def assign_filter_to_jail(
if do_reload: if do_reload:
try: try:
await jail_service.reload_all(socket_path) await reload_jails(socket_path)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
log.warning( log.warning(
"reload_after_assign_filter_failed", "reload_after_assign_filter_failed",

View File

@@ -20,9 +20,7 @@ Usage::
import aiohttp import aiohttp
import aiosqlite import aiosqlite
from app.services import geo_service # Use the geo_service directly in application startup
# warm the cache from the persistent store at startup
async with aiosqlite.connect("bangui.db") as db: async with aiosqlite.connect("bangui.db") as db:
await geo_service.load_cache_from_db(db) await geo_service.load_cache_from_db(db)

View File

@@ -30,7 +30,13 @@ from app.models.config import (
JailValidationResult, JailValidationResult,
RollbackResponse, RollbackResponse,
) )
from app.services import config_file_service, jail_service from app.utils.config_file_utils import (
_build_inactive_jail,
_ordered_config_files,
_parse_jails_sync,
_validate_jail_config_sync,
)
from app.utils.jail_utils import reload_jails
from app.utils.fail2ban_client import ( from app.utils.fail2ban_client import (
Fail2BanClient, Fail2BanClient,
Fail2BanConnectionError, Fail2BanConnectionError,
@@ -304,7 +310,7 @@ def _validate_regex_patterns(patterns: list[str]) -> None:
re.compile(pattern) re.compile(pattern)
except re.error as exc: except re.error as exc:
# Import here to avoid circular dependency # Import here to avoid circular dependency
from app.services.filter_config_service import FilterInvalidRegexError from app.exceptions import FilterInvalidRegexError
raise FilterInvalidRegexError(pattern, str(exc)) from exc raise FilterInvalidRegexError(pattern, str(exc)) from exc
@@ -460,12 +466,7 @@ async def start_daemon(start_cmd_parts: list[str]) -> bool:
return False return False
# Import shared functions from config_file_service # Shared functions from config_file_service are imported from app.utils.config_file_utils
_parse_jails_sync = config_file_service._parse_jails_sync
_build_inactive_jail = config_file_service._build_inactive_jail
_get_active_jail_names = config_file_service._get_active_jail_names
_validate_jail_config_sync = config_file_service._validate_jail_config_sync
_orderedconfig_files = config_file_service._ordered_config_files
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -624,7 +625,7 @@ async def activate_jail(
# Activation reload — if it fails, roll back immediately # # Activation reload — if it fails, roll back immediately #
# ---------------------------------------------------------------------- # # ---------------------------------------------------------------------- #
try: try:
await jail_service.reload_all(socket_path, include_jails=[name]) await reload_jails(socket_path, include_jails=[name])
except JailNotFoundError as exc: except JailNotFoundError as exc:
# Jail configuration is invalid (e.g. missing logpath that prevents # Jail configuration is invalid (e.g. missing logpath that prevents
# fail2ban from loading the jail). Roll back and provide a specific error. # fail2ban from loading the jail). Roll back and provide a specific error.
@@ -767,7 +768,7 @@ async def _rollback_activation_async(
# Step 2 — reload fail2ban with the restored config. # Step 2 — reload fail2ban with the restored config.
try: try:
await jail_service.reload_all(socket_path) await reload_jails(socket_path)
log.info("jail_activation_rollback_reload_ok", jail=name) log.info("jail_activation_rollback_reload_ok", jail=name)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
log.warning("jail_activation_rollback_reload_failed", jail=name, error=str(exc)) log.warning("jail_activation_rollback_reload_failed", jail=name, error=str(exc))
@@ -834,7 +835,7 @@ async def deactivate_jail(
) )
try: try:
await jail_service.reload_all(socket_path, exclude_jails=[name]) await reload_jails(socket_path, exclude_jails=[name])
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
log.warning("reload_after_deactivate_failed", jail=name, error=str(exc)) log.warning("reload_after_deactivate_failed", jail=name, error=str(exc))

View File

@@ -0,0 +1,128 @@
"""Log helper service.
Contains regex test and log preview helpers that are independent of
fail2ban socket operations.
"""
from __future__ import annotations
import asyncio
import re
from pathlib import Path
from app.models.config import (
LogPreviewLine,
LogPreviewRequest,
LogPreviewResponse,
RegexTestRequest,
RegexTestResponse,
)
def test_regex(request: RegexTestRequest) -> RegexTestResponse:
"""Test a regex pattern against a sample log line.
Args:
request: The regex test payload.
Returns:
RegexTestResponse with match result, groups and optional error.
"""
try:
compiled = re.compile(request.fail_regex)
except re.error as exc:
return RegexTestResponse(matched=False, groups=[], error=str(exc))
match = compiled.search(request.log_line)
if match is None:
return RegexTestResponse(matched=False)
groups: list[str] = list(match.groups() or [])
return RegexTestResponse(matched=True, groups=[str(g) for g in groups if g is not None])
async def preview_log(req: LogPreviewRequest) -> LogPreviewResponse:
"""Inspect the last lines of a log file and evaluate regex matches.
Args:
req: Log preview request.
Returns:
LogPreviewResponse with lines, total_lines and matched_count, or error.
"""
try:
compiled = re.compile(req.fail_regex)
except re.error as exc:
return LogPreviewResponse(
lines=[],
total_lines=0,
matched_count=0,
regex_error=str(exc),
)
path = Path(req.log_path)
if not path.is_file():
return LogPreviewResponse(
lines=[],
total_lines=0,
matched_count=0,
regex_error=f"File not found: {req.log_path!r}",
)
try:
raw_lines = await asyncio.get_event_loop().run_in_executor(
None,
_read_tail_lines,
str(path),
req.num_lines,
)
except OSError as exc:
return LogPreviewResponse(
lines=[],
total_lines=0,
matched_count=0,
regex_error=f"Cannot read file: {exc}",
)
result_lines: list[LogPreviewLine] = []
matched_count = 0
for line in raw_lines:
m = compiled.search(line)
groups = [str(g) for g in (m.groups() or []) if g is not None] if m else []
result_lines.append(
LogPreviewLine(line=line, matched=(m is not None), groups=groups),
)
if m:
matched_count += 1
return LogPreviewResponse(
lines=result_lines,
total_lines=len(result_lines),
matched_count=matched_count,
)
def _read_tail_lines(file_path: str, num_lines: int) -> list[str]:
"""Read the last *num_lines* from *file_path* in a memory-efficient way."""
chunk_size = 8192
raw_lines: list[bytes] = []
with open(file_path, "rb") as fh:
fh.seek(0, 2)
end_pos = fh.tell()
if end_pos == 0:
return []
buf = b""
pos = end_pos
while len(raw_lines) <= num_lines and pos > 0:
read_size = min(chunk_size, pos)
pos -= read_size
fh.seek(pos)
chunk = fh.read(read_size)
buf = chunk + buf
raw_lines = buf.split(b"\n")
if pos > 0 and len(raw_lines) > 1:
raw_lines = raw_lines[1:]
return [ln.decode("utf-8", errors="replace").rstrip() for ln in raw_lines[-num_lines:] if ln.strip()]

View File

@@ -102,30 +102,20 @@ async def run_setup(
log.info("bangui_setup_completed") log.info("bangui_setup_completed")
from app.utils.setup_utils import (
get_map_color_thresholds as util_get_map_color_thresholds,
get_password_hash as util_get_password_hash,
set_map_color_thresholds as util_set_map_color_thresholds,
)
async def get_password_hash(db: aiosqlite.Connection) -> str | None: async def get_password_hash(db: aiosqlite.Connection) -> str | None:
"""Return the stored bcrypt password hash, or ``None`` if not set. """Return the stored bcrypt password hash, or ``None`` if not set."""
return await util_get_password_hash(db)
Args:
db: Active aiosqlite connection.
Returns:
The bcrypt hash string, or ``None``.
"""
return await settings_repo.get_setting(db, _KEY_PASSWORD_HASH)
async def get_timezone(db: aiosqlite.Connection) -> str: async def get_timezone(db: aiosqlite.Connection) -> str:
"""Return the configured IANA timezone string. """Return the configured IANA timezone string."""
Falls back to ``"UTC"`` when no timezone has been stored (e.g. before
setup completes or for legacy databases).
Args:
db: Active aiosqlite connection.
Returns:
An IANA timezone identifier such as ``"Europe/Berlin"`` or ``"UTC"``.
"""
tz = await settings_repo.get_setting(db, _KEY_TIMEZONE) tz = await settings_repo.get_setting(db, _KEY_TIMEZONE)
return tz if tz else "UTC" return tz if tz else "UTC"
@@ -133,31 +123,8 @@ async def get_timezone(db: aiosqlite.Connection) -> str:
async def get_map_color_thresholds( async def get_map_color_thresholds(
db: aiosqlite.Connection, db: aiosqlite.Connection,
) -> tuple[int, int, int]: ) -> tuple[int, int, int]:
"""Return the configured map color thresholds (high, medium, low). """Return the configured map color thresholds (high, medium, low)."""
return await util_get_map_color_thresholds(db)
Falls back to default values (100, 50, 20) if not set.
Args:
db: Active aiosqlite connection.
Returns:
A tuple of (threshold_high, threshold_medium, threshold_low).
"""
high = await settings_repo.get_setting(
db, _KEY_MAP_COLOR_THRESHOLD_HIGH
)
medium = await settings_repo.get_setting(
db, _KEY_MAP_COLOR_THRESHOLD_MEDIUM
)
low = await settings_repo.get_setting(
db, _KEY_MAP_COLOR_THRESHOLD_LOW
)
return (
int(high) if high else 100,
int(medium) if medium else 50,
int(low) if low else 20,
)
async def set_map_color_thresholds( async def set_map_color_thresholds(
@@ -167,31 +134,12 @@ async def set_map_color_thresholds(
threshold_medium: int, threshold_medium: int,
threshold_low: int, threshold_low: int,
) -> None: ) -> None:
"""Update the map color threshold configuration. """Update the map color threshold configuration."""
await util_set_map_color_thresholds(
Args: db,
db: Active aiosqlite connection. threshold_high=threshold_high,
threshold_high: Ban count for red coloring. threshold_medium=threshold_medium,
threshold_medium: Ban count for yellow coloring. threshold_low=threshold_low,
threshold_low: Ban count for green coloring.
Raises:
ValueError: If thresholds are not positive integers or if
high <= medium <= low.
"""
if threshold_high <= 0 or threshold_medium <= 0 or threshold_low <= 0:
raise ValueError("All thresholds must be positive integers.")
if not (threshold_high > threshold_medium > threshold_low):
raise ValueError("Thresholds must satisfy: high > medium > low.")
await settings_repo.set_setting(
db, _KEY_MAP_COLOR_THRESHOLD_HIGH, str(threshold_high)
)
await settings_repo.set_setting(
db, _KEY_MAP_COLOR_THRESHOLD_MEDIUM, str(threshold_medium)
)
await settings_repo.set_setting(
db, _KEY_MAP_COLOR_THRESHOLD_LOW, str(threshold_low)
) )
log.info( log.info(
"map_color_thresholds_updated", "map_color_thresholds_updated",

View File

@@ -43,9 +43,15 @@ async def _run_import(app: Any) -> None:
http_session = app.state.http_session http_session = app.state.http_session
socket_path: str = app.state.settings.fail2ban_socket socket_path: str = app.state.settings.fail2ban_socket
from app.services import jail_service
log.info("blocklist_import_starting") log.info("blocklist_import_starting")
try: try:
result = await blocklist_service.import_all(db, http_session, socket_path) result = await blocklist_service.import_all(
db,
http_session,
socket_path,
)
log.info( log.info(
"blocklist_import_finished", "blocklist_import_finished",
total_imported=result.total_imported, total_imported=result.total_imported,

View File

@@ -0,0 +1,21 @@
"""Utilities re-exported from config_file_service for cross-module usage."""
from __future__ import annotations
from pathlib import Path
from app.services.config_file_service import (
_build_inactive_jail,
_get_active_jail_names,
_ordered_config_files,
_parse_jails_sync,
_validate_jail_config_sync,
)
__all__ = [
"_ordered_config_files",
"_parse_jails_sync",
"_build_inactive_jail",
"_get_active_jail_names",
"_validate_jail_config_sync",
]

View File

@@ -0,0 +1,20 @@
"""Jail helpers to decouple service layer dependencies."""
from __future__ import annotations
from collections.abc import Sequence
from app.services.jail_service import reload_all
async def reload_jails(
socket_path: str,
include_jails: Sequence[str] | None = None,
exclude_jails: Sequence[str] | None = None,
) -> None:
"""Reload fail2ban jails using shared jail service helper."""
await reload_all(
socket_path,
include_jails=list(include_jails) if include_jails is not None else None,
exclude_jails=list(exclude_jails) if exclude_jails is not None else None,
)

View File

@@ -0,0 +1,14 @@
"""Log-related helpers to avoid direct service-to-service imports."""
from __future__ import annotations
from app.models.config import LogPreviewRequest, LogPreviewResponse, RegexTestRequest, RegexTestResponse
from app.services.log_service import preview_log as _preview_log, test_regex as _test_regex
async def preview_log(req: LogPreviewRequest) -> LogPreviewResponse:
return await _preview_log(req)
def test_regex(req: RegexTestRequest) -> RegexTestResponse:
return _test_regex(req)

View File

@@ -0,0 +1,47 @@
"""Setup-related utilities shared by multiple services."""
from __future__ import annotations
from app.repositories import settings_repo
_KEY_PASSWORD_HASH = "master_password_hash"
_KEY_SETUP_DONE = "setup_completed"
_KEY_MAP_COLOR_THRESHOLD_HIGH = "map_color_threshold_high"
_KEY_MAP_COLOR_THRESHOLD_MEDIUM = "map_color_threshold_medium"
_KEY_MAP_COLOR_THRESHOLD_LOW = "map_color_threshold_low"
async def get_password_hash(db):
"""Return the stored master password hash or None."""
return await settings_repo.get_setting(db, _KEY_PASSWORD_HASH)
async def get_map_color_thresholds(db):
"""Return map color thresholds as tuple (high, medium, low)."""
high = await settings_repo.get_setting(db, _KEY_MAP_COLOR_THRESHOLD_HIGH)
medium = await settings_repo.get_setting(db, _KEY_MAP_COLOR_THRESHOLD_MEDIUM)
low = await settings_repo.get_setting(db, _KEY_MAP_COLOR_THRESHOLD_LOW)
return (
int(high) if high else 100,
int(medium) if medium else 50,
int(low) if low else 20,
)
async def set_map_color_thresholds(
db,
*,
threshold_high: int,
threshold_medium: int,
threshold_low: int,
) -> None:
"""Persist map color thresholds after validating values."""
if threshold_high <= 0 or threshold_medium <= 0 or threshold_low <= 0:
raise ValueError("All thresholds must be positive integers.")
if not (threshold_high > threshold_medium > threshold_low):
raise ValueError("Thresholds must satisfy: high > medium > low.")
await settings_repo.set_setting(db, _KEY_MAP_COLOR_THRESHOLD_HIGH, str(threshold_high))
await settings_repo.set_setting(db, _KEY_MAP_COLOR_THRESHOLD_MEDIUM, str(threshold_medium))
await settings_repo.set_setting(db, _KEY_MAP_COLOR_THRESHOLD_LOW, str(threshold_low))

View File

@@ -37,9 +37,15 @@ def test_settings(tmp_path: Path) -> Settings:
Returns: Returns:
A :class:`~app.config.Settings` instance with overridden paths. A :class:`~app.config.Settings` instance with overridden paths.
""" """
config_dir = tmp_path / "fail2ban"
(config_dir / "jail.d").mkdir(parents=True)
(config_dir / "filter.d").mkdir(parents=True)
(config_dir / "action.d").mkdir(parents=True)
return Settings( return Settings(
database_path=str(tmp_path / "test_bangui.db"), database_path=str(tmp_path / "test_bangui.db"),
fail2ban_socket="/tmp/fake_fail2ban.sock", fail2ban_socket="/tmp/fake_fail2ban.sock",
fail2ban_config_dir=str(config_dir),
session_secret="test-secret-key-do-not-use-in-production", session_secret="test-secret-key-do-not-use-in-production",
session_duration_minutes=60, session_duration_minutes=60,
timezone="UTC", timezone="UTC",

View File

@@ -501,7 +501,7 @@ class TestRegexTest:
"""POST /api/config/regex-test returns matched=true for a valid match.""" """POST /api/config/regex-test returns matched=true for a valid match."""
mock_response = RegexTestResponse(matched=True, groups=["1.2.3.4"], error=None) mock_response = RegexTestResponse(matched=True, groups=["1.2.3.4"], error=None)
with patch( with patch(
"app.routers.config.config_service.test_regex", "app.routers.config.log_service.test_regex",
return_value=mock_response, return_value=mock_response,
): ):
resp = await config_client.post( resp = await config_client.post(
@@ -519,7 +519,7 @@ class TestRegexTest:
"""POST /api/config/regex-test returns matched=false for no match.""" """POST /api/config/regex-test returns matched=false for no match."""
mock_response = RegexTestResponse(matched=False, groups=[], error=None) mock_response = RegexTestResponse(matched=False, groups=[], error=None)
with patch( with patch(
"app.routers.config.config_service.test_regex", "app.routers.config.log_service.test_regex",
return_value=mock_response, return_value=mock_response,
): ):
resp = await config_client.post( resp = await config_client.post(
@@ -597,7 +597,7 @@ class TestPreviewLog:
matched_count=1, matched_count=1,
) )
with patch( with patch(
"app.routers.config.config_service.preview_log", "app.routers.config.log_service.preview_log",
AsyncMock(return_value=mock_response), AsyncMock(return_value=mock_response),
): ):
resp = await config_client.post( resp = await config_client.post(
@@ -725,7 +725,7 @@ class TestGetInactiveJails:
mock_response = InactiveJailListResponse(jails=[mock_jail], total=1) mock_response = InactiveJailListResponse(jails=[mock_jail], total=1)
with patch( with patch(
"app.routers.config.config_file_service.list_inactive_jails", "app.routers.config.jail_config_service.list_inactive_jails",
AsyncMock(return_value=mock_response), AsyncMock(return_value=mock_response),
): ):
resp = await config_client.get("/api/config/jails/inactive") resp = await config_client.get("/api/config/jails/inactive")
@@ -740,7 +740,7 @@ class TestGetInactiveJails:
from app.models.config import InactiveJailListResponse from app.models.config import InactiveJailListResponse
with patch( with patch(
"app.routers.config.config_file_service.list_inactive_jails", "app.routers.config.jail_config_service.list_inactive_jails",
AsyncMock(return_value=InactiveJailListResponse(jails=[], total=0)), AsyncMock(return_value=InactiveJailListResponse(jails=[], total=0)),
): ):
resp = await config_client.get("/api/config/jails/inactive") resp = await config_client.get("/api/config/jails/inactive")
@@ -776,7 +776,7 @@ class TestActivateJail:
message="Jail 'apache-auth' activated successfully.", message="Jail 'apache-auth' activated successfully.",
) )
with patch( with patch(
"app.routers.config.config_file_service.activate_jail", "app.routers.config.jail_config_service.activate_jail",
AsyncMock(return_value=mock_response), AsyncMock(return_value=mock_response),
): ):
resp = await config_client.post( resp = await config_client.post(
@@ -796,7 +796,7 @@ class TestActivateJail:
name="apache-auth", active=True, message="Activated." name="apache-auth", active=True, message="Activated."
) )
with patch( with patch(
"app.routers.config.config_file_service.activate_jail", "app.routers.config.jail_config_service.activate_jail",
AsyncMock(return_value=mock_response), AsyncMock(return_value=mock_response),
) as mock_activate: ) as mock_activate:
resp = await config_client.post( resp = await config_client.post(
@@ -812,10 +812,10 @@ class TestActivateJail:
async def test_404_for_unknown_jail(self, config_client: AsyncClient) -> None: async def test_404_for_unknown_jail(self, config_client: AsyncClient) -> None:
"""POST /api/config/jails/missing/activate returns 404.""" """POST /api/config/jails/missing/activate returns 404."""
from app.services.config_file_service import JailNotFoundInConfigError from app.services.jail_config_service import JailNotFoundInConfigError
with patch( with patch(
"app.routers.config.config_file_service.activate_jail", "app.routers.config.jail_config_service.activate_jail",
AsyncMock(side_effect=JailNotFoundInConfigError("missing")), AsyncMock(side_effect=JailNotFoundInConfigError("missing")),
): ):
resp = await config_client.post( resp = await config_client.post(
@@ -826,10 +826,10 @@ class TestActivateJail:
async def test_409_when_already_active(self, config_client: AsyncClient) -> None: async def test_409_when_already_active(self, config_client: AsyncClient) -> None:
"""POST /api/config/jails/sshd/activate returns 409 if already active.""" """POST /api/config/jails/sshd/activate returns 409 if already active."""
from app.services.config_file_service import JailAlreadyActiveError from app.services.jail_config_service import JailAlreadyActiveError
with patch( with patch(
"app.routers.config.config_file_service.activate_jail", "app.routers.config.jail_config_service.activate_jail",
AsyncMock(side_effect=JailAlreadyActiveError("sshd")), AsyncMock(side_effect=JailAlreadyActiveError("sshd")),
): ):
resp = await config_client.post( resp = await config_client.post(
@@ -840,10 +840,10 @@ class TestActivateJail:
async def test_400_for_invalid_jail_name(self, config_client: AsyncClient) -> None: async def test_400_for_invalid_jail_name(self, config_client: AsyncClient) -> None:
"""POST /api/config/jails/ with bad name returns 400.""" """POST /api/config/jails/ with bad name returns 400."""
from app.services.config_file_service import JailNameError from app.services.jail_config_service import JailNameError
with patch( with patch(
"app.routers.config.config_file_service.activate_jail", "app.routers.config.jail_config_service.activate_jail",
AsyncMock(side_effect=JailNameError("bad name")), AsyncMock(side_effect=JailNameError("bad name")),
): ):
resp = await config_client.post( resp = await config_client.post(
@@ -872,7 +872,7 @@ class TestActivateJail:
message="Jail 'airsonic-auth' cannot be activated: log file '/var/log/airsonic/airsonic.log' not found", message="Jail 'airsonic-auth' cannot be activated: log file '/var/log/airsonic/airsonic.log' not found",
) )
with patch( with patch(
"app.routers.config.config_file_service.activate_jail", "app.routers.config.jail_config_service.activate_jail",
AsyncMock(return_value=blocked_response), AsyncMock(return_value=blocked_response),
): ):
resp = await config_client.post( resp = await config_client.post(
@@ -905,7 +905,7 @@ class TestDeactivateJail:
message="Jail 'sshd' deactivated successfully.", message="Jail 'sshd' deactivated successfully.",
) )
with patch( with patch(
"app.routers.config.config_file_service.deactivate_jail", "app.routers.config.jail_config_service.deactivate_jail",
AsyncMock(return_value=mock_response), AsyncMock(return_value=mock_response),
): ):
resp = await config_client.post("/api/config/jails/sshd/deactivate") resp = await config_client.post("/api/config/jails/sshd/deactivate")
@@ -917,10 +917,10 @@ class TestDeactivateJail:
async def test_404_for_unknown_jail(self, config_client: AsyncClient) -> None: async def test_404_for_unknown_jail(self, config_client: AsyncClient) -> None:
"""POST /api/config/jails/missing/deactivate returns 404.""" """POST /api/config/jails/missing/deactivate returns 404."""
from app.services.config_file_service import JailNotFoundInConfigError from app.services.jail_config_service import JailNotFoundInConfigError
with patch( with patch(
"app.routers.config.config_file_service.deactivate_jail", "app.routers.config.jail_config_service.deactivate_jail",
AsyncMock(side_effect=JailNotFoundInConfigError("missing")), AsyncMock(side_effect=JailNotFoundInConfigError("missing")),
): ):
resp = await config_client.post( resp = await config_client.post(
@@ -931,10 +931,10 @@ class TestDeactivateJail:
async def test_409_when_already_inactive(self, config_client: AsyncClient) -> None: async def test_409_when_already_inactive(self, config_client: AsyncClient) -> None:
"""POST /api/config/jails/apache-auth/deactivate returns 409 if already inactive.""" """POST /api/config/jails/apache-auth/deactivate returns 409 if already inactive."""
from app.services.config_file_service import JailAlreadyInactiveError from app.services.jail_config_service import JailAlreadyInactiveError
with patch( with patch(
"app.routers.config.config_file_service.deactivate_jail", "app.routers.config.jail_config_service.deactivate_jail",
AsyncMock(side_effect=JailAlreadyInactiveError("apache-auth")), AsyncMock(side_effect=JailAlreadyInactiveError("apache-auth")),
): ):
resp = await config_client.post( resp = await config_client.post(
@@ -945,10 +945,10 @@ class TestDeactivateJail:
async def test_400_for_invalid_jail_name(self, config_client: AsyncClient) -> None: async def test_400_for_invalid_jail_name(self, config_client: AsyncClient) -> None:
"""POST /api/config/jails/.../deactivate with bad name returns 400.""" """POST /api/config/jails/.../deactivate with bad name returns 400."""
from app.services.config_file_service import JailNameError from app.services.jail_config_service import JailNameError
with patch( with patch(
"app.routers.config.config_file_service.deactivate_jail", "app.routers.config.jail_config_service.deactivate_jail",
AsyncMock(side_effect=JailNameError("bad")), AsyncMock(side_effect=JailNameError("bad")),
): ):
resp = await config_client.post( resp = await config_client.post(
@@ -976,7 +976,7 @@ class TestDeactivateJail:
) )
with ( with (
patch( patch(
"app.routers.config.config_file_service.deactivate_jail", "app.routers.config.jail_config_service.deactivate_jail",
AsyncMock(return_value=mock_response), AsyncMock(return_value=mock_response),
), ),
patch( patch(
@@ -1027,7 +1027,7 @@ class TestListFilters:
total=1, total=1,
) )
with patch( with patch(
"app.routers.config.config_file_service.list_filters", "app.routers.config.filter_config_service.list_filters",
AsyncMock(return_value=mock_response), AsyncMock(return_value=mock_response),
): ):
resp = await config_client.get("/api/config/filters") resp = await config_client.get("/api/config/filters")
@@ -1043,7 +1043,7 @@ class TestListFilters:
from app.models.config import FilterListResponse from app.models.config import FilterListResponse
with patch( with patch(
"app.routers.config.config_file_service.list_filters", "app.routers.config.filter_config_service.list_filters",
AsyncMock(return_value=FilterListResponse(filters=[], total=0)), AsyncMock(return_value=FilterListResponse(filters=[], total=0)),
): ):
resp = await config_client.get("/api/config/filters") resp = await config_client.get("/api/config/filters")
@@ -1066,7 +1066,7 @@ class TestListFilters:
total=2, total=2,
) )
with patch( with patch(
"app.routers.config.config_file_service.list_filters", "app.routers.config.filter_config_service.list_filters",
AsyncMock(return_value=mock_response), AsyncMock(return_value=mock_response),
): ):
resp = await config_client.get("/api/config/filters") resp = await config_client.get("/api/config/filters")
@@ -1095,7 +1095,7 @@ class TestGetFilter:
async def test_200_returns_filter(self, config_client: AsyncClient) -> None: async def test_200_returns_filter(self, config_client: AsyncClient) -> None:
"""GET /api/config/filters/sshd returns 200 with FilterConfig.""" """GET /api/config/filters/sshd returns 200 with FilterConfig."""
with patch( with patch(
"app.routers.config.config_file_service.get_filter", "app.routers.config.filter_config_service.get_filter",
AsyncMock(return_value=_make_filter_config("sshd")), AsyncMock(return_value=_make_filter_config("sshd")),
): ):
resp = await config_client.get("/api/config/filters/sshd") resp = await config_client.get("/api/config/filters/sshd")
@@ -1108,10 +1108,10 @@ class TestGetFilter:
async def test_404_for_unknown_filter(self, config_client: AsyncClient) -> None: async def test_404_for_unknown_filter(self, config_client: AsyncClient) -> None:
"""GET /api/config/filters/missing returns 404.""" """GET /api/config/filters/missing returns 404."""
from app.services.config_file_service import FilterNotFoundError from app.services.filter_config_service import FilterNotFoundError
with patch( with patch(
"app.routers.config.config_file_service.get_filter", "app.routers.config.filter_config_service.get_filter",
AsyncMock(side_effect=FilterNotFoundError("missing")), AsyncMock(side_effect=FilterNotFoundError("missing")),
): ):
resp = await config_client.get("/api/config/filters/missing") resp = await config_client.get("/api/config/filters/missing")
@@ -1138,7 +1138,7 @@ class TestUpdateFilter:
async def test_200_returns_updated_filter(self, config_client: AsyncClient) -> None: async def test_200_returns_updated_filter(self, config_client: AsyncClient) -> None:
"""PUT /api/config/filters/sshd returns 200 with updated FilterConfig.""" """PUT /api/config/filters/sshd returns 200 with updated FilterConfig."""
with patch( with patch(
"app.routers.config.config_file_service.update_filter", "app.routers.config.filter_config_service.update_filter",
AsyncMock(return_value=_make_filter_config("sshd")), AsyncMock(return_value=_make_filter_config("sshd")),
): ):
resp = await config_client.put( resp = await config_client.put(
@@ -1151,10 +1151,10 @@ class TestUpdateFilter:
async def test_404_for_unknown_filter(self, config_client: AsyncClient) -> None: async def test_404_for_unknown_filter(self, config_client: AsyncClient) -> None:
"""PUT /api/config/filters/missing returns 404.""" """PUT /api/config/filters/missing returns 404."""
from app.services.config_file_service import FilterNotFoundError from app.services.filter_config_service import FilterNotFoundError
with patch( with patch(
"app.routers.config.config_file_service.update_filter", "app.routers.config.filter_config_service.update_filter",
AsyncMock(side_effect=FilterNotFoundError("missing")), AsyncMock(side_effect=FilterNotFoundError("missing")),
): ):
resp = await config_client.put( resp = await config_client.put(
@@ -1166,10 +1166,10 @@ class TestUpdateFilter:
async def test_422_for_invalid_regex(self, config_client: AsyncClient) -> None: async def test_422_for_invalid_regex(self, config_client: AsyncClient) -> None:
"""PUT /api/config/filters/sshd returns 422 for bad regex.""" """PUT /api/config/filters/sshd returns 422 for bad regex."""
from app.services.config_file_service import FilterInvalidRegexError from app.services.filter_config_service import FilterInvalidRegexError
with patch( with patch(
"app.routers.config.config_file_service.update_filter", "app.routers.config.filter_config_service.update_filter",
AsyncMock(side_effect=FilterInvalidRegexError("[bad", "unterminated")), AsyncMock(side_effect=FilterInvalidRegexError("[bad", "unterminated")),
): ):
resp = await config_client.put( resp = await config_client.put(
@@ -1181,10 +1181,10 @@ class TestUpdateFilter:
async def test_400_for_invalid_name(self, config_client: AsyncClient) -> None: async def test_400_for_invalid_name(self, config_client: AsyncClient) -> None:
"""PUT /api/config/filters/... with bad name returns 400.""" """PUT /api/config/filters/... with bad name returns 400."""
from app.services.config_file_service import FilterNameError from app.services.filter_config_service import FilterNameError
with patch( with patch(
"app.routers.config.config_file_service.update_filter", "app.routers.config.filter_config_service.update_filter",
AsyncMock(side_effect=FilterNameError("bad")), AsyncMock(side_effect=FilterNameError("bad")),
): ):
resp = await config_client.put( resp = await config_client.put(
@@ -1197,7 +1197,7 @@ class TestUpdateFilter:
async def test_reload_query_param_passed(self, config_client: AsyncClient) -> None: async def test_reload_query_param_passed(self, config_client: AsyncClient) -> None:
"""PUT /api/config/filters/sshd?reload=true passes do_reload=True.""" """PUT /api/config/filters/sshd?reload=true passes do_reload=True."""
with patch( with patch(
"app.routers.config.config_file_service.update_filter", "app.routers.config.filter_config_service.update_filter",
AsyncMock(return_value=_make_filter_config("sshd")), AsyncMock(return_value=_make_filter_config("sshd")),
) as mock_update: ) as mock_update:
resp = await config_client.put( resp = await config_client.put(
@@ -1228,7 +1228,7 @@ class TestCreateFilter:
async def test_201_creates_filter(self, config_client: AsyncClient) -> None: async def test_201_creates_filter(self, config_client: AsyncClient) -> None:
"""POST /api/config/filters returns 201 with FilterConfig.""" """POST /api/config/filters returns 201 with FilterConfig."""
with patch( with patch(
"app.routers.config.config_file_service.create_filter", "app.routers.config.filter_config_service.create_filter",
AsyncMock(return_value=_make_filter_config("my-custom")), AsyncMock(return_value=_make_filter_config("my-custom")),
): ):
resp = await config_client.post( resp = await config_client.post(
@@ -1241,10 +1241,10 @@ class TestCreateFilter:
async def test_409_when_already_exists(self, config_client: AsyncClient) -> None: async def test_409_when_already_exists(self, config_client: AsyncClient) -> None:
"""POST /api/config/filters returns 409 if filter exists.""" """POST /api/config/filters returns 409 if filter exists."""
from app.services.config_file_service import FilterAlreadyExistsError from app.services.filter_config_service import FilterAlreadyExistsError
with patch( with patch(
"app.routers.config.config_file_service.create_filter", "app.routers.config.filter_config_service.create_filter",
AsyncMock(side_effect=FilterAlreadyExistsError("sshd")), AsyncMock(side_effect=FilterAlreadyExistsError("sshd")),
): ):
resp = await config_client.post( resp = await config_client.post(
@@ -1256,10 +1256,10 @@ class TestCreateFilter:
async def test_422_for_invalid_regex(self, config_client: AsyncClient) -> None: async def test_422_for_invalid_regex(self, config_client: AsyncClient) -> None:
"""POST /api/config/filters returns 422 for bad regex.""" """POST /api/config/filters returns 422 for bad regex."""
from app.services.config_file_service import FilterInvalidRegexError from app.services.filter_config_service import FilterInvalidRegexError
with patch( with patch(
"app.routers.config.config_file_service.create_filter", "app.routers.config.filter_config_service.create_filter",
AsyncMock(side_effect=FilterInvalidRegexError("[bad", "unterminated")), AsyncMock(side_effect=FilterInvalidRegexError("[bad", "unterminated")),
): ):
resp = await config_client.post( resp = await config_client.post(
@@ -1271,10 +1271,10 @@ class TestCreateFilter:
async def test_400_for_invalid_name(self, config_client: AsyncClient) -> None: async def test_400_for_invalid_name(self, config_client: AsyncClient) -> None:
"""POST /api/config/filters returns 400 for invalid filter name.""" """POST /api/config/filters returns 400 for invalid filter name."""
from app.services.config_file_service import FilterNameError from app.services.filter_config_service import FilterNameError
with patch( with patch(
"app.routers.config.config_file_service.create_filter", "app.routers.config.filter_config_service.create_filter",
AsyncMock(side_effect=FilterNameError("bad")), AsyncMock(side_effect=FilterNameError("bad")),
): ):
resp = await config_client.post( resp = await config_client.post(
@@ -1304,7 +1304,7 @@ class TestDeleteFilter:
async def test_204_deletes_filter(self, config_client: AsyncClient) -> None: async def test_204_deletes_filter(self, config_client: AsyncClient) -> None:
"""DELETE /api/config/filters/my-custom returns 204.""" """DELETE /api/config/filters/my-custom returns 204."""
with patch( with patch(
"app.routers.config.config_file_service.delete_filter", "app.routers.config.filter_config_service.delete_filter",
AsyncMock(return_value=None), AsyncMock(return_value=None),
): ):
resp = await config_client.delete("/api/config/filters/my-custom") resp = await config_client.delete("/api/config/filters/my-custom")
@@ -1313,10 +1313,10 @@ class TestDeleteFilter:
async def test_404_for_unknown_filter(self, config_client: AsyncClient) -> None: async def test_404_for_unknown_filter(self, config_client: AsyncClient) -> None:
"""DELETE /api/config/filters/missing returns 404.""" """DELETE /api/config/filters/missing returns 404."""
from app.services.config_file_service import FilterNotFoundError from app.services.filter_config_service import FilterNotFoundError
with patch( with patch(
"app.routers.config.config_file_service.delete_filter", "app.routers.config.filter_config_service.delete_filter",
AsyncMock(side_effect=FilterNotFoundError("missing")), AsyncMock(side_effect=FilterNotFoundError("missing")),
): ):
resp = await config_client.delete("/api/config/filters/missing") resp = await config_client.delete("/api/config/filters/missing")
@@ -1325,10 +1325,10 @@ class TestDeleteFilter:
async def test_409_for_readonly_filter(self, config_client: AsyncClient) -> None: async def test_409_for_readonly_filter(self, config_client: AsyncClient) -> None:
"""DELETE /api/config/filters/sshd returns 409 for shipped conf-only filter.""" """DELETE /api/config/filters/sshd returns 409 for shipped conf-only filter."""
from app.services.config_file_service import FilterReadonlyError from app.services.filter_config_service import FilterReadonlyError
with patch( with patch(
"app.routers.config.config_file_service.delete_filter", "app.routers.config.filter_config_service.delete_filter",
AsyncMock(side_effect=FilterReadonlyError("sshd")), AsyncMock(side_effect=FilterReadonlyError("sshd")),
): ):
resp = await config_client.delete("/api/config/filters/sshd") resp = await config_client.delete("/api/config/filters/sshd")
@@ -1337,10 +1337,10 @@ class TestDeleteFilter:
async def test_400_for_invalid_name(self, config_client: AsyncClient) -> None: async def test_400_for_invalid_name(self, config_client: AsyncClient) -> None:
"""DELETE /api/config/filters/... with bad name returns 400.""" """DELETE /api/config/filters/... with bad name returns 400."""
from app.services.config_file_service import FilterNameError from app.services.filter_config_service import FilterNameError
with patch( with patch(
"app.routers.config.config_file_service.delete_filter", "app.routers.config.filter_config_service.delete_filter",
AsyncMock(side_effect=FilterNameError("bad")), AsyncMock(side_effect=FilterNameError("bad")),
): ):
resp = await config_client.delete("/api/config/filters/bad") resp = await config_client.delete("/api/config/filters/bad")
@@ -1367,7 +1367,7 @@ class TestAssignFilterToJail:
async def test_204_assigns_filter(self, config_client: AsyncClient) -> None: async def test_204_assigns_filter(self, config_client: AsyncClient) -> None:
"""POST /api/config/jails/sshd/filter returns 204 on success.""" """POST /api/config/jails/sshd/filter returns 204 on success."""
with patch( with patch(
"app.routers.config.config_file_service.assign_filter_to_jail", "app.routers.config.filter_config_service.assign_filter_to_jail",
AsyncMock(return_value=None), AsyncMock(return_value=None),
): ):
resp = await config_client.post( resp = await config_client.post(
@@ -1379,10 +1379,10 @@ class TestAssignFilterToJail:
async def test_404_for_unknown_jail(self, config_client: AsyncClient) -> None: async def test_404_for_unknown_jail(self, config_client: AsyncClient) -> None:
"""POST /api/config/jails/missing/filter returns 404.""" """POST /api/config/jails/missing/filter returns 404."""
from app.services.config_file_service import JailNotFoundInConfigError from app.services.jail_config_service import JailNotFoundInConfigError
with patch( with patch(
"app.routers.config.config_file_service.assign_filter_to_jail", "app.routers.config.filter_config_service.assign_filter_to_jail",
AsyncMock(side_effect=JailNotFoundInConfigError("missing")), AsyncMock(side_effect=JailNotFoundInConfigError("missing")),
): ):
resp = await config_client.post( resp = await config_client.post(
@@ -1394,10 +1394,10 @@ class TestAssignFilterToJail:
async def test_404_for_unknown_filter(self, config_client: AsyncClient) -> None: async def test_404_for_unknown_filter(self, config_client: AsyncClient) -> None:
"""POST /api/config/jails/sshd/filter returns 404 when filter not found.""" """POST /api/config/jails/sshd/filter returns 404 when filter not found."""
from app.services.config_file_service import FilterNotFoundError from app.services.filter_config_service import FilterNotFoundError
with patch( with patch(
"app.routers.config.config_file_service.assign_filter_to_jail", "app.routers.config.filter_config_service.assign_filter_to_jail",
AsyncMock(side_effect=FilterNotFoundError("missing-filter")), AsyncMock(side_effect=FilterNotFoundError("missing-filter")),
): ):
resp = await config_client.post( resp = await config_client.post(
@@ -1409,10 +1409,10 @@ class TestAssignFilterToJail:
async def test_400_for_invalid_jail_name(self, config_client: AsyncClient) -> None: async def test_400_for_invalid_jail_name(self, config_client: AsyncClient) -> None:
"""POST /api/config/jails/.../filter with bad jail name returns 400.""" """POST /api/config/jails/.../filter with bad jail name returns 400."""
from app.services.config_file_service import JailNameError from app.services.jail_config_service import JailNameError
with patch( with patch(
"app.routers.config.config_file_service.assign_filter_to_jail", "app.routers.config.filter_config_service.assign_filter_to_jail",
AsyncMock(side_effect=JailNameError("bad")), AsyncMock(side_effect=JailNameError("bad")),
): ):
resp = await config_client.post( resp = await config_client.post(
@@ -1424,10 +1424,10 @@ class TestAssignFilterToJail:
async def test_400_for_invalid_filter_name(self, config_client: AsyncClient) -> None: async def test_400_for_invalid_filter_name(self, config_client: AsyncClient) -> None:
"""POST /api/config/jails/sshd/filter with bad filter name returns 400.""" """POST /api/config/jails/sshd/filter with bad filter name returns 400."""
from app.services.config_file_service import FilterNameError from app.services.filter_config_service import FilterNameError
with patch( with patch(
"app.routers.config.config_file_service.assign_filter_to_jail", "app.routers.config.filter_config_service.assign_filter_to_jail",
AsyncMock(side_effect=FilterNameError("bad")), AsyncMock(side_effect=FilterNameError("bad")),
): ):
resp = await config_client.post( resp = await config_client.post(
@@ -1440,7 +1440,7 @@ class TestAssignFilterToJail:
async def test_reload_query_param_passed(self, config_client: AsyncClient) -> None: async def test_reload_query_param_passed(self, config_client: AsyncClient) -> None:
"""POST /api/config/jails/sshd/filter?reload=true passes do_reload=True.""" """POST /api/config/jails/sshd/filter?reload=true passes do_reload=True."""
with patch( with patch(
"app.routers.config.config_file_service.assign_filter_to_jail", "app.routers.config.filter_config_service.assign_filter_to_jail",
AsyncMock(return_value=None), AsyncMock(return_value=None),
) as mock_assign: ) as mock_assign:
resp = await config_client.post( resp = await config_client.post(
@@ -1478,7 +1478,7 @@ class TestListActionsRouter:
mock_response = ActionListResponse(actions=[mock_action], total=1) mock_response = ActionListResponse(actions=[mock_action], total=1)
with patch( with patch(
"app.routers.config.config_file_service.list_actions", "app.routers.config.action_config_service.list_actions",
AsyncMock(return_value=mock_response), AsyncMock(return_value=mock_response),
): ):
resp = await config_client.get("/api/config/actions") resp = await config_client.get("/api/config/actions")
@@ -1496,7 +1496,7 @@ class TestListActionsRouter:
mock_response = ActionListResponse(actions=[inactive, active], total=2) mock_response = ActionListResponse(actions=[inactive, active], total=2)
with patch( with patch(
"app.routers.config.config_file_service.list_actions", "app.routers.config.action_config_service.list_actions",
AsyncMock(return_value=mock_response), AsyncMock(return_value=mock_response),
): ):
resp = await config_client.get("/api/config/actions") resp = await config_client.get("/api/config/actions")
@@ -1524,7 +1524,7 @@ class TestGetActionRouter:
) )
with patch( with patch(
"app.routers.config.config_file_service.get_action", "app.routers.config.action_config_service.get_action",
AsyncMock(return_value=mock_action), AsyncMock(return_value=mock_action),
): ):
resp = await config_client.get("/api/config/actions/iptables") resp = await config_client.get("/api/config/actions/iptables")
@@ -1533,10 +1533,10 @@ class TestGetActionRouter:
assert resp.json()["name"] == "iptables" assert resp.json()["name"] == "iptables"
async def test_404_when_not_found(self, config_client: AsyncClient) -> None: async def test_404_when_not_found(self, config_client: AsyncClient) -> None:
from app.services.config_file_service import ActionNotFoundError from app.services.action_config_service import ActionNotFoundError
with patch( with patch(
"app.routers.config.config_file_service.get_action", "app.routers.config.action_config_service.get_action",
AsyncMock(side_effect=ActionNotFoundError("missing")), AsyncMock(side_effect=ActionNotFoundError("missing")),
): ):
resp = await config_client.get("/api/config/actions/missing") resp = await config_client.get("/api/config/actions/missing")
@@ -1563,7 +1563,7 @@ class TestUpdateActionRouter:
) )
with patch( with patch(
"app.routers.config.config_file_service.update_action", "app.routers.config.action_config_service.update_action",
AsyncMock(return_value=updated), AsyncMock(return_value=updated),
): ):
resp = await config_client.put( resp = await config_client.put(
@@ -1575,10 +1575,10 @@ class TestUpdateActionRouter:
assert resp.json()["actionban"] == "echo ban" assert resp.json()["actionban"] == "echo ban"
async def test_404_when_not_found(self, config_client: AsyncClient) -> None: async def test_404_when_not_found(self, config_client: AsyncClient) -> None:
from app.services.config_file_service import ActionNotFoundError from app.services.action_config_service import ActionNotFoundError
with patch( with patch(
"app.routers.config.config_file_service.update_action", "app.routers.config.action_config_service.update_action",
AsyncMock(side_effect=ActionNotFoundError("missing")), AsyncMock(side_effect=ActionNotFoundError("missing")),
): ):
resp = await config_client.put( resp = await config_client.put(
@@ -1588,10 +1588,10 @@ class TestUpdateActionRouter:
assert resp.status_code == 404 assert resp.status_code == 404
async def test_400_for_bad_name(self, config_client: AsyncClient) -> None: async def test_400_for_bad_name(self, config_client: AsyncClient) -> None:
from app.services.config_file_service import ActionNameError from app.services.action_config_service import ActionNameError
with patch( with patch(
"app.routers.config.config_file_service.update_action", "app.routers.config.action_config_service.update_action",
AsyncMock(side_effect=ActionNameError()), AsyncMock(side_effect=ActionNameError()),
): ):
resp = await config_client.put( resp = await config_client.put(
@@ -1620,7 +1620,7 @@ class TestCreateActionRouter:
) )
with patch( with patch(
"app.routers.config.config_file_service.create_action", "app.routers.config.action_config_service.create_action",
AsyncMock(return_value=created), AsyncMock(return_value=created),
): ):
resp = await config_client.post( resp = await config_client.post(
@@ -1632,10 +1632,10 @@ class TestCreateActionRouter:
assert resp.json()["name"] == "custom" assert resp.json()["name"] == "custom"
async def test_409_when_already_exists(self, config_client: AsyncClient) -> None: async def test_409_when_already_exists(self, config_client: AsyncClient) -> None:
from app.services.config_file_service import ActionAlreadyExistsError from app.services.action_config_service import ActionAlreadyExistsError
with patch( with patch(
"app.routers.config.config_file_service.create_action", "app.routers.config.action_config_service.create_action",
AsyncMock(side_effect=ActionAlreadyExistsError("iptables")), AsyncMock(side_effect=ActionAlreadyExistsError("iptables")),
): ):
resp = await config_client.post( resp = await config_client.post(
@@ -1646,10 +1646,10 @@ class TestCreateActionRouter:
assert resp.status_code == 409 assert resp.status_code == 409
async def test_400_for_bad_name(self, config_client: AsyncClient) -> None: async def test_400_for_bad_name(self, config_client: AsyncClient) -> None:
from app.services.config_file_service import ActionNameError from app.services.action_config_service import ActionNameError
with patch( with patch(
"app.routers.config.config_file_service.create_action", "app.routers.config.action_config_service.create_action",
AsyncMock(side_effect=ActionNameError()), AsyncMock(side_effect=ActionNameError()),
): ):
resp = await config_client.post( resp = await config_client.post(
@@ -1671,7 +1671,7 @@ class TestCreateActionRouter:
class TestDeleteActionRouter: class TestDeleteActionRouter:
async def test_204_on_delete(self, config_client: AsyncClient) -> None: async def test_204_on_delete(self, config_client: AsyncClient) -> None:
with patch( with patch(
"app.routers.config.config_file_service.delete_action", "app.routers.config.action_config_service.delete_action",
AsyncMock(return_value=None), AsyncMock(return_value=None),
): ):
resp = await config_client.delete("/api/config/actions/custom") resp = await config_client.delete("/api/config/actions/custom")
@@ -1679,10 +1679,10 @@ class TestDeleteActionRouter:
assert resp.status_code == 204 assert resp.status_code == 204
async def test_404_when_not_found(self, config_client: AsyncClient) -> None: async def test_404_when_not_found(self, config_client: AsyncClient) -> None:
from app.services.config_file_service import ActionNotFoundError from app.services.action_config_service import ActionNotFoundError
with patch( with patch(
"app.routers.config.config_file_service.delete_action", "app.routers.config.action_config_service.delete_action",
AsyncMock(side_effect=ActionNotFoundError("missing")), AsyncMock(side_effect=ActionNotFoundError("missing")),
): ):
resp = await config_client.delete("/api/config/actions/missing") resp = await config_client.delete("/api/config/actions/missing")
@@ -1690,10 +1690,10 @@ class TestDeleteActionRouter:
assert resp.status_code == 404 assert resp.status_code == 404
async def test_409_when_readonly(self, config_client: AsyncClient) -> None: async def test_409_when_readonly(self, config_client: AsyncClient) -> None:
from app.services.config_file_service import ActionReadonlyError from app.services.action_config_service import ActionReadonlyError
with patch( with patch(
"app.routers.config.config_file_service.delete_action", "app.routers.config.action_config_service.delete_action",
AsyncMock(side_effect=ActionReadonlyError("iptables")), AsyncMock(side_effect=ActionReadonlyError("iptables")),
): ):
resp = await config_client.delete("/api/config/actions/iptables") resp = await config_client.delete("/api/config/actions/iptables")
@@ -1701,10 +1701,10 @@ class TestDeleteActionRouter:
assert resp.status_code == 409 assert resp.status_code == 409
async def test_400_for_bad_name(self, config_client: AsyncClient) -> None: async def test_400_for_bad_name(self, config_client: AsyncClient) -> None:
from app.services.config_file_service import ActionNameError from app.services.action_config_service import ActionNameError
with patch( with patch(
"app.routers.config.config_file_service.delete_action", "app.routers.config.action_config_service.delete_action",
AsyncMock(side_effect=ActionNameError()), AsyncMock(side_effect=ActionNameError()),
): ):
resp = await config_client.delete("/api/config/actions/badname") resp = await config_client.delete("/api/config/actions/badname")
@@ -1723,7 +1723,7 @@ class TestDeleteActionRouter:
class TestAssignActionToJailRouter: class TestAssignActionToJailRouter:
async def test_204_on_success(self, config_client: AsyncClient) -> None: async def test_204_on_success(self, config_client: AsyncClient) -> None:
with patch( with patch(
"app.routers.config.config_file_service.assign_action_to_jail", "app.routers.config.action_config_service.assign_action_to_jail",
AsyncMock(return_value=None), AsyncMock(return_value=None),
): ):
resp = await config_client.post( resp = await config_client.post(
@@ -1734,10 +1734,10 @@ class TestAssignActionToJailRouter:
assert resp.status_code == 204 assert resp.status_code == 204
async def test_404_when_jail_not_found(self, config_client: AsyncClient) -> None: async def test_404_when_jail_not_found(self, config_client: AsyncClient) -> None:
from app.services.config_file_service import JailNotFoundInConfigError from app.services.jail_config_service import JailNotFoundInConfigError
with patch( with patch(
"app.routers.config.config_file_service.assign_action_to_jail", "app.routers.config.action_config_service.assign_action_to_jail",
AsyncMock(side_effect=JailNotFoundInConfigError("missing")), AsyncMock(side_effect=JailNotFoundInConfigError("missing")),
): ):
resp = await config_client.post( resp = await config_client.post(
@@ -1748,10 +1748,10 @@ class TestAssignActionToJailRouter:
assert resp.status_code == 404 assert resp.status_code == 404
async def test_404_when_action_not_found(self, config_client: AsyncClient) -> None: async def test_404_when_action_not_found(self, config_client: AsyncClient) -> None:
from app.services.config_file_service import ActionNotFoundError from app.services.action_config_service import ActionNotFoundError
with patch( with patch(
"app.routers.config.config_file_service.assign_action_to_jail", "app.routers.config.action_config_service.assign_action_to_jail",
AsyncMock(side_effect=ActionNotFoundError("missing")), AsyncMock(side_effect=ActionNotFoundError("missing")),
): ):
resp = await config_client.post( resp = await config_client.post(
@@ -1762,10 +1762,10 @@ class TestAssignActionToJailRouter:
assert resp.status_code == 404 assert resp.status_code == 404
async def test_400_for_bad_jail_name(self, config_client: AsyncClient) -> None: async def test_400_for_bad_jail_name(self, config_client: AsyncClient) -> None:
from app.services.config_file_service import JailNameError from app.services.jail_config_service import JailNameError
with patch( with patch(
"app.routers.config.config_file_service.assign_action_to_jail", "app.routers.config.action_config_service.assign_action_to_jail",
AsyncMock(side_effect=JailNameError()), AsyncMock(side_effect=JailNameError()),
): ):
resp = await config_client.post( resp = await config_client.post(
@@ -1776,10 +1776,10 @@ class TestAssignActionToJailRouter:
assert resp.status_code == 400 assert resp.status_code == 400
async def test_400_for_bad_action_name(self, config_client: AsyncClient) -> None: async def test_400_for_bad_action_name(self, config_client: AsyncClient) -> None:
from app.services.config_file_service import ActionNameError from app.services.action_config_service import ActionNameError
with patch( with patch(
"app.routers.config.config_file_service.assign_action_to_jail", "app.routers.config.action_config_service.assign_action_to_jail",
AsyncMock(side_effect=ActionNameError()), AsyncMock(side_effect=ActionNameError()),
): ):
resp = await config_client.post( resp = await config_client.post(
@@ -1791,7 +1791,7 @@ class TestAssignActionToJailRouter:
async def test_reload_param_passed(self, config_client: AsyncClient) -> None: async def test_reload_param_passed(self, config_client: AsyncClient) -> None:
with patch( with patch(
"app.routers.config.config_file_service.assign_action_to_jail", "app.routers.config.action_config_service.assign_action_to_jail",
AsyncMock(return_value=None), AsyncMock(return_value=None),
) as mock_assign: ) as mock_assign:
resp = await config_client.post( resp = await config_client.post(
@@ -1814,7 +1814,7 @@ class TestAssignActionToJailRouter:
class TestRemoveActionFromJailRouter: class TestRemoveActionFromJailRouter:
async def test_204_on_success(self, config_client: AsyncClient) -> None: async def test_204_on_success(self, config_client: AsyncClient) -> None:
with patch( with patch(
"app.routers.config.config_file_service.remove_action_from_jail", "app.routers.config.action_config_service.remove_action_from_jail",
AsyncMock(return_value=None), AsyncMock(return_value=None),
): ):
resp = await config_client.delete( resp = await config_client.delete(
@@ -1824,10 +1824,10 @@ class TestRemoveActionFromJailRouter:
assert resp.status_code == 204 assert resp.status_code == 204
async def test_404_when_jail_not_found(self, config_client: AsyncClient) -> None: async def test_404_when_jail_not_found(self, config_client: AsyncClient) -> None:
from app.services.config_file_service import JailNotFoundInConfigError from app.services.jail_config_service import JailNotFoundInConfigError
with patch( with patch(
"app.routers.config.config_file_service.remove_action_from_jail", "app.routers.config.action_config_service.remove_action_from_jail",
AsyncMock(side_effect=JailNotFoundInConfigError("missing")), AsyncMock(side_effect=JailNotFoundInConfigError("missing")),
): ):
resp = await config_client.delete( resp = await config_client.delete(
@@ -1837,10 +1837,10 @@ class TestRemoveActionFromJailRouter:
assert resp.status_code == 404 assert resp.status_code == 404
async def test_400_for_bad_jail_name(self, config_client: AsyncClient) -> None: async def test_400_for_bad_jail_name(self, config_client: AsyncClient) -> None:
from app.services.config_file_service import JailNameError from app.services.jail_config_service import JailNameError
with patch( with patch(
"app.routers.config.config_file_service.remove_action_from_jail", "app.routers.config.action_config_service.remove_action_from_jail",
AsyncMock(side_effect=JailNameError()), AsyncMock(side_effect=JailNameError()),
): ):
resp = await config_client.delete( resp = await config_client.delete(
@@ -1850,10 +1850,10 @@ class TestRemoveActionFromJailRouter:
assert resp.status_code == 400 assert resp.status_code == 400
async def test_400_for_bad_action_name(self, config_client: AsyncClient) -> None: async def test_400_for_bad_action_name(self, config_client: AsyncClient) -> None:
from app.services.config_file_service import ActionNameError from app.services.action_config_service import ActionNameError
with patch( with patch(
"app.routers.config.config_file_service.remove_action_from_jail", "app.routers.config.action_config_service.remove_action_from_jail",
AsyncMock(side_effect=ActionNameError()), AsyncMock(side_effect=ActionNameError()),
): ):
resp = await config_client.delete( resp = await config_client.delete(
@@ -1864,7 +1864,7 @@ class TestRemoveActionFromJailRouter:
async def test_reload_param_passed(self, config_client: AsyncClient) -> None: async def test_reload_param_passed(self, config_client: AsyncClient) -> None:
with patch( with patch(
"app.routers.config.config_file_service.remove_action_from_jail", "app.routers.config.action_config_service.remove_action_from_jail",
AsyncMock(return_value=None), AsyncMock(return_value=None),
) as mock_rm: ) as mock_rm:
resp = await config_client.delete( resp = await config_client.delete(
@@ -2060,7 +2060,7 @@ class TestValidateJailEndpoint:
jail_name="sshd", valid=True, issues=[] jail_name="sshd", valid=True, issues=[]
) )
with patch( with patch(
"app.routers.config.config_file_service.validate_jail_config", "app.routers.config.jail_config_service.validate_jail_config",
AsyncMock(return_value=mock_result), AsyncMock(return_value=mock_result),
): ):
resp = await config_client.post("/api/config/jails/sshd/validate") resp = await config_client.post("/api/config/jails/sshd/validate")
@@ -2080,7 +2080,7 @@ class TestValidateJailEndpoint:
jail_name="sshd", valid=False, issues=[issue] jail_name="sshd", valid=False, issues=[issue]
) )
with patch( with patch(
"app.routers.config.config_file_service.validate_jail_config", "app.routers.config.jail_config_service.validate_jail_config",
AsyncMock(return_value=mock_result), AsyncMock(return_value=mock_result),
): ):
resp = await config_client.post("/api/config/jails/sshd/validate") resp = await config_client.post("/api/config/jails/sshd/validate")
@@ -2093,10 +2093,10 @@ class TestValidateJailEndpoint:
async def test_400_for_invalid_jail_name(self, config_client: AsyncClient) -> None: async def test_400_for_invalid_jail_name(self, config_client: AsyncClient) -> None:
"""POST /api/config/jails/bad-name/validate returns 400 on JailNameError.""" """POST /api/config/jails/bad-name/validate returns 400 on JailNameError."""
from app.services.config_file_service import JailNameError from app.services.jail_config_service import JailNameError
with patch( with patch(
"app.routers.config.config_file_service.validate_jail_config", "app.routers.config.jail_config_service.validate_jail_config",
AsyncMock(side_effect=JailNameError("bad name")), AsyncMock(side_effect=JailNameError("bad name")),
): ):
resp = await config_client.post("/api/config/jails/bad-name/validate") resp = await config_client.post("/api/config/jails/bad-name/validate")
@@ -2188,7 +2188,7 @@ class TestRollbackEndpoint:
message="Jail 'sshd' disabled and fail2ban restarted.", message="Jail 'sshd' disabled and fail2ban restarted.",
) )
with patch( with patch(
"app.routers.config.config_file_service.rollback_jail", "app.routers.config.jail_config_service.rollback_jail",
AsyncMock(return_value=mock_result), AsyncMock(return_value=mock_result),
): ):
resp = await config_client.post("/api/config/jails/sshd/rollback") resp = await config_client.post("/api/config/jails/sshd/rollback")
@@ -2225,7 +2225,7 @@ class TestRollbackEndpoint:
message="fail2ban did not come back online.", message="fail2ban did not come back online.",
) )
with patch( with patch(
"app.routers.config.config_file_service.rollback_jail", "app.routers.config.jail_config_service.rollback_jail",
AsyncMock(return_value=mock_result), AsyncMock(return_value=mock_result),
): ):
resp = await config_client.post("/api/config/jails/sshd/rollback") resp = await config_client.post("/api/config/jails/sshd/rollback")
@@ -2238,10 +2238,10 @@ class TestRollbackEndpoint:
async def test_400_for_invalid_jail_name(self, config_client: AsyncClient) -> None: async def test_400_for_invalid_jail_name(self, config_client: AsyncClient) -> None:
"""POST /api/config/jails/bad/rollback returns 400 on JailNameError.""" """POST /api/config/jails/bad/rollback returns 400 on JailNameError."""
from app.services.config_file_service import JailNameError from app.services.jail_config_service import JailNameError
with patch( with patch(
"app.routers.config.config_file_service.rollback_jail", "app.routers.config.jail_config_service.rollback_jail",
AsyncMock(side_effect=JailNameError("bad")), AsyncMock(side_effect=JailNameError("bad")),
): ):
resp = await config_client.post("/api/config/jails/bad/rollback") resp = await config_client.post("/api/config/jails/bad/rollback")

View File

@@ -26,7 +26,7 @@ from app.models.file_config import (
JailConfigFileContent, JailConfigFileContent,
JailConfigFilesResponse, JailConfigFilesResponse,
) )
from app.services.file_config_service import ( from app.services.raw_config_io_service import (
ConfigDirError, ConfigDirError,
ConfigFileExistsError, ConfigFileExistsError,
ConfigFileNameError, ConfigFileNameError,
@@ -112,7 +112,7 @@ class TestListJailConfigFiles:
self, file_config_client: AsyncClient self, file_config_client: AsyncClient
) -> None: ) -> None:
with patch( with patch(
"app.routers.file_config.file_config_service.list_jail_config_files", "app.routers.file_config.raw_config_io_service.list_jail_config_files",
AsyncMock(return_value=_jail_files_resp()), AsyncMock(return_value=_jail_files_resp()),
): ):
resp = await file_config_client.get("/api/config/jail-files") resp = await file_config_client.get("/api/config/jail-files")
@@ -126,7 +126,7 @@ class TestListJailConfigFiles:
self, file_config_client: AsyncClient self, file_config_client: AsyncClient
) -> None: ) -> None:
with patch( with patch(
"app.routers.file_config.file_config_service.list_jail_config_files", "app.routers.file_config.raw_config_io_service.list_jail_config_files",
AsyncMock(side_effect=ConfigDirError("not found")), AsyncMock(side_effect=ConfigDirError("not found")),
): ):
resp = await file_config_client.get("/api/config/jail-files") resp = await file_config_client.get("/api/config/jail-files")
@@ -157,7 +157,7 @@ class TestGetJailConfigFile:
content="[sshd]\nenabled = true\n", content="[sshd]\nenabled = true\n",
) )
with patch( with patch(
"app.routers.file_config.file_config_service.get_jail_config_file", "app.routers.file_config.raw_config_io_service.get_jail_config_file",
AsyncMock(return_value=content), AsyncMock(return_value=content),
): ):
resp = await file_config_client.get("/api/config/jail-files/sshd.conf") resp = await file_config_client.get("/api/config/jail-files/sshd.conf")
@@ -167,7 +167,7 @@ class TestGetJailConfigFile:
async def test_404_not_found(self, file_config_client: AsyncClient) -> None: async def test_404_not_found(self, file_config_client: AsyncClient) -> None:
with patch( with patch(
"app.routers.file_config.file_config_service.get_jail_config_file", "app.routers.file_config.raw_config_io_service.get_jail_config_file",
AsyncMock(side_effect=ConfigFileNotFoundError("missing.conf")), AsyncMock(side_effect=ConfigFileNotFoundError("missing.conf")),
): ):
resp = await file_config_client.get("/api/config/jail-files/missing.conf") resp = await file_config_client.get("/api/config/jail-files/missing.conf")
@@ -178,7 +178,7 @@ class TestGetJailConfigFile:
self, file_config_client: AsyncClient self, file_config_client: AsyncClient
) -> None: ) -> None:
with patch( with patch(
"app.routers.file_config.file_config_service.get_jail_config_file", "app.routers.file_config.raw_config_io_service.get_jail_config_file",
AsyncMock(side_effect=ConfigFileNameError("bad name")), AsyncMock(side_effect=ConfigFileNameError("bad name")),
): ):
resp = await file_config_client.get("/api/config/jail-files/bad.txt") resp = await file_config_client.get("/api/config/jail-files/bad.txt")
@@ -194,7 +194,7 @@ class TestGetJailConfigFile:
class TestSetJailConfigEnabled: class TestSetJailConfigEnabled:
async def test_204_on_success(self, file_config_client: AsyncClient) -> None: async def test_204_on_success(self, file_config_client: AsyncClient) -> None:
with patch( with patch(
"app.routers.file_config.file_config_service.set_jail_config_enabled", "app.routers.file_config.raw_config_io_service.set_jail_config_enabled",
AsyncMock(return_value=None), AsyncMock(return_value=None),
): ):
resp = await file_config_client.put( resp = await file_config_client.put(
@@ -206,7 +206,7 @@ class TestSetJailConfigEnabled:
async def test_404_file_not_found(self, file_config_client: AsyncClient) -> None: async def test_404_file_not_found(self, file_config_client: AsyncClient) -> None:
with patch( with patch(
"app.routers.file_config.file_config_service.set_jail_config_enabled", "app.routers.file_config.raw_config_io_service.set_jail_config_enabled",
AsyncMock(side_effect=ConfigFileNotFoundError("missing.conf")), AsyncMock(side_effect=ConfigFileNotFoundError("missing.conf")),
): ):
resp = await file_config_client.put( resp = await file_config_client.put(
@@ -232,7 +232,7 @@ class TestGetFilterFileRaw:
async def test_200_returns_content(self, file_config_client: AsyncClient) -> None: async def test_200_returns_content(self, file_config_client: AsyncClient) -> None:
with patch( with patch(
"app.routers.file_config.file_config_service.get_filter_file", "app.routers.file_config.raw_config_io_service.get_filter_file",
AsyncMock(return_value=_conf_file_content("nginx")), AsyncMock(return_value=_conf_file_content("nginx")),
): ):
resp = await file_config_client.get("/api/config/filters/nginx/raw") resp = await file_config_client.get("/api/config/filters/nginx/raw")
@@ -242,7 +242,7 @@ class TestGetFilterFileRaw:
async def test_404_not_found(self, file_config_client: AsyncClient) -> None: async def test_404_not_found(self, file_config_client: AsyncClient) -> None:
with patch( with patch(
"app.routers.file_config.file_config_service.get_filter_file", "app.routers.file_config.raw_config_io_service.get_filter_file",
AsyncMock(side_effect=ConfigFileNotFoundError("missing")), AsyncMock(side_effect=ConfigFileNotFoundError("missing")),
): ):
resp = await file_config_client.get("/api/config/filters/missing/raw") resp = await file_config_client.get("/api/config/filters/missing/raw")
@@ -258,7 +258,7 @@ class TestGetFilterFileRaw:
class TestUpdateFilterFile: class TestUpdateFilterFile:
async def test_204_on_success(self, file_config_client: AsyncClient) -> None: async def test_204_on_success(self, file_config_client: AsyncClient) -> None:
with patch( with patch(
"app.routers.file_config.file_config_service.write_filter_file", "app.routers.file_config.raw_config_io_service.write_filter_file",
AsyncMock(return_value=None), AsyncMock(return_value=None),
): ):
resp = await file_config_client.put( resp = await file_config_client.put(
@@ -270,7 +270,7 @@ class TestUpdateFilterFile:
async def test_400_write_error(self, file_config_client: AsyncClient) -> None: async def test_400_write_error(self, file_config_client: AsyncClient) -> None:
with patch( with patch(
"app.routers.file_config.file_config_service.write_filter_file", "app.routers.file_config.raw_config_io_service.write_filter_file",
AsyncMock(side_effect=ConfigFileWriteError("disk full")), AsyncMock(side_effect=ConfigFileWriteError("disk full")),
): ):
resp = await file_config_client.put( resp = await file_config_client.put(
@@ -289,7 +289,7 @@ class TestUpdateFilterFile:
class TestCreateFilterFile: class TestCreateFilterFile:
async def test_201_creates_file(self, file_config_client: AsyncClient) -> None: async def test_201_creates_file(self, file_config_client: AsyncClient) -> None:
with patch( with patch(
"app.routers.file_config.file_config_service.create_filter_file", "app.routers.file_config.raw_config_io_service.create_filter_file",
AsyncMock(return_value="myfilter.conf"), AsyncMock(return_value="myfilter.conf"),
): ):
resp = await file_config_client.post( resp = await file_config_client.post(
@@ -302,7 +302,7 @@ class TestCreateFilterFile:
async def test_409_conflict(self, file_config_client: AsyncClient) -> None: async def test_409_conflict(self, file_config_client: AsyncClient) -> None:
with patch( with patch(
"app.routers.file_config.file_config_service.create_filter_file", "app.routers.file_config.raw_config_io_service.create_filter_file",
AsyncMock(side_effect=ConfigFileExistsError("myfilter.conf")), AsyncMock(side_effect=ConfigFileExistsError("myfilter.conf")),
): ):
resp = await file_config_client.post( resp = await file_config_client.post(
@@ -314,7 +314,7 @@ class TestCreateFilterFile:
async def test_400_invalid_name(self, file_config_client: AsyncClient) -> None: async def test_400_invalid_name(self, file_config_client: AsyncClient) -> None:
with patch( with patch(
"app.routers.file_config.file_config_service.create_filter_file", "app.routers.file_config.raw_config_io_service.create_filter_file",
AsyncMock(side_effect=ConfigFileNameError("bad/../name")), AsyncMock(side_effect=ConfigFileNameError("bad/../name")),
): ):
resp = await file_config_client.post( resp = await file_config_client.post(
@@ -342,7 +342,7 @@ class TestListActionFiles:
) )
resp_data = ActionListResponse(actions=[mock_action], total=1) resp_data = ActionListResponse(actions=[mock_action], total=1)
with patch( with patch(
"app.routers.config.config_file_service.list_actions", "app.routers.config.action_config_service.list_actions",
AsyncMock(return_value=resp_data), AsyncMock(return_value=resp_data),
): ):
resp = await file_config_client.get("/api/config/actions") resp = await file_config_client.get("/api/config/actions")
@@ -365,7 +365,7 @@ class TestCreateActionFile:
actionban="echo ban <ip>", actionban="echo ban <ip>",
) )
with patch( with patch(
"app.routers.config.config_file_service.create_action", "app.routers.config.action_config_service.create_action",
AsyncMock(return_value=created), AsyncMock(return_value=created),
): ):
resp = await file_config_client.post( resp = await file_config_client.post(
@@ -387,7 +387,7 @@ class TestGetActionFileRaw:
async def test_200_returns_content(self, file_config_client: AsyncClient) -> None: async def test_200_returns_content(self, file_config_client: AsyncClient) -> None:
with patch( with patch(
"app.routers.file_config.file_config_service.get_action_file", "app.routers.file_config.raw_config_io_service.get_action_file",
AsyncMock(return_value=_conf_file_content("iptables")), AsyncMock(return_value=_conf_file_content("iptables")),
): ):
resp = await file_config_client.get("/api/config/actions/iptables/raw") resp = await file_config_client.get("/api/config/actions/iptables/raw")
@@ -397,7 +397,7 @@ class TestGetActionFileRaw:
async def test_404_not_found(self, file_config_client: AsyncClient) -> None: async def test_404_not_found(self, file_config_client: AsyncClient) -> None:
with patch( with patch(
"app.routers.file_config.file_config_service.get_action_file", "app.routers.file_config.raw_config_io_service.get_action_file",
AsyncMock(side_effect=ConfigFileNotFoundError("missing")), AsyncMock(side_effect=ConfigFileNotFoundError("missing")),
): ):
resp = await file_config_client.get("/api/config/actions/missing/raw") resp = await file_config_client.get("/api/config/actions/missing/raw")
@@ -408,7 +408,7 @@ class TestGetActionFileRaw:
self, file_config_client: AsyncClient self, file_config_client: AsyncClient
) -> None: ) -> None:
with patch( with patch(
"app.routers.file_config.file_config_service.get_action_file", "app.routers.file_config.raw_config_io_service.get_action_file",
AsyncMock(side_effect=ConfigDirError("no dir")), AsyncMock(side_effect=ConfigDirError("no dir")),
): ):
resp = await file_config_client.get("/api/config/actions/iptables/raw") resp = await file_config_client.get("/api/config/actions/iptables/raw")
@@ -426,7 +426,7 @@ class TestUpdateActionFileRaw:
async def test_204_on_success(self, file_config_client: AsyncClient) -> None: async def test_204_on_success(self, file_config_client: AsyncClient) -> None:
with patch( with patch(
"app.routers.file_config.file_config_service.write_action_file", "app.routers.file_config.raw_config_io_service.write_action_file",
AsyncMock(return_value=None), AsyncMock(return_value=None),
): ):
resp = await file_config_client.put( resp = await file_config_client.put(
@@ -438,7 +438,7 @@ class TestUpdateActionFileRaw:
async def test_400_write_error(self, file_config_client: AsyncClient) -> None: async def test_400_write_error(self, file_config_client: AsyncClient) -> None:
with patch( with patch(
"app.routers.file_config.file_config_service.write_action_file", "app.routers.file_config.raw_config_io_service.write_action_file",
AsyncMock(side_effect=ConfigFileWriteError("disk full")), AsyncMock(side_effect=ConfigFileWriteError("disk full")),
): ):
resp = await file_config_client.put( resp = await file_config_client.put(
@@ -450,7 +450,7 @@ class TestUpdateActionFileRaw:
async def test_404_not_found(self, file_config_client: AsyncClient) -> None: async def test_404_not_found(self, file_config_client: AsyncClient) -> None:
with patch( with patch(
"app.routers.file_config.file_config_service.write_action_file", "app.routers.file_config.raw_config_io_service.write_action_file",
AsyncMock(side_effect=ConfigFileNotFoundError("missing")), AsyncMock(side_effect=ConfigFileNotFoundError("missing")),
): ):
resp = await file_config_client.put( resp = await file_config_client.put(
@@ -462,7 +462,7 @@ class TestUpdateActionFileRaw:
async def test_400_invalid_name(self, file_config_client: AsyncClient) -> None: async def test_400_invalid_name(self, file_config_client: AsyncClient) -> None:
with patch( with patch(
"app.routers.file_config.file_config_service.write_action_file", "app.routers.file_config.raw_config_io_service.write_action_file",
AsyncMock(side_effect=ConfigFileNameError("bad/../name")), AsyncMock(side_effect=ConfigFileNameError("bad/../name")),
): ):
resp = await file_config_client.put( resp = await file_config_client.put(
@@ -481,7 +481,7 @@ class TestUpdateActionFileRaw:
class TestCreateJailConfigFile: class TestCreateJailConfigFile:
async def test_201_creates_file(self, file_config_client: AsyncClient) -> None: async def test_201_creates_file(self, file_config_client: AsyncClient) -> None:
with patch( with patch(
"app.routers.file_config.file_config_service.create_jail_config_file", "app.routers.file_config.raw_config_io_service.create_jail_config_file",
AsyncMock(return_value="myjail.conf"), AsyncMock(return_value="myjail.conf"),
): ):
resp = await file_config_client.post( resp = await file_config_client.post(
@@ -494,7 +494,7 @@ class TestCreateJailConfigFile:
async def test_409_conflict(self, file_config_client: AsyncClient) -> None: async def test_409_conflict(self, file_config_client: AsyncClient) -> None:
with patch( with patch(
"app.routers.file_config.file_config_service.create_jail_config_file", "app.routers.file_config.raw_config_io_service.create_jail_config_file",
AsyncMock(side_effect=ConfigFileExistsError("myjail.conf")), AsyncMock(side_effect=ConfigFileExistsError("myjail.conf")),
): ):
resp = await file_config_client.post( resp = await file_config_client.post(
@@ -506,7 +506,7 @@ class TestCreateJailConfigFile:
async def test_400_invalid_name(self, file_config_client: AsyncClient) -> None: async def test_400_invalid_name(self, file_config_client: AsyncClient) -> None:
with patch( with patch(
"app.routers.file_config.file_config_service.create_jail_config_file", "app.routers.file_config.raw_config_io_service.create_jail_config_file",
AsyncMock(side_effect=ConfigFileNameError("bad/../name")), AsyncMock(side_effect=ConfigFileNameError("bad/../name")),
): ):
resp = await file_config_client.post( resp = await file_config_client.post(
@@ -520,7 +520,7 @@ class TestCreateJailConfigFile:
self, file_config_client: AsyncClient self, file_config_client: AsyncClient
) -> None: ) -> None:
with patch( with patch(
"app.routers.file_config.file_config_service.create_jail_config_file", "app.routers.file_config.raw_config_io_service.create_jail_config_file",
AsyncMock(side_effect=ConfigDirError("no dir")), AsyncMock(side_effect=ConfigDirError("no dir")),
): ):
resp = await file_config_client.post( resp = await file_config_client.post(
@@ -542,7 +542,7 @@ class TestGetParsedFilter:
) -> None: ) -> None:
cfg = FilterConfig(name="nginx", filename="nginx.conf") cfg = FilterConfig(name="nginx", filename="nginx.conf")
with patch( with patch(
"app.routers.file_config.file_config_service.get_parsed_filter_file", "app.routers.file_config.raw_config_io_service.get_parsed_filter_file",
AsyncMock(return_value=cfg), AsyncMock(return_value=cfg),
): ):
resp = await file_config_client.get("/api/config/filters/nginx/parsed") resp = await file_config_client.get("/api/config/filters/nginx/parsed")
@@ -554,7 +554,7 @@ class TestGetParsedFilter:
async def test_404_not_found(self, file_config_client: AsyncClient) -> None: async def test_404_not_found(self, file_config_client: AsyncClient) -> None:
with patch( with patch(
"app.routers.file_config.file_config_service.get_parsed_filter_file", "app.routers.file_config.raw_config_io_service.get_parsed_filter_file",
AsyncMock(side_effect=ConfigFileNotFoundError("missing")), AsyncMock(side_effect=ConfigFileNotFoundError("missing")),
): ):
resp = await file_config_client.get( resp = await file_config_client.get(
@@ -567,7 +567,7 @@ class TestGetParsedFilter:
self, file_config_client: AsyncClient self, file_config_client: AsyncClient
) -> None: ) -> None:
with patch( with patch(
"app.routers.file_config.file_config_service.get_parsed_filter_file", "app.routers.file_config.raw_config_io_service.get_parsed_filter_file",
AsyncMock(side_effect=ConfigDirError("no dir")), AsyncMock(side_effect=ConfigDirError("no dir")),
): ):
resp = await file_config_client.get("/api/config/filters/nginx/parsed") resp = await file_config_client.get("/api/config/filters/nginx/parsed")
@@ -583,7 +583,7 @@ class TestGetParsedFilter:
class TestUpdateParsedFilter: class TestUpdateParsedFilter:
async def test_204_on_success(self, file_config_client: AsyncClient) -> None: async def test_204_on_success(self, file_config_client: AsyncClient) -> None:
with patch( with patch(
"app.routers.file_config.file_config_service.update_parsed_filter_file", "app.routers.file_config.raw_config_io_service.update_parsed_filter_file",
AsyncMock(return_value=None), AsyncMock(return_value=None),
): ):
resp = await file_config_client.put( resp = await file_config_client.put(
@@ -595,7 +595,7 @@ class TestUpdateParsedFilter:
async def test_404_not_found(self, file_config_client: AsyncClient) -> None: async def test_404_not_found(self, file_config_client: AsyncClient) -> None:
with patch( with patch(
"app.routers.file_config.file_config_service.update_parsed_filter_file", "app.routers.file_config.raw_config_io_service.update_parsed_filter_file",
AsyncMock(side_effect=ConfigFileNotFoundError("missing")), AsyncMock(side_effect=ConfigFileNotFoundError("missing")),
): ):
resp = await file_config_client.put( resp = await file_config_client.put(
@@ -607,7 +607,7 @@ class TestUpdateParsedFilter:
async def test_400_write_error(self, file_config_client: AsyncClient) -> None: async def test_400_write_error(self, file_config_client: AsyncClient) -> None:
with patch( with patch(
"app.routers.file_config.file_config_service.update_parsed_filter_file", "app.routers.file_config.raw_config_io_service.update_parsed_filter_file",
AsyncMock(side_effect=ConfigFileWriteError("disk full")), AsyncMock(side_effect=ConfigFileWriteError("disk full")),
): ):
resp = await file_config_client.put( resp = await file_config_client.put(
@@ -629,7 +629,7 @@ class TestGetParsedAction:
) -> None: ) -> None:
cfg = ActionConfig(name="iptables", filename="iptables.conf") cfg = ActionConfig(name="iptables", filename="iptables.conf")
with patch( with patch(
"app.routers.file_config.file_config_service.get_parsed_action_file", "app.routers.file_config.raw_config_io_service.get_parsed_action_file",
AsyncMock(return_value=cfg), AsyncMock(return_value=cfg),
): ):
resp = await file_config_client.get( resp = await file_config_client.get(
@@ -643,7 +643,7 @@ class TestGetParsedAction:
async def test_404_not_found(self, file_config_client: AsyncClient) -> None: async def test_404_not_found(self, file_config_client: AsyncClient) -> None:
with patch( with patch(
"app.routers.file_config.file_config_service.get_parsed_action_file", "app.routers.file_config.raw_config_io_service.get_parsed_action_file",
AsyncMock(side_effect=ConfigFileNotFoundError("missing")), AsyncMock(side_effect=ConfigFileNotFoundError("missing")),
): ):
resp = await file_config_client.get( resp = await file_config_client.get(
@@ -656,7 +656,7 @@ class TestGetParsedAction:
self, file_config_client: AsyncClient self, file_config_client: AsyncClient
) -> None: ) -> None:
with patch( with patch(
"app.routers.file_config.file_config_service.get_parsed_action_file", "app.routers.file_config.raw_config_io_service.get_parsed_action_file",
AsyncMock(side_effect=ConfigDirError("no dir")), AsyncMock(side_effect=ConfigDirError("no dir")),
): ):
resp = await file_config_client.get( resp = await file_config_client.get(
@@ -674,7 +674,7 @@ class TestGetParsedAction:
class TestUpdateParsedAction: class TestUpdateParsedAction:
async def test_204_on_success(self, file_config_client: AsyncClient) -> None: async def test_204_on_success(self, file_config_client: AsyncClient) -> None:
with patch( with patch(
"app.routers.file_config.file_config_service.update_parsed_action_file", "app.routers.file_config.raw_config_io_service.update_parsed_action_file",
AsyncMock(return_value=None), AsyncMock(return_value=None),
): ):
resp = await file_config_client.put( resp = await file_config_client.put(
@@ -686,7 +686,7 @@ class TestUpdateParsedAction:
async def test_404_not_found(self, file_config_client: AsyncClient) -> None: async def test_404_not_found(self, file_config_client: AsyncClient) -> None:
with patch( with patch(
"app.routers.file_config.file_config_service.update_parsed_action_file", "app.routers.file_config.raw_config_io_service.update_parsed_action_file",
AsyncMock(side_effect=ConfigFileNotFoundError("missing")), AsyncMock(side_effect=ConfigFileNotFoundError("missing")),
): ):
resp = await file_config_client.put( resp = await file_config_client.put(
@@ -698,7 +698,7 @@ class TestUpdateParsedAction:
async def test_400_write_error(self, file_config_client: AsyncClient) -> None: async def test_400_write_error(self, file_config_client: AsyncClient) -> None:
with patch( with patch(
"app.routers.file_config.file_config_service.update_parsed_action_file", "app.routers.file_config.raw_config_io_service.update_parsed_action_file",
AsyncMock(side_effect=ConfigFileWriteError("disk full")), AsyncMock(side_effect=ConfigFileWriteError("disk full")),
): ):
resp = await file_config_client.put( resp = await file_config_client.put(
@@ -721,7 +721,7 @@ class TestGetParsedJailFile:
section = JailSectionConfig(enabled=True, port="ssh") section = JailSectionConfig(enabled=True, port="ssh")
cfg = JailFileConfig(filename="sshd.conf", jails={"sshd": section}) cfg = JailFileConfig(filename="sshd.conf", jails={"sshd": section})
with patch( with patch(
"app.routers.file_config.file_config_service.get_parsed_jail_file", "app.routers.file_config.raw_config_io_service.get_parsed_jail_file",
AsyncMock(return_value=cfg), AsyncMock(return_value=cfg),
): ):
resp = await file_config_client.get( resp = await file_config_client.get(
@@ -735,7 +735,7 @@ class TestGetParsedJailFile:
async def test_404_not_found(self, file_config_client: AsyncClient) -> None: async def test_404_not_found(self, file_config_client: AsyncClient) -> None:
with patch( with patch(
"app.routers.file_config.file_config_service.get_parsed_jail_file", "app.routers.file_config.raw_config_io_service.get_parsed_jail_file",
AsyncMock(side_effect=ConfigFileNotFoundError("missing.conf")), AsyncMock(side_effect=ConfigFileNotFoundError("missing.conf")),
): ):
resp = await file_config_client.get( resp = await file_config_client.get(
@@ -748,7 +748,7 @@ class TestGetParsedJailFile:
self, file_config_client: AsyncClient self, file_config_client: AsyncClient
) -> None: ) -> None:
with patch( with patch(
"app.routers.file_config.file_config_service.get_parsed_jail_file", "app.routers.file_config.raw_config_io_service.get_parsed_jail_file",
AsyncMock(side_effect=ConfigDirError("no dir")), AsyncMock(side_effect=ConfigDirError("no dir")),
): ):
resp = await file_config_client.get( resp = await file_config_client.get(
@@ -766,7 +766,7 @@ class TestGetParsedJailFile:
class TestUpdateParsedJailFile: class TestUpdateParsedJailFile:
async def test_204_on_success(self, file_config_client: AsyncClient) -> None: async def test_204_on_success(self, file_config_client: AsyncClient) -> None:
with patch( with patch(
"app.routers.file_config.file_config_service.update_parsed_jail_file", "app.routers.file_config.raw_config_io_service.update_parsed_jail_file",
AsyncMock(return_value=None), AsyncMock(return_value=None),
): ):
resp = await file_config_client.put( resp = await file_config_client.put(
@@ -778,7 +778,7 @@ class TestUpdateParsedJailFile:
async def test_404_not_found(self, file_config_client: AsyncClient) -> None: async def test_404_not_found(self, file_config_client: AsyncClient) -> None:
with patch( with patch(
"app.routers.file_config.file_config_service.update_parsed_jail_file", "app.routers.file_config.raw_config_io_service.update_parsed_jail_file",
AsyncMock(side_effect=ConfigFileNotFoundError("missing.conf")), AsyncMock(side_effect=ConfigFileNotFoundError("missing.conf")),
): ):
resp = await file_config_client.put( resp = await file_config_client.put(
@@ -790,7 +790,7 @@ class TestUpdateParsedJailFile:
async def test_400_write_error(self, file_config_client: AsyncClient) -> None: async def test_400_write_error(self, file_config_client: AsyncClient) -> None:
with patch( with patch(
"app.routers.file_config.file_config_service.update_parsed_jail_file", "app.routers.file_config.raw_config_io_service.update_parsed_jail_file",
AsyncMock(side_effect=ConfigFileWriteError("disk full")), AsyncMock(side_effect=ConfigFileWriteError("disk full")),
): ):
resp = await file_config_client.put( resp = await file_config_client.put(

View File

@@ -203,9 +203,15 @@ class TestImport:
call_count += 1 call_count += 1
raise JailNotFoundError(jail) raise JailNotFoundError(jail)
with patch("app.services.jail_service.ban_ip", side_effect=_raise_jail_not_found): with patch("app.services.jail_service.ban_ip", side_effect=_raise_jail_not_found) as mocked_ban_ip:
from app.services import jail_service
result = await blocklist_service.import_source( result = await blocklist_service.import_source(
source, session, "/tmp/fake.sock", db source,
session,
"/tmp/fake.sock",
db,
ban_ip=jail_service.ban_ip,
) )
# Must abort after the first JailNotFoundError — only one ban attempt. # Must abort after the first JailNotFoundError — only one ban attempt.
@@ -226,7 +232,14 @@ class TestImport:
with patch( with patch(
"app.services.jail_service.ban_ip", new_callable=AsyncMock "app.services.jail_service.ban_ip", new_callable=AsyncMock
): ):
result = await blocklist_service.import_all(db, session, "/tmp/fake.sock") from app.services import jail_service
result = await blocklist_service.import_all(
db,
session,
"/tmp/fake.sock",
ban_ip=jail_service.ban_ip,
)
# Only S1 is enabled, S2 is disabled. # Only S1 is enabled, S2 is disabled.
assert len(result.results) == 1 assert len(result.results) == 1

View File

@@ -721,9 +721,11 @@ class TestGetServiceStatus:
def __init__(self, **_kw: Any) -> None: def __init__(self, **_kw: Any) -> None:
self.send = AsyncMock(side_effect=_send) self.send = AsyncMock(side_effect=_send)
with patch("app.services.config_service.Fail2BanClient", _FakeClient), \ with patch("app.services.config_service.Fail2BanClient", _FakeClient):
patch("app.services.health_service.probe", AsyncMock(return_value=online_status)): result = await config_service.get_service_status(
result = await config_service.get_service_status(_SOCKET) _SOCKET,
probe_fn=AsyncMock(return_value=online_status),
)
assert result.online is True assert result.online is True
assert result.version == "1.0.0" assert result.version == "1.0.0"
@@ -739,8 +741,10 @@ class TestGetServiceStatus:
offline_status = ServerStatus(online=False) offline_status = ServerStatus(online=False)
with patch("app.services.health_service.probe", AsyncMock(return_value=offline_status)): result = await config_service.get_service_status(
result = await config_service.get_service_status(_SOCKET) _SOCKET,
probe_fn=AsyncMock(return_value=offline_status),
)
assert result.online is False assert result.online is False
assert result.jail_count == 0 assert result.jail_count == 0

View File

@@ -8,7 +8,7 @@ import pytest
from app.models.config import ActionConfigUpdate, FilterConfigUpdate, JailFileConfigUpdate from app.models.config import ActionConfigUpdate, FilterConfigUpdate, JailFileConfigUpdate
from app.models.file_config import ConfFileCreateRequest, ConfFileUpdateRequest from app.models.file_config import ConfFileCreateRequest, ConfFileUpdateRequest
from app.services.file_config_service import ( from app.services.raw_config_io_service import (
ConfigDirError, ConfigDirError,
ConfigFileExistsError, ConfigFileExistsError,
ConfigFileNameError, ConfigFileNameError,

View File

@@ -14,6 +14,7 @@ import {
makeStyles, makeStyles,
tokens, tokens,
} from "@fluentui/react-components"; } from "@fluentui/react-components";
import { useCardStyles } from "../theme/commonStyles";
import type { BanOriginFilter, TimeRange } from "../types/ban"; import type { BanOriginFilter, TimeRange } from "../types/ban";
import { import {
BAN_ORIGIN_FILTER_LABELS, BAN_ORIGIN_FILTER_LABELS,
@@ -57,20 +58,6 @@ const useStyles = makeStyles({
alignItems: "center", alignItems: "center",
flexWrap: "wrap", flexWrap: "wrap",
gap: tokens.spacingVerticalS, gap: tokens.spacingVerticalS,
backgroundColor: tokens.colorNeutralBackground1,
borderRadius: tokens.borderRadiusMedium,
borderTopWidth: "1px",
borderTopStyle: "solid",
borderTopColor: tokens.colorNeutralStroke2,
borderRightWidth: "1px",
borderRightStyle: "solid",
borderRightColor: tokens.colorNeutralStroke2,
borderBottomWidth: "1px",
borderBottomStyle: "solid",
borderBottomColor: tokens.colorNeutralStroke2,
borderLeftWidth: "1px",
borderLeftStyle: "solid",
borderLeftColor: tokens.colorNeutralStroke2,
paddingTop: tokens.spacingVerticalS, paddingTop: tokens.spacingVerticalS,
paddingBottom: tokens.spacingVerticalS, paddingBottom: tokens.spacingVerticalS,
paddingLeft: tokens.spacingHorizontalM, paddingLeft: tokens.spacingHorizontalM,
@@ -107,9 +94,10 @@ export function DashboardFilterBar({
onOriginFilterChange, onOriginFilterChange,
}: DashboardFilterBarProps): React.JSX.Element { }: DashboardFilterBarProps): React.JSX.Element {
const styles = useStyles(); const styles = useStyles();
const cardStyles = useCardStyles();
return ( return (
<div className={styles.container}> <div className={`${styles.container} ${cardStyles.card}`}>
{/* Time-range group */} {/* Time-range group */}
<div className={styles.group}> <div className={styles.group}>
<Text weight="semibold" size={300}> <Text weight="semibold" size={300}>

View File

@@ -18,6 +18,7 @@ import {
tokens, tokens,
Tooltip, Tooltip,
} from "@fluentui/react-components"; } from "@fluentui/react-components";
import { useCardStyles } from "../theme/commonStyles";
import { ArrowClockwiseRegular, ShieldRegular } from "@fluentui/react-icons"; import { ArrowClockwiseRegular, ShieldRegular } from "@fluentui/react-icons";
import { useServerStatus } from "../hooks/useServerStatus"; import { useServerStatus } from "../hooks/useServerStatus";
@@ -31,20 +32,6 @@ const useStyles = makeStyles({
alignItems: "center", alignItems: "center",
gap: tokens.spacingHorizontalL, gap: tokens.spacingHorizontalL,
padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalL}`, padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalL}`,
backgroundColor: tokens.colorNeutralBackground1,
borderRadius: tokens.borderRadiusMedium,
borderTopWidth: "1px",
borderTopStyle: "solid",
borderTopColor: tokens.colorNeutralStroke2,
borderRightWidth: "1px",
borderRightStyle: "solid",
borderRightColor: tokens.colorNeutralStroke2,
borderBottomWidth: "1px",
borderBottomStyle: "solid",
borderBottomColor: tokens.colorNeutralStroke2,
borderLeftWidth: "1px",
borderLeftStyle: "solid",
borderLeftColor: tokens.colorNeutralStroke2,
marginBottom: tokens.spacingVerticalL, marginBottom: tokens.spacingVerticalL,
flexWrap: "wrap", flexWrap: "wrap",
}, },
@@ -85,8 +72,10 @@ export function ServerStatusBar(): React.JSX.Element {
const styles = useStyles(); const styles = useStyles();
const { status, loading, error, refresh } = useServerStatus(); const { status, loading, error, refresh } = useServerStatus();
const cardStyles = useCardStyles();
return ( return (
<div className={styles.bar} role="status" aria-label="fail2ban server status"> <div className={`${cardStyles.card} ${styles.bar}`} role="status" aria-label="fail2ban server status">
{/* ---------------------------------------------------------------- */} {/* ---------------------------------------------------------------- */}
{/* Online / Offline badge */} {/* Online / Offline badge */}
{/* ---------------------------------------------------------------- */} {/* ---------------------------------------------------------------- */}

View File

@@ -10,6 +10,7 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { ComposableMap, ZoomableGroup, Geography, useGeographies } from "react-simple-maps"; import { ComposableMap, ZoomableGroup, Geography, useGeographies } from "react-simple-maps";
import { Button, makeStyles, tokens } from "@fluentui/react-components"; import { Button, makeStyles, tokens } from "@fluentui/react-components";
import { useCardStyles } from "../theme/commonStyles";
import type { GeoPermissibleObjects } from "d3-geo"; import type { GeoPermissibleObjects } from "d3-geo";
import { ISO_NUMERIC_TO_ALPHA2 } from "../data/isoNumericToAlpha2"; import { ISO_NUMERIC_TO_ALPHA2 } from "../data/isoNumericToAlpha2";
import { getBanCountColor } from "../utils/mapColors"; import { getBanCountColor } from "../utils/mapColors";
@@ -29,9 +30,6 @@ const useStyles = makeStyles({
mapWrapper: { mapWrapper: {
width: "100%", width: "100%",
position: "relative", position: "relative",
backgroundColor: tokens.colorNeutralBackground2,
borderRadius: tokens.borderRadiusMedium,
border: `1px solid ${tokens.colorNeutralStroke1}`,
overflow: "hidden", overflow: "hidden",
}, },
countLabel: { countLabel: {
@@ -211,6 +209,7 @@ export function WorldMap({
thresholdHigh = 100, thresholdHigh = 100,
}: WorldMapProps): React.JSX.Element { }: WorldMapProps): React.JSX.Element {
const styles = useStyles(); const styles = useStyles();
const cardStyles = useCardStyles();
const [zoom, setZoom] = useState<number>(1); const [zoom, setZoom] = useState<number>(1);
const [center, setCenter] = useState<[number, number]>([0, 0]); const [center, setCenter] = useState<[number, number]>([0, 0]);
@@ -229,7 +228,7 @@ export function WorldMap({
return ( return (
<div <div
className={styles.mapWrapper} className={`${cardStyles.card} ${styles.mapWrapper}`}
role="img" role="img"
aria-label="World map showing banned IP counts by country. Click a country to filter the table below." aria-label="World map showing banned IP counts by country. Click a country to filter the table below."
> >

View File

@@ -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 (
<div className={sectionStyles.section}>
<div className={sectionStyles.sectionHeader}>
<Text size={500} weight="semibold">
Import Log
</Text>
<Button icon={<ArrowClockwiseRegular />} appearance="secondary" onClick={refresh}>
Refresh
</Button>
</div>
{error && (
<MessageBar intent="error">
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
)}
{loading ? (
<div className={styles.centred}>
<Spinner label="Loading log…" />
</div>
) : !data || data.items.length === 0 ? (
<div className={styles.centred}>
<Text>No import runs recorded yet.</Text>
</div>
) : (
<>
<div className={styles.tableWrapper}>
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Timestamp</TableHeaderCell>
<TableHeaderCell>Source URL</TableHeaderCell>
<TableHeaderCell>Imported</TableHeaderCell>
<TableHeaderCell>Skipped</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{data.items.map((entry) => (
<TableRow key={entry.id} className={entry.errors ? styles.errorRow : undefined}>
<TableCell>
<TableCellLayout>
<span className={styles.mono}>{entry.timestamp}</span>
</TableCellLayout>
</TableCell>
<TableCell>
<TableCellLayout>
<span className={styles.mono}>{entry.source_url}</span>
</TableCellLayout>
</TableCell>
<TableCell>
<TableCellLayout>{entry.ips_imported}</TableCellLayout>
</TableCell>
<TableCell>
<TableCellLayout>{entry.ips_skipped}</TableCellLayout>
</TableCell>
<TableCell>
<TableCellLayout>
{entry.errors ? (
<Badge appearance="filled" color="danger">
Error
</Badge>
) : (
<Badge appearance="filled" color="success">
OK
</Badge>
)}
</TableCellLayout>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{data.total_pages > 1 && (
<div className={styles.pagination}>
<Button size="small" appearance="secondary" disabled={page <= 1} onClick={() => { setPage(page - 1); }}>
Previous
</Button>
<Text size={200}>
Page {page} of {data.total_pages}
</Text>
<Button size="small" appearance="secondary" disabled={page >= data.total_pages} onClick={() => { setPage(page + 1); }}>
Next
</Button>
</div>
)}
</>
)}
</div>
);
}

View File

@@ -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<ScheduleFrequency, string> = {
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<string | null>(null);
const config = info?.config ?? {
frequency: "daily" as ScheduleFrequency,
interval_hours: 24,
hour: 3,
minute: 0,
day_of_week: 0,
};
const [draft, setDraft] = useState<ScheduleConfig>(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 (
<div className={sectionStyles.section}>
<div className={sectionStyles.sectionHeader}>
<Text size={500} weight="semibold">
Import Schedule
</Text>
<Button icon={<PlayRegular />} appearance="secondary" onClick={onRunImport} disabled={runImportRunning}>
{runImportRunning ? <Spinner size="tiny" /> : "Run Now"}
</Button>
</div>
{error && (
<MessageBar intent="error">
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
)}
{saveMsg && (
<MessageBar intent={saveMsg === "Schedule saved." ? "success" : "error"}>
<MessageBarBody>{saveMsg}</MessageBarBody>
</MessageBar>
)}
{loading ? (
<div className={styles.centred}>
<Spinner label="Loading schedule…" />
</div>
) : (
<>
<div className={styles.scheduleForm}>
<Field label="Frequency" className={styles.scheduleField}>
<Select
value={draft.frequency}
onChange={(_ev, d) => { setDraft((p) => ({ ...p, frequency: d.value as ScheduleFrequency })); }}
>
{(["hourly", "daily", "weekly"] as ScheduleFrequency[]).map((f) => (
<option key={f} value={f}>
{FREQUENCY_LABELS[f]}
</option>
))}
</Select>
</Field>
{draft.frequency === "hourly" && (
<Field label="Every (hours)" className={styles.scheduleField}>
<Input
type="number"
value={String(draft.interval_hours)}
onChange={(_ev, d) => { setDraft((p) => ({ ...p, interval_hours: Math.max(1, parseInt(d.value, 10) || 1) })); }}
min={1}
max={168}
/>
</Field>
)}
{draft.frequency !== "hourly" && (
<>
{draft.frequency === "weekly" && (
<Field label="Day of week" className={styles.scheduleField}>
<Select
value={String(draft.day_of_week)}
onChange={(_ev, d) => { setDraft((p) => ({ ...p, day_of_week: parseInt(d.value, 10) })); }}
>
{DAYS.map((day, i) => (
<option key={day} value={i}>
{day}
</option>
))}
</Select>
</Field>
)}
<Field label="Hour (UTC)" className={styles.scheduleField}>
<Select
value={String(draft.hour)}
onChange={(_ev, d) => { setDraft((p) => ({ ...p, hour: parseInt(d.value, 10) })); }}
>
{Array.from({ length: 24 }, (_, i) => (
<option key={i} value={i}>
{String(i).padStart(2, "0")}:00
</option>
))}
</Select>
</Field>
<Field label="Minute" className={styles.scheduleField}>
<Select
value={String(draft.minute)}
onChange={(_ev, d) => { setDraft((p) => ({ ...p, minute: parseInt(d.value, 10) })); }}
>
{[0, 15, 30, 45].map((m) => (
<option key={m} value={m}>
{String(m).padStart(2, "0")}
</option>
))}
</Select>
</Field>
</>
)}
<Button appearance="primary" onClick={handleSave} disabled={saving} style={{ alignSelf: "flex-end" }}>
{saving ? <Spinner size="tiny" /> : "Save Schedule"}
</Button>
</div>
<div className={styles.metaRow}>
<div className={styles.metaItem}>
<Text size={200} weight="semibold">
Last run
</Text>
<Text size={200}>{info?.last_run_at ?? "Never"}</Text>
</div>
<div className={styles.metaItem}>
<Text size={200} weight="semibold">
Next run
</Text>
<Text size={200}>{info?.next_run_at ?? "Not scheduled"}</Text>
</div>
</div>
</>
)}
</div>
);
}

View File

@@ -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<SourceFormValues>(initial);
const handleOpen = useCallback((): void => {
setValues(initial);
}, [initial]);
return (
<Dialog
open={open}
onOpenChange={(_ev, data) => {
if (!data.open) onClose();
}}
>
<DialogSurface onAnimationEnd={open ? handleOpen : undefined}>
<DialogBody>
<DialogTitle>{mode === "add" ? "Add Blocklist Source" : "Edit Blocklist Source"}</DialogTitle>
<DialogContent>
<div className={styles.dialogForm}>
{error && (
<MessageBar intent="error">
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
)}
<Field label="Name" required>
<Input
value={values.name}
onChange={(_ev, d) => { setValues((p) => ({ ...p, name: d.value })); }}
placeholder="e.g. Blocklist.de — All"
/>
</Field>
<Field label="URL" required>
<Input
value={values.url}
onChange={(_ev, d) => { setValues((p) => ({ ...p, url: d.value })); }}
placeholder="https://lists.blocklist.de/lists/all.txt"
/>
</Field>
<Switch
label="Enabled"
checked={values.enabled}
onChange={(_ev, d) => { setValues((p) => ({ ...p, enabled: d.checked })); }}
/>
</div>
</DialogContent>
<DialogActions>
<Button appearance="secondary" onClick={onClose} disabled={saving}>
Cancel
</Button>
<Button
appearance="primary"
disabled={saving || !values.name.trim() || !values.url.trim()}
onClick={() => { onSubmit(values); }}
>
{saving ? <Spinner size="tiny" /> : mode === "add" ? "Add" : "Save"}
</Button>
</DialogActions>
</DialogBody>
</DialogSurface>
</Dialog>
);
}
interface PreviewDialogProps {
open: boolean;
source: BlocklistSource | null;
onClose: () => void;
fetchPreview: (id: number) => Promise<PreviewResponse>;
}
function PreviewDialog({ open, source, onClose, fetchPreview }: PreviewDialogProps): React.JSX.Element {
const styles = useBlocklistStyles();
const [data, setData] = useState<PreviewResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<Dialog
open={open}
onOpenChange={(_ev, d) => {
if (!d.open) onClose();
}}
>
<DialogSurface onAnimationEnd={open ? handleOpen : undefined}>
<DialogBody>
<DialogTitle>Preview {source?.name ?? ""}</DialogTitle>
<DialogContent>
{loading && (
<div style={{ textAlign: "center", padding: "16px" }}>
<Spinner label="Downloading…" />
</div>
)}
{error && (
<MessageBar intent="error">
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
)}
{data && (
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
<Text size={300}>
{data.valid_count} valid IPs / {data.skipped_count} skipped of {data.total_lines} total lines. Showing first {data.entries.length}:
</Text>
<div className={styles.previewList}>
{data.entries.map((entry) => (
<div key={entry}>{entry}</div>
))}
</div>
</div>
)}
</DialogContent>
<DialogActions>
<Button appearance="secondary" onClick={onClose}>
Close
</Button>
</DialogActions>
</DialogBody>
</DialogSurface>
</Dialog>
);
}
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<SourceFormValues>(EMPTY_SOURCE);
const [editingId, setEditingId] = useState<number | null>(null);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [previewOpen, setPreviewOpen] = useState(false);
const [previewSourceItem, setPreviewSourceItem] = useState<BlocklistSource | null>(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 (
<div className={sectionStyles.section}>
<div className={sectionStyles.sectionHeader}>
<Text size={500} weight="semibold">
Blocklist Sources
</Text>
<div style={{ display: "flex", gap: "8px" }}>
<Button icon={<PlayRegular />} appearance="secondary" onClick={onRunImport} disabled={runImportRunning}>
{runImportRunning ? <Spinner size="tiny" /> : "Run Now"}
</Button>
<Button icon={<ArrowClockwiseRegular />} appearance="secondary" onClick={refresh}>
Refresh
</Button>
<Button icon={<AddRegular />} appearance="primary" onClick={openAdd}>
Add Source
</Button>
</div>
</div>
{error && (
<MessageBar intent="error">
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
)}
{loading ? (
<div className={styles.centred}>
<Spinner label="Loading sources…" />
</div>
) : sources.length === 0 ? (
<div className={styles.centred}>
<Text>No blocklist sources configured. Click "Add Source" to get started.</Text>
</div>
) : (
<div className={styles.tableWrapper}>
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>URL</TableHeaderCell>
<TableHeaderCell>Enabled</TableHeaderCell>
<TableHeaderCell>Actions</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{sources.map((source) => (
<TableRow key={source.id}>
<TableCell>
<TableCellLayout>{source.name}</TableCellLayout>
</TableCell>
<TableCell>
<TableCellLayout>
<span className={styles.mono}>{source.url}</span>
</TableCellLayout>
</TableCell>
<TableCell>
<Switch
checked={source.enabled}
onChange={() => { handleToggleEnabled(source); }}
label={source.enabled ? "On" : "Off"}
/>
</TableCell>
<TableCell>
<div className={styles.actionsCell}>
<Button
icon={<EyeRegular />}
size="small"
appearance="secondary"
onClick={() => { handlePreview(source); }}
>
Preview
</Button>
<Button
icon={<EditRegular />}
size="small"
appearance="secondary"
onClick={() => { openEdit(source); }}
>
Edit
</Button>
<Button
icon={<DeleteRegular />}
size="small"
appearance="secondary"
onClick={() => { handleDelete(source); }}
>
Delete
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
<SourceFormDialog
open={dialogOpen}
mode={dialogMode}
initial={dialogInitial}
saving={saving}
error={saveError}
onClose={() => { setDialogOpen(false); }}
onSubmit={handleSubmit}
/>
<PreviewDialog
open={previewOpen}
source={previewSourceItem}
onClose={() => { setPreviewOpen(false); }}
fetchPreview={previewSource}
/>
</div>
);
}

View File

@@ -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 },
});

View File

@@ -32,6 +32,7 @@ import {
type TableColumnDefinition, type TableColumnDefinition,
createTableColumn, createTableColumn,
} from "@fluentui/react-components"; } from "@fluentui/react-components";
import { useCommonSectionStyles } from "../../theme/commonStyles";
import { formatTimestamp } from "../../utils/formatDate"; import { formatTimestamp } from "../../utils/formatDate";
import { import {
ArrowClockwiseRegular, ArrowClockwiseRegular,
@@ -54,26 +55,6 @@ const PAGE_SIZE_OPTIONS = [10, 25, 50, 100] as const;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const useStyles = makeStyles({ const useStyles = makeStyles({
root: {
display: "flex",
flexDirection: "column",
gap: tokens.spacingVerticalS,
backgroundColor: tokens.colorNeutralBackground1,
borderRadius: tokens.borderRadiusMedium,
borderTopWidth: "1px",
borderTopStyle: "solid",
borderTopColor: tokens.colorNeutralStroke2,
borderRightWidth: "1px",
borderRightStyle: "solid",
borderRightColor: tokens.colorNeutralStroke2,
borderBottomWidth: "1px",
borderBottomStyle: "solid",
borderBottomColor: tokens.colorNeutralStroke2,
borderLeftWidth: "1px",
borderLeftStyle: "solid",
borderLeftColor: tokens.colorNeutralStroke2,
padding: tokens.spacingVerticalM,
},
header: { header: {
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
@@ -134,7 +115,7 @@ const useStyles = makeStyles({
/** A row item augmented with an `onUnban` callback for the row action. */ /** A row item augmented with an `onUnban` callback for the row action. */
interface BanRow { interface BanRow {
ban: ActiveBan; ban: ActiveBan;
onUnban: (ip: string) => void; onUnban: (ip: string) => Promise<void>;
} }
const columns: TableColumnDefinition<BanRow>[] = [ const columns: TableColumnDefinition<BanRow>[] = [
@@ -187,9 +168,7 @@ const columns: TableColumnDefinition<BanRow>[] = [
size="small" size="small"
appearance="subtle" appearance="subtle"
icon={<DismissRegular />} icon={<DismissRegular />}
onClick={() => { onClick={() => { void onUnban(ban.ip); }}
onUnban(ban.ip);
}}
aria-label={`Unban ${ban.ip}`} aria-label={`Unban ${ban.ip}`}
/> />
</Tooltip> </Tooltip>
@@ -214,8 +193,8 @@ export interface BannedIpsSectionProps {
onSearch: (term: string) => void; onSearch: (term: string) => void;
onPageChange: (page: number) => void; onPageChange: (page: number) => void;
onPageSizeChange: (size: number) => void; onPageSizeChange: (size: number) => void;
onRefresh: () => void; onRefresh: () => Promise<void>;
onUnban: (ip: string) => void; onUnban: (ip: string) => Promise<void>;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -243,6 +222,7 @@ export function BannedIpsSection({
onUnban, onUnban,
}: BannedIpsSectionProps): React.JSX.Element { }: BannedIpsSectionProps): React.JSX.Element {
const styles = useStyles(); const styles = useStyles();
const sectionStyles = useCommonSectionStyles();
const rows: BanRow[] = items.map((ban) => ({ const rows: BanRow[] = items.map((ban) => ({
ban, ban,
@@ -252,7 +232,7 @@ export function BannedIpsSection({
const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 1; const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 1;
return ( return (
<div className={styles.root}> <div className={sectionStyles.section}>
{/* Section header */} {/* Section header */}
<div className={styles.header}> <div className={styles.header}>
<div className={styles.headerLeft}> <div className={styles.headerLeft}>
@@ -265,7 +245,7 @@ export function BannedIpsSection({
size="small" size="small"
appearance="subtle" appearance="subtle"
icon={<ArrowClockwiseRegular />} icon={<ArrowClockwiseRegular />}
onClick={onRefresh} onClick={() => { void onRefresh(); }}
aria-label="Refresh banned IPs" aria-label="Refresh banned IPs"
/> />
</div> </div>

View File

@@ -0,0 +1,88 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useConfigItem } from "../useConfigItem";
describe("useConfigItem", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
it("loads data and sets loading state", async () => {
const fetchFn = vi.fn().mockResolvedValue("hello");
const saveFn = vi.fn().mockResolvedValue(undefined);
const { result } = renderHook(() => useConfigItem<string, string>({ fetchFn, saveFn }));
expect(result.current.loading).toBe(true);
await act(async () => {
await Promise.resolve();
});
expect(fetchFn).toHaveBeenCalled();
expect(result.current.data).toBe("hello");
expect(result.current.loading).toBe(false);
});
it("sets error if fetch rejects", async () => {
const fetchFn = vi.fn().mockRejectedValue(new Error("nope"));
const saveFn = vi.fn().mockResolvedValue(undefined);
const { result } = renderHook(() => useConfigItem<string, string>({ fetchFn, saveFn }));
await act(async () => {
await Promise.resolve();
});
expect(result.current.error).toBe("nope");
expect(result.current.loading).toBe(false);
});
it("save updates data when mergeOnSave is provided", async () => {
const fetchFn = vi.fn().mockResolvedValue({ value: 1 });
const saveFn = vi.fn().mockResolvedValue(undefined);
const { result } = renderHook(() =>
useConfigItem<{ value: number }, { delta: number }>({
fetchFn,
saveFn,
mergeOnSave: (prev, update) =>
prev ? { ...prev, value: prev.value + update.delta } : prev,
})
);
await act(async () => {
await Promise.resolve();
});
expect(result.current.data).toEqual({ value: 1 });
await act(async () => {
await result.current.save({ delta: 2 });
});
expect(saveFn).toHaveBeenCalledWith({ delta: 2 });
expect(result.current.data).toEqual({ value: 3 });
});
it("saveError is set when save fails", async () => {
const fetchFn = vi.fn().mockResolvedValue("ok");
const saveFn = vi.fn().mockRejectedValue(new Error("save failed"));
const { result } = renderHook(() => useConfigItem<string, string>({ fetchFn, saveFn }));
await act(async () => {
await Promise.resolve();
});
await act(async () => {
await expect(result.current.save("test")).rejects.toThrow("save failed");
});
expect(result.current.saveError).toBe("save failed");
});
});

View File

@@ -2,7 +2,7 @@
* React hook for loading and updating a single parsed action config. * React hook for loading and updating a single parsed action config.
*/ */
import { useCallback, useEffect, useRef, useState } from "react"; import { useConfigItem } from "./useConfigItem";
import { fetchAction, updateAction } from "../api/config"; import { fetchAction, updateAction } from "../api/config";
import type { ActionConfig, ActionConfigUpdate } from "../types/config"; import type { ActionConfig, ActionConfigUpdate } from "../types/config";
@@ -23,67 +23,28 @@ export interface UseActionConfigResult {
* @param name - Action base name (e.g. ``"iptables"``). * @param name - Action base name (e.g. ``"iptables"``).
*/ */
export function useActionConfig(name: string): UseActionConfigResult { export function useActionConfig(name: string): UseActionConfigResult {
const [config, setConfig] = useState<ActionConfig | null>(null); const { data, loading, error, saving, saveError, refresh, save } = useConfigItem<
const [loading, setLoading] = useState(true); ActionConfig,
const [error, setError] = useState<string | null>(null); ActionConfigUpdate
const [saving, setSaving] = useState(false); >({
const [saveError, setSaveError] = useState<string | null>(null); fetchFn: () => fetchAction(name),
const abortRef = useRef<AbortController | null>(null); saveFn: (update) => updateAction(name, update),
mergeOnSave: (prev, update) =>
prev
? {
...prev,
...Object.fromEntries(Object.entries(update).filter(([, v]) => v != null)),
}
: prev,
});
const load = useCallback((): void => { return {
abortRef.current?.abort(); config: data,
const ctrl = new AbortController(); loading,
abortRef.current = ctrl; error,
setLoading(true); saving,
setError(null); saveError,
refresh,
fetchAction(name) save,
.then((data) => { };
if (!ctrl.signal.aborted) {
setConfig(data);
setLoading(false);
}
})
.catch((err: unknown) => {
if (!ctrl.signal.aborted) {
setError(err instanceof Error ? err.message : "Failed to load action config");
setLoading(false);
}
});
}, [name]);
useEffect(() => {
load();
return (): void => {
abortRef.current?.abort();
};
}, [load]);
const save = useCallback(
async (update: ActionConfigUpdate): Promise<void> => {
setSaving(true);
setSaveError(null);
try {
await updateAction(name, update);
setConfig((prev) =>
prev
? {
...prev,
...Object.fromEntries(
Object.entries(update).filter(([, v]) => v !== null && v !== undefined)
),
}
: prev
);
} catch (err: unknown) {
setSaveError(err instanceof Error ? err.message : "Failed to save action config");
throw err;
} finally {
setSaving(false);
}
},
[name]
);
return { config, loading, error, saving, saveError, refresh: load, save };
} }

View File

@@ -7,6 +7,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { fetchBanTrend } from "../api/dashboard"; import { fetchBanTrend } from "../api/dashboard";
import { handleFetchError } from "../utils/fetchError";
import type { BanTrendBucket, BanOriginFilter, TimeRange } from "../types/ban"; import type { BanTrendBucket, BanOriginFilter, TimeRange } from "../types/ban";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -65,7 +66,7 @@ export function useBanTrend(
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (controller.signal.aborted) return; if (controller.signal.aborted) return;
setError(err instanceof Error ? err.message : "Failed to fetch trend data"); handleFetchError(err, setError, "Failed to fetch trend data");
}) })
.finally(() => { .finally(() => {
if (!controller.signal.aborted) { if (!controller.signal.aborted) {

View File

@@ -7,6 +7,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { fetchBans } from "../api/dashboard"; import { fetchBans } from "../api/dashboard";
import { handleFetchError } from "../utils/fetchError";
import type { DashboardBanItem, TimeRange, BanOriginFilter } from "../types/ban"; import type { DashboardBanItem, TimeRange, BanOriginFilter } from "../types/ban";
/** Items per page for the ban table. */ /** Items per page for the ban table. */
@@ -63,7 +64,7 @@ export function useBans(
setBanItems(data.items); setBanItems(data.items);
setTotal(data.total); setTotal(data.total);
} catch (err: unknown) { } catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to fetch data"); handleFetchError(err, setError, "Failed to fetch bans");
} finally { } finally {
setLoading(false); setLoading(false);
} }

View File

@@ -14,12 +14,14 @@ import {
updateBlocklist, updateBlocklist,
updateSchedule, updateSchedule,
} from "../api/blocklist"; } from "../api/blocklist";
import { handleFetchError } from "../utils/fetchError";
import type { import type {
BlocklistSource, BlocklistSource,
BlocklistSourceCreate, BlocklistSourceCreate,
BlocklistSourceUpdate, BlocklistSourceUpdate,
ImportLogListResponse, ImportLogListResponse,
ImportRunResult, ImportRunResult,
PreviewResponse,
ScheduleConfig, ScheduleConfig,
ScheduleInfo, ScheduleInfo,
} from "../types/blocklist"; } from "../types/blocklist";
@@ -65,7 +67,7 @@ export function useBlocklists(): UseBlocklistsReturn {
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (!ctrl.signal.aborted) { if (!ctrl.signal.aborted) {
setError(err instanceof Error ? err.message : "Failed to load blocklists"); handleFetchError(err, setError, "Failed to load blocklists");
setLoading(false); setLoading(false);
} }
}); });
@@ -144,7 +146,7 @@ export function useSchedule(): UseScheduleReturn {
setLoading(false); setLoading(false);
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
setError(err instanceof Error ? err.message : "Failed to load schedule"); handleFetchError(err, setError, "Failed to load schedule");
setLoading(false); setLoading(false);
}); });
}, []); }, []);
@@ -200,7 +202,7 @@ export function useImportLog(
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (!ctrl.signal.aborted) { if (!ctrl.signal.aborted) {
setError(err instanceof Error ? err.message : "Failed to load import log"); handleFetchError(err, setError, "Failed to load import log");
setLoading(false); setLoading(false);
} }
}); });
@@ -242,7 +244,7 @@ export function useRunImport(): UseRunImportReturn {
const result = await runImportNow(); const result = await runImportNow();
setLastResult(result); setLastResult(result);
} catch (err: unknown) { } catch (err: unknown) {
setError(err instanceof Error ? err.message : "Import failed"); handleFetchError(err, setError, "Import failed");
} finally { } finally {
setRunning(false); setRunning(false);
} }

View File

@@ -12,11 +12,13 @@ import {
flushLogs, flushLogs,
previewLog, previewLog,
reloadConfig, reloadConfig,
restartFail2Ban,
testRegex, testRegex,
updateGlobalConfig, updateGlobalConfig,
updateJailConfig, updateJailConfig,
updateServerSettings, updateServerSettings,
} from "../api/config"; } from "../api/config";
import { handleFetchError } from "../utils/fetchError";
import type { import type {
AddLogPathRequest, AddLogPathRequest,
GlobalConfig, GlobalConfig,
@@ -65,9 +67,7 @@ export function useJailConfigs(): UseJailConfigsResult {
setTotal(resp.total); setTotal(resp.total);
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (err instanceof Error && err.name !== "AbortError") { handleFetchError(err, setError, "Failed to fetch jail configs");
setError(err.message);
}
}) })
.finally(() => { .finally(() => {
setLoading(false); setLoading(false);
@@ -128,9 +128,7 @@ export function useJailConfigDetail(name: string): UseJailConfigDetailResult {
setJail(resp.jail); setJail(resp.jail);
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (err instanceof Error && err.name !== "AbortError") { handleFetchError(err, setError, "Failed to fetch jail config");
setError(err.message);
}
}) })
.finally(() => { .finally(() => {
setLoading(false); setLoading(false);
@@ -191,9 +189,7 @@ export function useGlobalConfig(): UseGlobalConfigResult {
fetchGlobalConfig() fetchGlobalConfig()
.then(setConfig) .then(setConfig)
.catch((err: unknown) => { .catch((err: unknown) => {
if (err instanceof Error && err.name !== "AbortError") { handleFetchError(err, setError, "Failed to fetch global config");
setError(err.message);
}
}) })
.finally(() => { .finally(() => {
setLoading(false); setLoading(false);
@@ -251,9 +247,7 @@ export function useServerSettings(): UseServerSettingsResult {
setSettings(resp.settings); setSettings(resp.settings);
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (err instanceof Error && err.name !== "AbortError") { handleFetchError(err, setError, "Failed to fetch server settings");
setError(err.message);
}
}) })
.finally(() => { .finally(() => {
setLoading(false); setLoading(false);

View File

@@ -13,6 +13,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { fetchJails } from "../api/jails"; import { fetchJails } from "../api/jails";
import { fetchJailConfigs } from "../api/config"; import { fetchJailConfigs } from "../api/config";
import { handleFetchError } from "../utils/fetchError";
import type { JailConfig } from "../types/config"; import type { JailConfig } from "../types/config";
import type { JailSummary } from "../types/jail"; import type { JailSummary } from "../types/jail";
@@ -110,7 +111,7 @@ export function useConfigActiveStatus(): UseConfigActiveStatusResult {
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (ctrl.signal.aborted) return; if (ctrl.signal.aborted) return;
setError(err instanceof Error ? err.message : "Failed to load status."); handleFetchError(err, setError, "Failed to load active status.");
setLoading(false); setLoading(false);
}); });
}, []); }, []);

View File

@@ -0,0 +1,85 @@
/**
* Generic config hook for loading and saving a single entity.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { handleFetchError } from "../utils/fetchError";
export interface UseConfigItemResult<T, U> {
data: T | null;
loading: boolean;
error: string | null;
saving: boolean;
saveError: string | null;
refresh: () => void;
save: (update: U) => Promise<void>;
}
export interface UseConfigItemOptions<T, U> {
fetchFn: (signal: AbortSignal) => Promise<T>;
saveFn: (update: U) => Promise<void>;
mergeOnSave?: (prev: T | null, update: U) => T | null;
}
export function useConfigItem<T, U>(
options: UseConfigItemOptions<T, U>
): UseConfigItemResult<T, U> {
const { fetchFn, saveFn, mergeOnSave } = options;
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const refresh = useCallback((): void => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setLoading(true);
setError(null);
fetchFn(controller.signal)
.then((nextData) => {
if (controller.signal.aborted) return;
setData(nextData);
setLoading(false);
})
.catch((err: unknown) => {
if (controller.signal.aborted) return;
handleFetchError(err, setError, "Failed to load data");
setLoading(false);
});
}, [fetchFn]);
useEffect(() => {
refresh();
return (): void => {
abortRef.current?.abort();
};
}, [refresh]);
const save = useCallback(
async (update: U): Promise<void> => {
setSaving(true);
setSaveError(null);
try {
await saveFn(update);
if (mergeOnSave) {
setData((prevData) => mergeOnSave(prevData, update));
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Failed to save data";
setSaveError(message);
throw err;
} finally {
setSaving(false);
}
},
[saveFn, mergeOnSave]
);
return { data, loading, error, saving, saveError, refresh, save };
}

View File

@@ -9,6 +9,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { fetchBansByCountry } from "../api/map"; import { fetchBansByCountry } from "../api/map";
import { handleFetchError } from "../utils/fetchError";
import type { DashboardBanItem, BanOriginFilter, TimeRange } from "../types/ban"; import type { DashboardBanItem, BanOriginFilter, TimeRange } from "../types/ban";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -77,7 +78,7 @@ export function useDashboardCountryData(
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (controller.signal.aborted) return; if (controller.signal.aborted) return;
setError(err instanceof Error ? err.message : "Failed to fetch data"); handleFetchError(err, setError, "Failed to fetch dashboard country data");
}) })
.finally(() => { .finally(() => {
if (!controller.signal.aborted) { if (!controller.signal.aborted) {

View File

@@ -2,7 +2,7 @@
* React hook for loading and updating a single parsed filter config. * React hook for loading and updating a single parsed filter config.
*/ */
import { useCallback, useEffect, useRef, useState } from "react"; import { useConfigItem } from "./useConfigItem";
import { fetchParsedFilter, updateParsedFilter } from "../api/config"; import { fetchParsedFilter, updateParsedFilter } from "../api/config";
import type { FilterConfig, FilterConfigUpdate } from "../types/config"; import type { FilterConfig, FilterConfigUpdate } from "../types/config";
@@ -23,69 +23,28 @@ export interface UseFilterConfigResult {
* @param name - Filter base name (e.g. ``"sshd"``). * @param name - Filter base name (e.g. ``"sshd"``).
*/ */
export function useFilterConfig(name: string): UseFilterConfigResult { export function useFilterConfig(name: string): UseFilterConfigResult {
const [config, setConfig] = useState<FilterConfig | null>(null); const { data, loading, error, saving, saveError, refresh, save } = useConfigItem<
const [loading, setLoading] = useState(true); FilterConfig,
const [error, setError] = useState<string | null>(null); FilterConfigUpdate
const [saving, setSaving] = useState(false); >({
const [saveError, setSaveError] = useState<string | null>(null); fetchFn: () => fetchParsedFilter(name),
const abortRef = useRef<AbortController | null>(null); saveFn: (update) => updateParsedFilter(name, update),
mergeOnSave: (prev, update) =>
prev
? {
...prev,
...Object.fromEntries(Object.entries(update).filter(([, v]) => v != null)),
}
: prev,
});
const load = useCallback((): void => { return {
abortRef.current?.abort(); config: data,
const ctrl = new AbortController(); loading,
abortRef.current = ctrl; error,
setLoading(true); saving,
setError(null); saveError,
refresh,
fetchParsedFilter(name) save,
.then((data) => { };
if (!ctrl.signal.aborted) {
setConfig(data);
setLoading(false);
}
})
.catch((err: unknown) => {
if (!ctrl.signal.aborted) {
setError(err instanceof Error ? err.message : "Failed to load filter config");
setLoading(false);
}
});
}, [name]);
useEffect(() => {
load();
return (): void => {
abortRef.current?.abort();
};
}, [load]);
const save = useCallback(
async (update: FilterConfigUpdate): Promise<void> => {
setSaving(true);
setSaveError(null);
try {
await updateParsedFilter(name, update);
// Optimistically update local state so the form reflects changes
// without a full reload.
setConfig((prev) =>
prev
? {
...prev,
...Object.fromEntries(
Object.entries(update).filter(([, v]) => v !== null && v !== undefined)
),
}
: prev
);
} catch (err: unknown) {
setSaveError(err instanceof Error ? err.message : "Failed to save filter config");
throw err;
} finally {
setSaving(false);
}
},
[name]
);
return { config, loading, error, saving, saveError, refresh: load, save };
} }

View File

@@ -4,6 +4,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { fetchHistory, fetchIpHistory } from "../api/history"; import { fetchHistory, fetchIpHistory } from "../api/history";
import { handleFetchError } from "../utils/fetchError";
import type { import type {
HistoryBanItem, HistoryBanItem,
HistoryQuery, HistoryQuery,
@@ -44,9 +45,7 @@ export function useHistory(query: HistoryQuery = {}): UseHistoryResult {
setTotal(resp.total); setTotal(resp.total);
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (err instanceof Error && err.name !== "AbortError") { handleFetchError(err, setError, "Failed to fetch history");
setError(err.message);
}
}) })
.finally((): void => { .finally((): void => {
setLoading(false); setLoading(false);
@@ -91,9 +90,7 @@ export function useIpHistory(ip: string): UseIpHistoryResult {
setDetail(resp); setDetail(resp);
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (err instanceof Error && err.name !== "AbortError") { handleFetchError(err, setError, "Failed to fetch IP history");
setError(err.message);
}
}) })
.finally((): void => { .finally((): void => {
setLoading(false); setLoading(false);

View File

@@ -7,6 +7,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { fetchBansByJail } from "../api/dashboard"; import { fetchBansByJail } from "../api/dashboard";
import { handleFetchError } from "../utils/fetchError";
import type { BanOriginFilter, JailBanCount, TimeRange } from "../types/ban"; import type { BanOriginFilter, JailBanCount, TimeRange } from "../types/ban";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -65,9 +66,7 @@ export function useJailDistribution(
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (controller.signal.aborted) return; if (controller.signal.aborted) return;
setError( handleFetchError(err, setError, "Failed to fetch jail distribution");
err instanceof Error ? err.message : "Failed to fetch jail distribution",
);
}) })
.finally(() => { .finally(() => {
if (!controller.signal.aborted) { if (!controller.signal.aborted) {

View File

@@ -2,7 +2,7 @@
* React hook for loading and updating a single parsed jail.d config file. * React hook for loading and updating a single parsed jail.d config file.
*/ */
import { useCallback, useEffect, useRef, useState } from "react"; import { useConfigItem } from "./useConfigItem";
import { fetchParsedJailFile, updateParsedJailFile } from "../api/config"; import { fetchParsedJailFile, updateParsedJailFile } from "../api/config";
import type { JailFileConfig, JailFileConfigUpdate } from "../types/config"; import type { JailFileConfig, JailFileConfigUpdate } from "../types/config";
@@ -21,56 +21,23 @@ export interface UseJailFileConfigResult {
* @param filename - Filename including extension (e.g. ``"sshd.conf"``). * @param filename - Filename including extension (e.g. ``"sshd.conf"``).
*/ */
export function useJailFileConfig(filename: string): UseJailFileConfigResult { export function useJailFileConfig(filename: string): UseJailFileConfigResult {
const [config, setConfig] = useState<JailFileConfig | null>(null); const { data, loading, error, refresh, save } = useConfigItem<
const [loading, setLoading] = useState(true); JailFileConfig,
const [error, setError] = useState<string | null>(null); JailFileConfigUpdate
const abortRef = useRef<AbortController | null>(null); >({
fetchFn: () => fetchParsedJailFile(filename),
saveFn: (update) => updateParsedJailFile(filename, update),
mergeOnSave: (prev, update) =>
update.jails != null && prev
? { ...prev, jails: { ...prev.jails, ...update.jails } }
: prev,
});
const load = useCallback((): void => { return {
abortRef.current?.abort(); config: data,
const ctrl = new AbortController(); loading,
abortRef.current = ctrl; error,
setLoading(true); refresh,
setError(null); save,
};
fetchParsedJailFile(filename)
.then((data) => {
if (!ctrl.signal.aborted) {
setConfig(data);
setLoading(false);
}
})
.catch((err: unknown) => {
if (!ctrl.signal.aborted) {
setError(err instanceof Error ? err.message : "Failed to load jail file config");
setLoading(false);
}
});
}, [filename]);
useEffect(() => {
load();
return (): void => {
abortRef.current?.abort();
};
}, [load]);
const save = useCallback(
async (update: JailFileConfigUpdate): Promise<void> => {
try {
await updateParsedJailFile(filename, update);
// Optimistically merge updated jails into local state.
if (update.jails != null) {
setConfig((prev) =>
prev ? { ...prev, jails: { ...prev.jails, ...update.jails } } : prev
);
}
} catch (err: unknown) {
throw err instanceof Error ? err : new Error("Failed to save jail file config");
}
},
[filename]
);
return { config, loading, error, refresh: load, save };
} }

View File

@@ -7,6 +7,7 @@
*/ */
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { handleFetchError } from "../utils/fetchError";
import { import {
addIgnoreIp, addIgnoreIp,
banIp, banIp,
@@ -92,7 +93,7 @@ export function useJails(): UseJailsResult {
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (!ctrl.signal.aborted) { if (!ctrl.signal.aborted) {
setError(err instanceof Error ? err.message : String(err)); handleFetchError(err, setError, "Failed to load jails");
} }
}) })
.finally(() => { .finally(() => {
@@ -195,7 +196,7 @@ export function useJailDetail(name: string): UseJailDetailResult {
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (!ctrl.signal.aborted) { if (!ctrl.signal.aborted) {
setError(err instanceof Error ? err.message : String(err)); handleFetchError(err, setError, "Failed to fetch jail detail");
} }
}) })
.finally(() => { .finally(() => {
@@ -309,7 +310,7 @@ export function useJailBannedIps(jailName: string): UseJailBannedIpsResult {
setItems(resp.items); setItems(resp.items);
setTotal(resp.total); setTotal(resp.total);
} catch (err: unknown) { } catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err)); handleFetchError(err, setError, "Failed to fetch jailed IPs");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -415,7 +416,7 @@ export function useActiveBans(): UseActiveBansResult {
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (!ctrl.signal.aborted) { if (!ctrl.signal.aborted) {
setError(err instanceof Error ? err.message : String(err)); handleFetchError(err, setError, "Failed to fetch active bans");
} }
}) })
.finally(() => { .finally(() => {
@@ -496,7 +497,7 @@ export function useIpLookup(): UseIpLookupResult {
setResult(res); setResult(res);
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
setError(err instanceof Error ? err.message : String(err)); handleFetchError(err, setError, "Failed to lookup IP");
}) })
.finally(() => { .finally(() => {
setLoading(false); setLoading(false);

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { fetchMapColorThresholds, updateMapColorThresholds } from "../api/config"; import { fetchMapColorThresholds, updateMapColorThresholds } from "../api/config";
import { handleFetchError } from "../utils/fetchError";
import type { import type {
MapColorThresholdsResponse, MapColorThresholdsResponse,
MapColorThresholdsUpdate, MapColorThresholdsUpdate,
@@ -26,7 +27,7 @@ export function useMapColorThresholds(): UseMapColorThresholdsResult {
const data = await fetchMapColorThresholds(); const data = await fetchMapColorThresholds();
setThresholds(data); setThresholds(data);
} catch (err: unknown) { } catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to fetch map color thresholds"); handleFetchError(err, setError, "Failed to fetch map color thresholds");
} finally { } finally {
setLoading(false); setLoading(false);
} }

View File

@@ -4,6 +4,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { fetchBansByCountry } from "../api/map"; import { fetchBansByCountry } from "../api/map";
import { handleFetchError } from "../utils/fetchError";
import type { BansByCountryResponse, MapBanItem, TimeRange } from "../types/map"; import type { BansByCountryResponse, MapBanItem, TimeRange } from "../types/map";
import type { BanOriginFilter } from "../types/ban"; import type { BanOriginFilter } from "../types/ban";
@@ -68,9 +69,7 @@ export function useMapData(
setData(resp); setData(resp);
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (err instanceof Error && err.name !== "AbortError") { handleFetchError(err, setError, "Failed to fetch map data");
setError(err.message);
}
}) })
.finally((): void => { .finally((): void => {
setLoading(false); setLoading(false);

View File

@@ -8,6 +8,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { fetchServerStatus } from "../api/dashboard"; import { fetchServerStatus } from "../api/dashboard";
import { handleFetchError } from "../utils/fetchError";
import type { ServerStatus } from "../types/server"; import type { ServerStatus } from "../types/server";
/** How often to poll the status endpoint (milliseconds). */ /** How often to poll the status endpoint (milliseconds). */
@@ -45,7 +46,7 @@ export function useServerStatus(): UseServerStatusResult {
setStatus(data.status); setStatus(data.status);
setError(null); setError(null);
} catch (err: unknown) { } catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to fetch server status"); handleFetchError(err, setError, "Failed to fetch server status");
} finally { } finally {
setLoading(false); setLoading(false);
} }

View File

@@ -6,6 +6,7 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { ApiError } from "../api/client"; import { ApiError } from "../api/client";
import { handleFetchError } from "../utils/fetchError";
import { getSetupStatus, submitSetup } from "../api/setup"; import { getSetupStatus, submitSetup } from "../api/setup";
import type { import type {
SetupRequest, SetupRequest,
@@ -44,9 +45,11 @@ export function useSetup(): UseSetupResult {
const resp = await getSetupStatus(); const resp = await getSetupStatus();
setStatus(resp); setStatus(resp);
} catch (err: unknown) { } catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : "Failed to fetch setup status"; const fallback = "Failed to fetch setup status";
console.warn("Setup status check failed:", errorMessage); handleFetchError(err, setError, fallback);
setError(errorMessage); if (!(err instanceof DOMException && err.name === "AbortError")) {
console.warn("Setup status check failed:", err instanceof Error ? err.message : fallback);
}
} finally { } finally {
setLoading(false); setLoading(false);
} }

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { fetchTimezone } from "../api/setup"; import { fetchTimezone } from "../api/setup";
import { handleFetchError } from "../utils/fetchError";
export interface UseTimezoneDataResult { export interface UseTimezoneDataResult {
timezone: string; timezone: string;
@@ -21,7 +22,7 @@ export function useTimezoneData(): UseTimezoneDataResult {
const resp = await fetchTimezone(); const resp = await fetchTimezone();
setTimezone(resp.timezone); setTimezone(resp.timezone);
} catch (err: unknown) { } catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to fetch timezone"); handleFetchError(err, setError, "Failed to fetch timezone");
setTimezone("UTC"); setTimezone("UTC");
} finally { } finally {
setLoading(false); setLoading(false);

View File

@@ -1,341 +1,18 @@
/** /**
* BlocklistsPage — external IP blocklist source management. * BlocklistsPage — external IP blocklist source management.
* *
* Provides three sections: * Responsible for composition of sources, schedule, and import log 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.
*/ */
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { import { Button, MessageBar, MessageBarBody, Text } from "@fluentui/react-components";
Badge, import { useBlocklistStyles } from "../theme/commonStyles";
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 {
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 { BlocklistSourcesSection } from "../components/blocklist/BlocklistSourcesSection";
// Styles import { BlocklistScheduleSection } from "../components/blocklist/BlocklistScheduleSection";
// --------------------------------------------------------------------------- import { BlocklistImportLogSection } from "../components/blocklist/BlocklistImportLogSection";
import { useRunImport } from "../hooks/useBlocklist";
const useStyles = makeStyles({ import type { ImportRunResult } from "../types/blocklist";
root: {
display: "flex",
flexDirection: "column",
gap: tokens.spacingVerticalXL,
},
section: {
backgroundColor: tokens.colorNeutralBackground1,
borderRadius: tokens.borderRadiusMedium,
borderTopWidth: "1px",
borderTopStyle: "solid",
borderTopColor: tokens.colorNeutralStroke2,
borderRightWidth: "1px",
borderRightStyle: "solid",
borderRightColor: tokens.colorNeutralStroke2,
borderBottomWidth: "1px",
borderBottomStyle: "solid",
borderBottomColor: tokens.colorNeutralStroke2,
borderLeftWidth: "1px",
borderLeftStyle: "solid",
borderLeftColor: tokens.colorNeutralStroke2,
padding: tokens.spacingVerticalM,
display: "flex",
flexDirection: "column",
gap: tokens.spacingVerticalS,
},
sectionHeader: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
paddingBottom: tokens.spacingVerticalS,
borderBottomWidth: "1px",
borderBottomStyle: "solid",
borderBottomColor: tokens.colorNeutralStroke2,
},
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<SourceFormValues>(initial);
// Sync when dialog re-opens with new initial data.
const handleOpen = useCallback((): void => {
setValues(initial);
}, [initial]);
return (
<Dialog
open={open}
onOpenChange={(_ev, data) => {
if (!data.open) onClose();
}}
>
<DialogSurface onAnimationEnd={open ? handleOpen : undefined}>
<DialogBody>
<DialogTitle>{mode === "add" ? "Add Blocklist Source" : "Edit Blocklist Source"}</DialogTitle>
<DialogContent>
<div className={styles.dialogForm}>
{error && (
<MessageBar intent="error">
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
)}
<Field label="Name" required>
<Input
value={values.name}
onChange={(_ev, d) => {
setValues((p) => ({ ...p, name: d.value }));
}}
placeholder="e.g. Blocklist.de — All"
/>
</Field>
<Field label="URL" required>
<Input
value={values.url}
onChange={(_ev, d) => {
setValues((p) => ({ ...p, url: d.value }));
}}
placeholder="https://lists.blocklist.de/lists/all.txt"
/>
</Field>
<Switch
label="Enabled"
checked={values.enabled}
onChange={(_ev, d) => {
setValues((p) => ({ ...p, enabled: d.checked }));
}}
/>
</div>
</DialogContent>
<DialogActions>
<Button appearance="secondary" onClick={onClose} disabled={saving}>
Cancel
</Button>
<Button
appearance="primary"
disabled={saving || !values.name.trim() || !values.url.trim()}
onClick={() => {
onSubmit(values);
}}
>
{saving ? <Spinner size="tiny" /> : mode === "add" ? "Add" : "Save"}
</Button>
</DialogActions>
</DialogBody>
</DialogSurface>
</Dialog>
);
}
// ---------------------------------------------------------------------------
// Preview dialog
// ---------------------------------------------------------------------------
interface PreviewDialogProps {
open: boolean;
source: BlocklistSource | null;
onClose: () => void;
fetchPreview: (id: number) => Promise<PreviewResponse>;
}
function PreviewDialog({ open, source, onClose, fetchPreview }: PreviewDialogProps): React.JSX.Element {
const styles = useStyles();
const [data, setData] = useState<PreviewResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<Dialog
open={open}
onOpenChange={(_ev, d) => {
if (!d.open) onClose();
}}
>
<DialogSurface onAnimationEnd={open ? handleOpen : undefined}>
<DialogBody>
<DialogTitle>Preview {source?.name ?? ""}</DialogTitle>
<DialogContent>
{loading && (
<div style={{ textAlign: "center", padding: "16px" }}>
<Spinner label="Downloading…" />
</div>
)}
{error && (
<MessageBar intent="error">
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
)}
{data && (
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
<Text size={300}>
{data.valid_count} valid IPs / {data.skipped_count} skipped of{" "}
{data.total_lines} total lines. Showing first {data.entries.length}:
</Text>
<div className={styles.previewList}>
{data.entries.map((entry) => (
<div key={entry}>{entry}</div>
))}
</div>
</div>
)}
</DialogContent>
<DialogActions>
<Button appearance="secondary" onClick={onClose}>
Close
</Button>
</DialogActions>
</DialogBody>
</DialogSurface>
</Dialog>
);
}
// ---------------------------------------------------------------------------
// Import result dialog
// ---------------------------------------------------------------------------
interface ImportResultDialogProps { interface ImportResultDialogProps {
open: boolean; open: boolean;
@@ -344,592 +21,29 @@ interface ImportResultDialogProps {
} }
function ImportResultDialog({ open, result, onClose }: ImportResultDialogProps): React.JSX.Element { function ImportResultDialog({ open, result, onClose }: ImportResultDialogProps): React.JSX.Element {
const styles = useStyles(); if (!open || !result) return <></>;
if (!result) return <></>;
return ( return (
<Dialog <div style={{ position: "fixed", top: 0, left: 0, width: "100%", height: "100%", backgroundColor: "rgba(0, 0, 0, 0.5)", display: "flex", justifyContent: "center", alignItems: "center", zIndex: 1000 }}>
open={open} <div style={{ background: "white", padding: "24px", borderRadius: "8px", maxWidth: "520px", minWidth: "300px" }}>
onOpenChange={(_ev, d) => { <Text as="h2" size={500} weight="semibold">
if (!d.open) onClose(); Import Complete
}}
>
<DialogSurface>
<DialogBody>
<DialogTitle>Import Complete</DialogTitle>
<DialogContent>
<div className={styles.runResult}>
<Text size={300} weight="semibold">
Total imported: {result.total_imported} &nbsp;|&nbsp; Skipped:
{result.total_skipped} &nbsp;|&nbsp; Sources with errors: {result.errors_count}
</Text>
{result.results.map((r, i) => (
<div key={i} style={{ padding: "4px 0", borderBottom: "1px solid #eee" }}>
<Text size={200} weight="semibold">
{r.source_url}
</Text>
<br />
<Text size={200}>
Imported: {r.ips_imported} | Skipped: {r.ips_skipped}
{r.error ? ` | Error: ${r.error}` : ""}
</Text>
</div>
))}
</div>
</DialogContent>
<DialogActions>
<Button appearance="primary" onClick={onClose}>
Close
</Button>
</DialogActions>
</DialogBody>
</DialogSurface>
</Dialog>
);
}
// ---------------------------------------------------------------------------
// 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 { 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<SourceFormValues>(EMPTY_SOURCE);
const [editingId, setEditingId] = useState<number | null>(null);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [previewOpen, setPreviewOpen] = useState(false);
const [previewSourceItem, setPreviewSourceItem] = useState<BlocklistSource | null>(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 (
<div className={styles.section}>
<div className={styles.sectionHeader}>
<Text size={500} weight="semibold">
Blocklist Sources
</Text> </Text>
<div style={{ display: "flex", gap: "8px" }}> <Text size={200} style={{ marginTop: "12px" }}>
<Button Total imported: {result.total_imported} | Skipped: {result.total_skipped} | Sources with errors: {result.errors_count}
icon={<PlayRegular />} </Text>
appearance="secondary" <div style={{ marginTop: "16px", textAlign: "right" }}>
onClick={onRunImport} <Button appearance="primary" onClick={onClose}>
disabled={runImportRunning} Close
>
{runImportRunning ? <Spinner size="tiny" /> : "Run Now"}
</Button>
<Button icon={<ArrowClockwiseRegular />} appearance="secondary" onClick={refresh}>
Refresh
</Button>
<Button icon={<AddRegular />} appearance="primary" onClick={openAdd}>
Add Source
</Button> </Button>
</div> </div>
</div> </div>
{error && (
<MessageBar intent="error">
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
)}
{loading ? (
<div className={styles.centred}>
<Spinner label="Loading sources…" />
</div>
) : sources.length === 0 ? (
<div className={styles.centred}>
<Text>No blocklist sources configured. Click "Add Source" to get started.</Text>
</div>
) : (
<div className={styles.tableWrapper}>
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>URL</TableHeaderCell>
<TableHeaderCell>Enabled</TableHeaderCell>
<TableHeaderCell>Actions</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{sources.map((source) => (
<TableRow key={source.id}>
<TableCell>
<TableCellLayout>{source.name}</TableCellLayout>
</TableCell>
<TableCell>
<TableCellLayout>
<span className={styles.mono}>{source.url}</span>
</TableCellLayout>
</TableCell>
<TableCell>
<Switch
checked={source.enabled}
onChange={() => {
handleToggleEnabled(source);
}}
label={source.enabled ? "On" : "Off"}
/>
</TableCell>
<TableCell>
<div className={styles.actionsCell}>
<Button
icon={<EyeRegular />}
size="small"
appearance="secondary"
onClick={() => {
handlePreview(source);
}}
>
Preview
</Button>
<Button
icon={<EditRegular />}
size="small"
appearance="secondary"
onClick={() => {
openEdit(source);
}}
>
Edit
</Button>
<Button
icon={<DeleteRegular />}
size="small"
appearance="secondary"
onClick={() => {
handleDelete(source);
}}
>
Delete
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
<SourceFormDialog
open={dialogOpen}
mode={dialogMode}
initial={dialogInitial}
saving={saving}
error={saveError}
onClose={() => {
setDialogOpen(false);
}}
onSubmit={handleSubmit}
/>
<PreviewDialog
open={previewOpen}
source={previewSourceItem}
onClose={() => {
setPreviewOpen(false);
}}
fetchPreview={previewSource}
/>
</div> </div>
); );
} }
// ---------------------------------------------------------------------------
// Schedule section
// ---------------------------------------------------------------------------
const FREQUENCY_LABELS: Record<ScheduleFrequency, string> = {
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 { info, loading, error, saveSchedule } = useSchedule();
const [saving, setSaving] = useState(false);
const [saveMsg, setSaveMsg] = useState<string | null>(null);
const config = info?.config ?? {
frequency: "daily" as ScheduleFrequency,
interval_hours: 24,
hour: 3,
minute: 0,
day_of_week: 0,
};
const [draft, setDraft] = useState<ScheduleConfig>(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 (
<div className={styles.section}>
<div className={styles.sectionHeader}>
<Text size={500} weight="semibold">
Import Schedule
</Text>
<Button
icon={<PlayRegular />}
appearance="secondary"
onClick={onRunImport}
disabled={runImportRunning}
>
{runImportRunning ? <Spinner size="tiny" /> : "Run Now"}
</Button>
</div>
{error && (
<MessageBar intent="error">
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
)}
{saveMsg && (
<MessageBar intent={saveMsg === "Schedule saved." ? "success" : "error"}>
<MessageBarBody>{saveMsg}</MessageBarBody>
</MessageBar>
)}
{loading ? (
<div className={styles.centred}>
<Spinner label="Loading schedule…" />
</div>
) : (
<>
<div className={styles.scheduleForm}>
<Field label="Frequency" className={styles.scheduleField}>
<Select
value={draft.frequency}
onChange={(_ev, d) => {
setDraft((p) => ({ ...p, frequency: d.value as ScheduleFrequency }));
}}
>
{(["hourly", "daily", "weekly"] as ScheduleFrequency[]).map((f) => (
<option key={f} value={f}>
{FREQUENCY_LABELS[f]}
</option>
))}
</Select>
</Field>
{draft.frequency === "hourly" && (
<Field label="Every (hours)" className={styles.scheduleField}>
<Input
type="number"
value={String(draft.interval_hours)}
onChange={(_ev, d) => {
setDraft((p) => ({ ...p, interval_hours: Math.max(1, parseInt(d.value, 10) || 1) }));
}}
min={1}
max={168}
/>
</Field>
)}
{draft.frequency !== "hourly" && (
<>
{draft.frequency === "weekly" && (
<Field label="Day of week" className={styles.scheduleField}>
<Select
value={String(draft.day_of_week)}
onChange={(_ev, d) => {
setDraft((p) => ({ ...p, day_of_week: parseInt(d.value, 10) }));
}}
>
{DAYS.map((day, i) => (
<option key={day} value={i}>
{day}
</option>
))}
</Select>
</Field>
)}
<Field label="Hour (UTC)" className={styles.scheduleField}>
<Select
value={String(draft.hour)}
onChange={(_ev, d) => {
setDraft((p) => ({ ...p, hour: parseInt(d.value, 10) }));
}}
>
{Array.from({ length: 24 }, (_, i) => (
<option key={i} value={i}>
{String(i).padStart(2, "0")}:00
</option>
))}
</Select>
</Field>
<Field label="Minute" className={styles.scheduleField}>
<Select
value={String(draft.minute)}
onChange={(_ev, d) => {
setDraft((p) => ({ ...p, minute: parseInt(d.value, 10) }));
}}
>
{[0, 15, 30, 45].map((m) => (
<option key={m} value={m}>
{String(m).padStart(2, "0")}
</option>
))}
</Select>
</Field>
</>
)}
<Button
appearance="primary"
onClick={handleSave}
disabled={saving}
style={{ alignSelf: "flex-end" }}
>
{saving ? <Spinner size="tiny" /> : "Save Schedule"}
</Button>
</div>
<div className={styles.metaRow}>
<div className={styles.metaItem}>
<Text size={200} weight="semibold">
Last run
</Text>
<Text size={200}>{info?.last_run_at ?? "Never"}</Text>
</div>
<div className={styles.metaItem}>
<Text size={200} weight="semibold">
Next run
</Text>
<Text size={200}>{info?.next_run_at ?? "Not scheduled"}</Text>
</div>
</div>
</>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Import log section
// ---------------------------------------------------------------------------
function ImportLogSection(): React.JSX.Element {
const styles = useStyles();
const { data, loading, error, page, setPage, refresh } = useImportLog(undefined, 20);
return (
<div className={styles.section}>
<div className={styles.sectionHeader}>
<Text size={500} weight="semibold">
Import Log
</Text>
<Button icon={<ArrowClockwiseRegular />} appearance="secondary" onClick={refresh}>
Refresh
</Button>
</div>
{error && (
<MessageBar intent="error">
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
)}
{loading ? (
<div className={styles.centred}>
<Spinner label="Loading log…" />
</div>
) : !data || data.items.length === 0 ? (
<div className={styles.centred}>
<Text>No import runs recorded yet.</Text>
</div>
) : (
<>
<div className={styles.tableWrapper}>
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Timestamp</TableHeaderCell>
<TableHeaderCell>Source URL</TableHeaderCell>
<TableHeaderCell>Imported</TableHeaderCell>
<TableHeaderCell>Skipped</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{data.items.map((entry) => (
<TableRow
key={entry.id}
className={entry.errors ? styles.errorRow : undefined}
>
<TableCell>
<TableCellLayout>
<span className={styles.mono}>{entry.timestamp}</span>
</TableCellLayout>
</TableCell>
<TableCell>
<TableCellLayout>
<span className={styles.mono}>{entry.source_url}</span>
</TableCellLayout>
</TableCell>
<TableCell>
<TableCellLayout>{entry.ips_imported}</TableCellLayout>
</TableCell>
<TableCell>
<TableCellLayout>{entry.ips_skipped}</TableCellLayout>
</TableCell>
<TableCell>
<TableCellLayout>
{entry.errors ? (
<Badge appearance="filled" color="danger">
Error
</Badge>
) : (
<Badge appearance="filled" color="success">
OK
</Badge>
)}
</TableCellLayout>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{data.total_pages > 1 && (
<div className={styles.pagination}>
<Button
size="small"
appearance="secondary"
disabled={page <= 1}
onClick={() => {
setPage(page - 1);
}}
>
Previous
</Button>
<Text size={200}>
Page {page} of {data.total_pages}
</Text>
<Button
size="small"
appearance="secondary"
disabled={page >= data.total_pages}
onClick={() => {
setPage(page + 1);
}}
>
Next
</Button>
</div>
)}
</>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export function BlocklistsPage(): React.JSX.Element { 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 { running, lastResult, error: importError, runNow } = useRunImport();
const [importResultOpen, setImportResultOpen] = useState(false); const [importResultOpen, setImportResultOpen] = useState(false);
@@ -951,18 +65,15 @@ export function BlocklistsPage(): React.JSX.Element {
</MessageBar> </MessageBar>
)} )}
<SourcesSection onRunImport={handleRunImport} runImportRunning={running} /> <BlocklistSourcesSection onRunImport={handleRunImport} runImportRunning={running} />
<ScheduleSection onRunImport={handleRunImport} runImportRunning={running} /> <BlocklistScheduleSection onRunImport={handleRunImport} runImportRunning={running} />
<ImportLogSection /> <BlocklistImportLogSection />
<ImportResultDialog <ImportResultDialog
open={importResultOpen} open={importResultOpen}
result={lastResult} result={lastResult}
onClose={() => { onClose={() => { setImportResultOpen(false); }}
setImportResultOpen(false);
}}
/> />
</div> </div>
); );
} }

View File

@@ -15,6 +15,7 @@ import { DashboardFilterBar } from "../components/DashboardFilterBar";
import { ServerStatusBar } from "../components/ServerStatusBar"; import { ServerStatusBar } from "../components/ServerStatusBar";
import { TopCountriesBarChart } from "../components/TopCountriesBarChart"; import { TopCountriesBarChart } from "../components/TopCountriesBarChart";
import { TopCountriesPieChart } from "../components/TopCountriesPieChart"; import { TopCountriesPieChart } from "../components/TopCountriesPieChart";
import { useCommonSectionStyles } from "../theme/commonStyles";
import { useDashboardCountryData } from "../hooks/useDashboardCountryData"; import { useDashboardCountryData } from "../hooks/useDashboardCountryData";
import type { BanOriginFilter, TimeRange } from "../types/ban"; import type { BanOriginFilter, TimeRange } from "../types/ban";
@@ -29,26 +30,6 @@ const useStyles = makeStyles({
flexDirection: "column", flexDirection: "column",
gap: tokens.spacingVerticalM, gap: tokens.spacingVerticalM,
}, },
section: {
display: "flex",
flexDirection: "column",
gap: tokens.spacingVerticalS,
backgroundColor: tokens.colorNeutralBackground1,
borderRadius: tokens.borderRadiusMedium,
borderTopWidth: "1px",
borderTopStyle: "solid",
borderTopColor: tokens.colorNeutralStroke2,
borderRightWidth: "1px",
borderRightStyle: "solid",
borderRightColor: tokens.colorNeutralStroke2,
borderBottomWidth: "1px",
borderBottomStyle: "solid",
borderBottomColor: tokens.colorNeutralStroke2,
borderLeftWidth: "1px",
borderLeftStyle: "solid",
borderLeftColor: tokens.colorNeutralStroke2,
padding: tokens.spacingVerticalM,
},
sectionHeader: { sectionHeader: {
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
@@ -93,6 +74,8 @@ export function DashboardPage(): React.JSX.Element {
const { countries, countryNames, isLoading: countryLoading, error: countryError, reload: reloadCountry } = const { countries, countryNames, isLoading: countryLoading, error: countryError, reload: reloadCountry } =
useDashboardCountryData(timeRange, originFilter); useDashboardCountryData(timeRange, originFilter);
const sectionStyles = useCommonSectionStyles();
return ( return (
<div className={styles.root}> <div className={styles.root}>
{/* ------------------------------------------------------------------ */} {/* ------------------------------------------------------------------ */}
@@ -113,7 +96,7 @@ export function DashboardPage(): React.JSX.Element {
{/* ------------------------------------------------------------------ */} {/* ------------------------------------------------------------------ */}
{/* Ban Trend section */} {/* Ban Trend section */}
{/* ------------------------------------------------------------------ */} {/* ------------------------------------------------------------------ */}
<div className={styles.section}> <div className={sectionStyles.section}>
<div className={styles.sectionHeader}> <div className={styles.sectionHeader}>
<Text as="h2" size={500} weight="semibold"> <Text as="h2" size={500} weight="semibold">
Ban Trend Ban Trend
@@ -127,7 +110,7 @@ export function DashboardPage(): React.JSX.Element {
{/* ------------------------------------------------------------------ */} {/* ------------------------------------------------------------------ */}
{/* Charts section */} {/* Charts section */}
{/* ------------------------------------------------------------------ */} {/* ------------------------------------------------------------------ */}
<div className={styles.section}> <div className={sectionStyles.section}>
<div className={styles.sectionHeader}> <div className={styles.sectionHeader}>
<Text as="h2" size={500} weight="semibold"> <Text as="h2" size={500} weight="semibold">
Top Countries Top Countries
@@ -162,7 +145,7 @@ export function DashboardPage(): React.JSX.Element {
{/* ------------------------------------------------------------------ */} {/* ------------------------------------------------------------------ */}
{/* Ban list section */} {/* Ban list section */}
{/* ------------------------------------------------------------------ */} {/* ------------------------------------------------------------------ */}
<div className={styles.section}> <div className={sectionStyles.section}>
<div className={styles.sectionHeader}> <div className={styles.sectionHeader}>
<Text as="h2" size={500} weight="semibold"> <Text as="h2" size={500} weight="semibold">
Ban List Ban List

View File

@@ -36,6 +36,7 @@ import {
makeStyles, makeStyles,
tokens, tokens,
} from "@fluentui/react-components"; } from "@fluentui/react-components";
import { useCardStyles } from "../theme/commonStyles";
import { import {
ArrowCounterclockwiseRegular, ArrowCounterclockwiseRegular,
ArrowLeftRegular, ArrowLeftRegular,
@@ -118,9 +119,6 @@ const useStyles = makeStyles({
gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))", gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))",
gap: tokens.spacingVerticalM, gap: tokens.spacingVerticalM,
padding: tokens.spacingVerticalM, padding: tokens.spacingVerticalM,
background: tokens.colorNeutralBackground2,
borderRadius: tokens.borderRadiusMedium,
border: `1px solid ${tokens.colorNeutralStroke1}`,
marginBottom: tokens.spacingVerticalM, marginBottom: tokens.spacingVerticalM,
}, },
detailField: { detailField: {
@@ -222,6 +220,7 @@ interface IpDetailViewProps {
function IpDetailView({ ip, onBack }: IpDetailViewProps): React.JSX.Element { function IpDetailView({ ip, onBack }: IpDetailViewProps): React.JSX.Element {
const styles = useStyles(); const styles = useStyles();
const cardStyles = useCardStyles();
const { detail, loading, error, refresh } = useIpHistory(ip); const { detail, loading, error, refresh } = useIpHistory(ip);
if (loading) { if (loading) {
@@ -278,7 +277,7 @@ function IpDetailView({ ip, onBack }: IpDetailViewProps): React.JSX.Element {
</div> </div>
{/* Summary grid */} {/* Summary grid */}
<div className={styles.detailGrid}> <div className={`${cardStyles.card} ${styles.detailGrid}`}>
<div className={styles.detailField}> <div className={styles.detailField}>
<span className={styles.detailLabel}>Total Bans</span> <span className={styles.detailLabel}>Total Bans</span>
<span className={styles.detailValue}>{String(detail.total_bans)}</span> <span className={styles.detailValue}>{String(detail.total_bans)}</span>

View File

@@ -23,6 +23,7 @@ import {
makeStyles, makeStyles,
tokens, tokens,
} from "@fluentui/react-components"; } from "@fluentui/react-components";
import { useCommonSectionStyles } from "../theme/commonStyles";
import { import {
ArrowClockwiseRegular, ArrowClockwiseRegular,
ArrowLeftRegular, ArrowLeftRegular,
@@ -53,36 +54,7 @@ const useStyles = makeStyles({
alignItems: "center", alignItems: "center",
gap: tokens.spacingHorizontalS, gap: tokens.spacingHorizontalS,
}, },
section: {
display: "flex",
flexDirection: "column",
gap: tokens.spacingVerticalS,
backgroundColor: tokens.colorNeutralBackground1,
borderRadius: tokens.borderRadiusMedium,
borderTopWidth: "1px",
borderTopStyle: "solid",
borderTopColor: tokens.colorNeutralStroke2,
borderRightWidth: "1px",
borderRightStyle: "solid",
borderRightColor: tokens.colorNeutralStroke2,
borderBottomWidth: "1px",
borderBottomStyle: "solid",
borderBottomColor: tokens.colorNeutralStroke2,
borderLeftWidth: "1px",
borderLeftStyle: "solid",
borderLeftColor: tokens.colorNeutralStroke2,
padding: tokens.spacingVerticalM,
},
sectionHeader: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: tokens.spacingHorizontalM,
paddingBottom: tokens.spacingVerticalS,
borderBottomWidth: "1px",
borderBottomStyle: "solid",
borderBottomColor: tokens.colorNeutralStroke2,
},
headerRow: { headerRow: {
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
@@ -181,6 +153,7 @@ interface JailInfoProps {
function JailInfoSection({ jail, onRefresh, onStart, onStop, onSetIdle, onReload }: JailInfoProps): React.JSX.Element { function JailInfoSection({ jail, onRefresh, onStart, onStop, onSetIdle, onReload }: JailInfoProps): React.JSX.Element {
const styles = useStyles(); const styles = useStyles();
const sectionStyles = useCommonSectionStyles();
const navigate = useNavigate(); const navigate = useNavigate();
const [ctrlError, setCtrlError] = useState<string | null>(null); const [ctrlError, setCtrlError] = useState<string | null>(null);
@@ -206,8 +179,8 @@ function JailInfoSection({ jail, onRefresh, onStart, onStop, onSetIdle, onReload
}; };
return ( return (
<div className={styles.section}> <div className={sectionStyles.section}>
<div className={styles.sectionHeader}> <div className={sectionStyles.sectionHeader}>
<div className={styles.headerRow}> <div className={styles.headerRow}>
<Text <Text
size={600} size={600}
@@ -334,10 +307,10 @@ function JailInfoSection({ jail, onRefresh, onStart, onStop, onSetIdle, onReload
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function PatternsSection({ jail }: { jail: Jail }): React.JSX.Element { function PatternsSection({ jail }: { jail: Jail }): React.JSX.Element {
const styles = useStyles(); const sectionStyles = useCommonSectionStyles();
return ( return (
<div className={styles.section}> <div className={sectionStyles.section}>
<div className={styles.sectionHeader}> <div className={sectionStyles.sectionHeader}>
<Text as="h2" size={500} weight="semibold"> <Text as="h2" size={500} weight="semibold">
Log Paths &amp; Patterns Log Paths &amp; Patterns
</Text> </Text>
@@ -374,12 +347,13 @@ function PatternsSection({ jail }: { jail: Jail }): React.JSX.Element {
function BantimeEscalationSection({ jail }: { jail: Jail }): React.JSX.Element | null { function BantimeEscalationSection({ jail }: { jail: Jail }): React.JSX.Element | null {
const styles = useStyles(); const styles = useStyles();
const sectionStyles = useCommonSectionStyles();
const esc = jail.bantime_escalation; const esc = jail.bantime_escalation;
if (!esc?.increment) return null; if (!esc?.increment) return null;
return ( return (
<div className={styles.section}> <div className={sectionStyles.section}>
<div className={styles.sectionHeader}> <div className={sectionStyles.sectionHeader}>
<Text as="h2" size={500} weight="semibold"> <Text as="h2" size={500} weight="semibold">
Ban-time Escalation Ban-time Escalation
</Text> </Text>
@@ -445,6 +419,7 @@ function IgnoreListSection({
onToggleIgnoreSelf, onToggleIgnoreSelf,
}: IgnoreListSectionProps): React.JSX.Element { }: IgnoreListSectionProps): React.JSX.Element {
const styles = useStyles(); const styles = useStyles();
const sectionStyles = useCommonSectionStyles();
const [inputVal, setInputVal] = useState(""); const [inputVal, setInputVal] = useState("");
const [opError, setOpError] = useState<string | null>(null); const [opError, setOpError] = useState<string | null>(null);
@@ -470,8 +445,8 @@ function IgnoreListSection({
}; };
return ( return (
<div className={styles.section}> <div className={sectionStyles.section}>
<div className={styles.sectionHeader}> <div className={sectionStyles.sectionHeader}>
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM }}> <div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM }}>
<Text as="h2" size={500} weight="semibold"> <Text as="h2" size={500} weight="semibold">
Ignore List (IP Whitelist) Ignore List (IP Whitelist)

View File

@@ -33,6 +33,7 @@ import {
type TableColumnDefinition, type TableColumnDefinition,
createTableColumn, createTableColumn,
} from "@fluentui/react-components"; } from "@fluentui/react-components";
import { useCardStyles, useCommonSectionStyles } from "../theme/commonStyles";
import { import {
ArrowClockwiseRegular, ArrowClockwiseRegular,
ArrowSyncRegular, ArrowSyncRegular,
@@ -58,36 +59,7 @@ const useStyles = makeStyles({
flexDirection: "column", flexDirection: "column",
gap: tokens.spacingVerticalL, gap: tokens.spacingVerticalL,
}, },
section: {
display: "flex",
flexDirection: "column",
gap: tokens.spacingVerticalS,
backgroundColor: tokens.colorNeutralBackground1,
borderRadius: tokens.borderRadiusMedium,
borderTopWidth: "1px",
borderTopStyle: "solid",
borderTopColor: tokens.colorNeutralStroke2,
borderRightWidth: "1px",
borderRightStyle: "solid",
borderRightColor: tokens.colorNeutralStroke2,
borderBottomWidth: "1px",
borderBottomStyle: "solid",
borderBottomColor: tokens.colorNeutralStroke2,
borderLeftWidth: "1px",
borderLeftStyle: "solid",
borderLeftColor: tokens.colorNeutralStroke2,
padding: tokens.spacingVerticalM,
},
sectionHeader: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: tokens.spacingHorizontalM,
paddingBottom: tokens.spacingVerticalS,
borderBottomWidth: "1px",
borderBottomStyle: "solid",
borderBottomColor: tokens.colorNeutralStroke2,
},
tableWrapper: { overflowX: "auto" }, tableWrapper: { overflowX: "auto" },
centred: { centred: {
display: "flex", display: "flex",
@@ -117,20 +89,6 @@ const useStyles = makeStyles({
gap: tokens.spacingVerticalS, gap: tokens.spacingVerticalS,
marginTop: tokens.spacingVerticalS, marginTop: tokens.spacingVerticalS,
padding: tokens.spacingVerticalS, padding: tokens.spacingVerticalS,
backgroundColor: tokens.colorNeutralBackground2,
borderRadius: tokens.borderRadiusMedium,
borderTopWidth: "1px",
borderTopStyle: "solid",
borderTopColor: tokens.colorNeutralStroke2,
borderRightWidth: "1px",
borderRightStyle: "solid",
borderRightColor: tokens.colorNeutralStroke2,
borderBottomWidth: "1px",
borderBottomStyle: "solid",
borderBottomColor: tokens.colorNeutralStroke2,
borderLeftWidth: "1px",
borderLeftStyle: "solid",
borderLeftColor: tokens.colorNeutralStroke2,
}, },
lookupRow: { lookupRow: {
display: "flex", display: "flex",
@@ -208,6 +166,7 @@ const jailColumns: TableColumnDefinition<JailSummary>[] = [
function JailOverviewSection(): React.JSX.Element { function JailOverviewSection(): React.JSX.Element {
const styles = useStyles(); const styles = useStyles();
const sectionStyles = useCommonSectionStyles();
const { jails, total, loading, error, refresh, startJail, stopJail, setIdle, reloadJail, reloadAll } = const { jails, total, loading, error, refresh, startJail, stopJail, setIdle, reloadJail, reloadAll } =
useJails(); useJails();
const [opError, setOpError] = useState<string | null>(null); const [opError, setOpError] = useState<string | null>(null);
@@ -220,8 +179,8 @@ function JailOverviewSection(): React.JSX.Element {
}; };
return ( return (
<div className={styles.section}> <div className={sectionStyles.section}>
<div className={styles.sectionHeader}> <div className={sectionStyles.sectionHeader}>
<Text as="h2" size={500} weight="semibold"> <Text as="h2" size={500} weight="semibold">
Jail Overview Jail Overview
{total > 0 && ( {total > 0 && (
@@ -358,6 +317,7 @@ interface BanUnbanFormProps {
function BanUnbanForm({ jailNames, onBan, onUnban }: BanUnbanFormProps): React.JSX.Element { function BanUnbanForm({ jailNames, onBan, onUnban }: BanUnbanFormProps): React.JSX.Element {
const styles = useStyles(); const styles = useStyles();
const sectionStyles = useCommonSectionStyles();
const [banIpVal, setBanIpVal] = useState(""); const [banIpVal, setBanIpVal] = useState("");
const [banJail, setBanJail] = useState(""); const [banJail, setBanJail] = useState("");
const [unbanIpVal, setUnbanIpVal] = useState(""); const [unbanIpVal, setUnbanIpVal] = useState("");
@@ -415,8 +375,8 @@ function BanUnbanForm({ jailNames, onBan, onUnban }: BanUnbanFormProps): React.J
}; };
return ( return (
<div className={styles.section}> <div className={sectionStyles.section}>
<div className={styles.sectionHeader}> <div className={sectionStyles.sectionHeader}>
<Text as="h2" size={500} weight="semibold"> <Text as="h2" size={500} weight="semibold">
Ban / Unban IP Ban / Unban IP
</Text> </Text>
@@ -544,6 +504,8 @@ function BanUnbanForm({ jailNames, onBan, onUnban }: BanUnbanFormProps): React.J
function IpLookupSection(): React.JSX.Element { function IpLookupSection(): React.JSX.Element {
const styles = useStyles(); const styles = useStyles();
const sectionStyles = useCommonSectionStyles();
const cardStyles = useCardStyles();
const { result, loading, error, lookup, clear } = useIpLookup(); const { result, loading, error, lookup, clear } = useIpLookup();
const [inputVal, setInputVal] = useState(""); const [inputVal, setInputVal] = useState("");
@@ -554,8 +516,8 @@ function IpLookupSection(): React.JSX.Element {
}; };
return ( return (
<div className={styles.section}> <div className={sectionStyles.section}>
<div className={styles.sectionHeader}> <div className={sectionStyles.sectionHeader}>
<Text as="h2" size={500} weight="semibold"> <Text as="h2" size={500} weight="semibold">
IP Lookup IP Lookup
</Text> </Text>
@@ -595,7 +557,7 @@ function IpLookupSection(): React.JSX.Element {
)} )}
{result && ( {result && (
<div className={styles.lookupResult}> <div className={`${cardStyles.card} ${styles.lookupResult}`}>
<div className={styles.lookupRow}> <div className={styles.lookupRow}>
<Text className={styles.lookupLabel}>IP:</Text> <Text className={styles.lookupLabel}>IP:</Text>
<Text className={styles.mono}>{result.ip}</Text> <Text className={styles.mono}>{result.ip}</Text>

View File

@@ -0,0 +1,30 @@
import { makeStyles, tokens } from "@fluentui/react-components";
export const useCommonSectionStyles = makeStyles({
section: {
display: "flex",
flexDirection: "column",
gap: tokens.spacingVerticalS,
backgroundColor: tokens.colorNeutralBackground1,
borderRadius: tokens.borderRadiusMedium,
border: `1px solid ${tokens.colorNeutralStroke2}`,
padding: tokens.spacingVerticalM,
},
sectionHeader: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: tokens.spacingHorizontalM,
paddingBottom: tokens.spacingVerticalS,
borderBottom: `1px solid ${tokens.colorNeutralStroke2}`,
},
});
export const useCardStyles = makeStyles({
card: {
backgroundColor: tokens.colorNeutralBackground1,
borderRadius: tokens.borderRadiusMedium,
border: `1px solid ${tokens.colorNeutralStroke2}`,
padding: tokens.spacingVerticalM,
},
});

View File

@@ -0,0 +1,14 @@
/**
* Normalize fetch error handling across hooks.
*/
export function handleFetchError(
err: unknown,
setError: (value: string | null) => void,
fallback: string = "Unknown error",
): void {
if (err instanceof DOMException && err.name === "AbortError") {
return;
}
setError(err instanceof Error ? err.message : fallback);
}