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/
├── app/
│ ├── __init__.py
│ ├── main.py # FastAPI app factory, lifespan, exception handlers
│ ├── config.py # Pydantic settings (env vars, .env loading)
│ ├── dependencies.py # FastAPI Depends() providers (DB, services, auth)
│ ├── models/ # Pydantic schemas
│ ├── `main.py` # FastAPI app factory, lifespan, exception handlers
│ ├── `config.py` # Pydantic settings (env vars, .env loading)
│ ├── `db.py` # Database connection and initialization
│ ├── `exceptions.py` # Shared domain exception classes
│ ├── `dependencies.py` # FastAPI Depends() providers (DB, services, auth)
│ ├── `models/` # Pydantic schemas
│ │ ├── auth.py # Login request/response, session models
│ │ ├── ban.py # Ban 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
│ │ ├── ban_service.py # Ban/unban execution, currently-banned queries
│ │ ├── 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
│ │ ├── blocklist_service.py # Download, validate, apply blocklists
│ │ ├── geo_service.py # IP-to-country resolution, ASN/RIR lookup
@@ -119,17 +127,18 @@ backend/
│ ├── repositories/ # Data access layer (raw queries only)
│ │ ├── settings_repo.py # App configuration CRUD in SQLite
│ │ ├── session_repo.py # Session storage and lookup
│ │ ├── blocklist_repo.py # Blocklist sources and import log persistence
│ │ └── import_log_repo.py # Import run history records
│ │ ├── blocklist_repo.py # Blocklist sources and import log persistence│ │ ├── fail2ban_db_repo.py # fail2ban SQLite ban history read operations
│ │ ├── geo_cache_repo.py # IP geolocation cache persistence│ │ └── import_log_repo.py # Import run history records
│ ├── tasks/ # APScheduler background jobs
│ │ ├── blocklist_import.py# Scheduled blocklist download and application
│ │ ├── geo_cache_flush.py # Periodic geo cache persistence (dirty-set flush to SQLite)
│ │ └── health_check.py # Periodic fail2ban connectivity probe
│ │ ├── 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
│ └── utils/ # Helpers, constants, shared types
│ ├── fail2ban_client.py # Async wrapper around the fail2ban socket protocol
│ ├── ip_utils.py # IP/CIDR validation and normalisation
│ ├── time_utils.py # Timezone-aware datetime helpers
── constants.py # Shared constants (default paths, limits, etc.)
│ ├── time_utils.py # Timezone-aware datetime helpers│ ├── jail_config.py # Jail config parser/serializer helper
── 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/
│ ├── conftest.py # Shared fixtures (test app, client, mock DB)
│ ├── 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 |
| `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 |
| `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.
@@ -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 |
| `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 |
| `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 |
| `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 |
@@ -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) |
| `session_repo.py` | Store, retrieve, and delete session records for authentication |
| `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 |
#### 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`)
- **Response models** — shape outgoing API data (e.g., `JailResponse`, `BanListResponse`)
- **Domain models** — internal representations used between services and repositories (e.g., `Ban`, `Jail`)
| Model file | Purpose |
|---|---|
| `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/`)
@@ -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 |
| `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 |
#### 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). |
| `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 |
| `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 |
#### 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.
---
## 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
---
### 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):
"""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):
"""Raised when a fail2ban jail operation fails."""
@@ -21,3 +25,29 @@ class ConfigOperationError(Exception):
class ServerOperationError(Exception):
"""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
socket_path: str = request.app.state.settings.fail2ban_socket
from app.services import jail_service
return await blocklist_service.import_all(
db,
http_session,

View File

@@ -76,7 +76,7 @@ from app.models.config import (
RollbackResponse,
ServiceStatusResponse,
)
from app.services import config_service, jail_service
from app.services import config_service, jail_service, log_service
from app.services import (
action_config_service,
config_file_service,
@@ -472,7 +472,7 @@ async def regex_test(
Returns:
: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:
: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``).
"""
socket_path: str = request.app.state.settings.fail2ban_socket
from app.services import health_service
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:
raise _bad_gateway(exc) from exc

View File

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

View File

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

View File

@@ -20,7 +20,7 @@ if TYPE_CHECKING:
from app.models.auth import Session
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
log: structlog.stdlib.BoundLogger = structlog.get_logger()
@@ -65,7 +65,7 @@ async def login(
Raises:
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:
log.warning("bangui_login_no_hash")
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 "", ()
_TIME_RANGE_SLACK_SECONDS: int = 60
def _since_unix(range_: TimeRange) -> int:
"""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.
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_]
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
import importlib
import json
from collections.abc import Awaitable
from typing import TYPE_CHECKING
import structlog
@@ -29,6 +31,7 @@ from app.models.blocklist import (
ScheduleConfig,
ScheduleInfo,
)
from app.exceptions import JailNotFoundError
from app.repositories import blocklist_repo, import_log_repo, settings_repo
from app.utils.ip_utils import is_valid_ip, is_valid_network
@@ -244,6 +247,7 @@ async def import_source(
db: aiosqlite.Connection,
geo_is_cached: Callable[[str], bool] | None = None,
geo_batch_lookup: GeoBatchLookup | None = None,
ban_ip: Callable[[str, str, str], Awaitable[None]] | None = None,
) -> ImportSourceResult:
"""Download and apply bans from a single blocklist source.
@@ -301,8 +305,14 @@ async def import_source(
ban_error: str | None = None
imported_ips: list[str] = []
# Import jail_service here to avoid circular import at module level.
from app.services import jail_service # noqa: PLC0415
if ban_ip is None:
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():
stripped = line.strip()
@@ -315,10 +325,10 @@ async def import_source(
continue
try:
await jail_service.ban_ip(socket_path, BLOCKLIST_JAIL, stripped)
await ban_ip_fn(socket_path, BLOCKLIST_JAIL, stripped)
imported += 1
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
# continuing because every subsequent ban would also fail.
ban_error = str(exc)
@@ -387,6 +397,7 @@ async def import_all(
socket_path: str,
geo_is_cached: Callable[[str], bool] | None = None,
geo_batch_lookup: GeoBatchLookup | None = None,
ban_ip: Callable[[str, str, str], Awaitable[None]] | None = None,
) -> ImportRunResult:
"""Import all enabled blocklist sources.
@@ -417,6 +428,7 @@ async def import_all(
db,
geo_is_cached=geo_is_cached,
geo_batch_lookup=geo_batch_lookup,
ban_ip=ban_ip,
)
results.append(result)
total_imported += result.ips_imported

View File

@@ -54,9 +54,9 @@ from app.models.config import (
JailValidationResult,
RollbackResponse,
)
from app.exceptions import JailNotFoundError
from app.services import jail_service
from app.exceptions import FilterInvalidRegexError, JailNotFoundError
from app.utils import conffile_parser
from app.utils.jail_utils import reload_jails
from app.utils.fail2ban_client import (
Fail2BanClient,
Fail2BanConnectionError,
@@ -65,6 +65,41 @@ from app.utils.fail2ban_client import (
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
# ---------------------------------------------------------------------------
@@ -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
# ---------------------------------------------------------------------------
@@ -1206,7 +1226,7 @@ async def activate_jail(
# Activation reload — if it fails, roll back immediately #
# ---------------------------------------------------------------------- #
try:
await jail_service.reload_all(socket_path, include_jails=[name])
await _reload_all(socket_path, include_jails=[name])
except JailNotFoundError as exc:
# Jail configuration is invalid (e.g. missing logpath that prevents
# 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.
try:
await jail_service.reload_all(socket_path)
await _reload_all(socket_path)
log.info("jail_activation_rollback_reload_ok", jail=name)
except Exception as exc: # noqa: BLE001
log.warning("jail_activation_rollback_reload_failed", jail=name, error=str(exc))
@@ -1416,7 +1436,7 @@ async def deactivate_jail(
)
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
log.warning("reload_after_deactivate_failed", jail=name, error=str(exc))
@@ -1972,7 +1992,7 @@ async def update_filter(
if do_reload:
try:
await jail_service.reload_all(socket_path)
await _reload_all(socket_path)
except Exception as exc: # noqa: BLE001
log.warning(
"reload_after_filter_update_failed",
@@ -2047,7 +2067,7 @@ async def create_filter(
if do_reload:
try:
await jail_service.reload_all(socket_path)
await _reload_all(socket_path)
except Exception as exc: # noqa: BLE001
log.warning(
"reload_after_filter_create_failed",
@@ -2174,7 +2194,7 @@ async def assign_filter_to_jail(
if do_reload:
try:
await jail_service.reload_all(socket_path)
await _reload_all(socket_path)
except Exception as exc: # noqa: BLE001
log.warning(
"reload_after_assign_filter_failed",
@@ -2826,7 +2846,7 @@ async def update_action(
if do_reload:
try:
await jail_service.reload_all(socket_path)
await _reload_all(socket_path)
except Exception as exc: # noqa: BLE001
log.warning(
"reload_after_action_update_failed",
@@ -2895,7 +2915,7 @@ async def create_action(
if do_reload:
try:
await jail_service.reload_all(socket_path)
await _reload_all(socket_path)
except Exception as exc: # noqa: BLE001
log.warning(
"reload_after_action_create_failed",
@@ -3026,7 +3046,7 @@ async def assign_action_to_jail(
if do_reload:
try:
await jail_service.reload_all(socket_path)
await _reload_all(socket_path)
except Exception as exc: # noqa: BLE001
log.warning(
"reload_after_assign_action_failed",
@@ -3088,7 +3108,7 @@ async def remove_action_from_jail(
if do_reload:
try:
await jail_service.reload_all(socket_path)
await _reload_all(socket_path)
except Exception as exc: # noqa: BLE001
log.warning(
"reload_after_remove_action_failed",

View File

@@ -15,6 +15,7 @@ from __future__ import annotations
import asyncio
import contextlib
import re
from collections.abc import Awaitable, Callable
from pathlib import Path
from typing import TYPE_CHECKING, TypeVar, cast
@@ -35,7 +36,6 @@ from app.models.config import (
JailConfigListResponse,
JailConfigResponse,
JailConfigUpdate,
LogPreviewLine,
LogPreviewRequest,
LogPreviewResponse,
MapColorThresholdsResponse,
@@ -45,8 +45,12 @@ from app.models.config import (
ServiceStatusResponse,
)
from app.exceptions import ConfigOperationError, ConfigValidationError, JailNotFoundError
from app.services import setup_service
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()
@@ -494,27 +498,8 @@ async def update_global_config(socket_path: str, update: GlobalConfigUpdate) ->
def test_regex(request: RegexTestRequest) -> RegexTestResponse:
"""Test a regex pattern against a sample log line.
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])
"""Proxy to log utilities for regex test without service imports."""
return util_test_regex(request)
# ---------------------------------------------------------------------------
@@ -592,101 +577,14 @@ async def delete_log_path(
raise ConfigOperationError(f"Failed to delete log path {log_path!r}: {exc}") from exc
async def preview_log(req: LogPreviewRequest) -> LogPreviewResponse:
"""Read the last *num_lines* of a log file and test *fail_regex* against each.
This operation reads from the local filesystem — no socket is used.
Args:
req: :class:`~app.models.config.LogPreviewRequest`.
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()]
async def preview_log(
req: LogPreviewRequest,
preview_fn: Callable[[LogPreviewRequest], Awaitable[LogPreviewResponse]] | None = None,
) -> LogPreviewResponse:
"""Proxy to an injectable log preview function."""
if preview_fn is None:
preview_fn = util_preview_log
return await preview_fn(req)
# ---------------------------------------------------------------------------
@@ -703,7 +601,7 @@ async def get_map_color_thresholds(db: aiosqlite.Connection) -> MapColorThreshol
Returns:
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(
threshold_high=high,
threshold_medium=medium,
@@ -724,7 +622,7 @@ async def update_map_color_thresholds(
Raises:
ValueError: If validation fails (thresholds must satisfy high > medium > low).
"""
await setup_service.set_map_color_thresholds(
await util_set_map_color_thresholds(
db,
threshold_high=update.threshold_high,
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:
"""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 the total number of lines in *file_path* synchronously."""
count = 0
with open(file_path, "rb") as fh:
for chunk in iter(lambda: fh.read(65536), b""):
@@ -763,6 +652,32 @@ def _count_file_lines(file_path: str) -> int:
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(
socket_path: str,
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.
Delegates to :func:`~app.services.health_service.probe` for the core
health snapshot and augments it with the current log-level and log-target
values from the socket.
Delegates to an injectable *probe_fn* (defaults to
:func:`~app.services.health_service.probe`). This avoids direct service-to-
service imports inside this module.
Args:
socket_path: Path to the fail2ban Unix domain socket.
probe_fn: Optional probe function.
Returns:
: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:
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)

View File

@@ -25,15 +25,9 @@ from app.models.config import (
FilterUpdateRequest,
AssignFilterRequest,
)
from app.exceptions import 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.exceptions import FilterInvalidRegexError, JailNotFoundError
from app.utils import conffile_parser
from app.utils.jail_utils import reload_jails
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):
"""Raised when a filter name contains invalid characters."""
@@ -723,7 +702,7 @@ async def update_filter(
if do_reload:
try:
await jail_service.reload_all(socket_path)
await reload_jails(socket_path)
except Exception as exc: # noqa: BLE001
log.warning(
"reload_after_filter_update_failed",
@@ -798,7 +777,7 @@ async def create_filter(
if do_reload:
try:
await jail_service.reload_all(socket_path)
await reload_jails(socket_path)
except Exception as exc: # noqa: BLE001
log.warning(
"reload_after_filter_create_failed",
@@ -924,7 +903,7 @@ async def assign_filter_to_jail(
if do_reload:
try:
await jail_service.reload_all(socket_path)
await reload_jails(socket_path)
except Exception as exc: # noqa: BLE001
log.warning(
"reload_after_assign_filter_failed",

View File

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

View File

@@ -30,7 +30,13 @@ from app.models.config import (
JailValidationResult,
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 (
Fail2BanClient,
Fail2BanConnectionError,
@@ -304,7 +310,7 @@ def _validate_regex_patterns(patterns: list[str]) -> None:
re.compile(pattern)
except re.error as exc:
# 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
@@ -460,12 +466,7 @@ async def start_daemon(start_cmd_parts: list[str]) -> bool:
return False
# Import shared functions from config_file_service
_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
# Shared functions from config_file_service are imported from app.utils.config_file_utils
# ---------------------------------------------------------------------------
@@ -624,7 +625,7 @@ async def activate_jail(
# Activation reload — if it fails, roll back immediately #
# ---------------------------------------------------------------------- #
try:
await jail_service.reload_all(socket_path, include_jails=[name])
await reload_jails(socket_path, include_jails=[name])
except JailNotFoundError as exc:
# Jail configuration is invalid (e.g. missing logpath that prevents
# 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.
try:
await jail_service.reload_all(socket_path)
await reload_jails(socket_path)
log.info("jail_activation_rollback_reload_ok", jail=name)
except Exception as exc: # noqa: BLE001
log.warning("jail_activation_rollback_reload_failed", jail=name, error=str(exc))
@@ -834,7 +835,7 @@ async def deactivate_jail(
)
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
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")
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:
"""Return the stored bcrypt password hash, or ``None`` if not set.
Args:
db: Active aiosqlite connection.
Returns:
The bcrypt hash string, or ``None``.
"""
return await settings_repo.get_setting(db, _KEY_PASSWORD_HASH)
"""Return the stored bcrypt password hash, or ``None`` if not set."""
return await util_get_password_hash(db)
async def get_timezone(db: aiosqlite.Connection) -> str:
"""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"``.
"""
"""Return the configured IANA timezone string."""
tz = await settings_repo.get_setting(db, _KEY_TIMEZONE)
return tz if tz else "UTC"
@@ -133,31 +123,8 @@ async def get_timezone(db: aiosqlite.Connection) -> str:
async def get_map_color_thresholds(
db: aiosqlite.Connection,
) -> tuple[int, int, int]:
"""Return the configured map color thresholds (high, medium, low).
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,
)
"""Return the configured map color thresholds (high, medium, low)."""
return await util_get_map_color_thresholds(db)
async def set_map_color_thresholds(
@@ -167,31 +134,12 @@ async def set_map_color_thresholds(
threshold_medium: int,
threshold_low: int,
) -> None:
"""Update the map color threshold configuration.
Args:
db: Active aiosqlite connection.
threshold_high: Ban count for red coloring.
threshold_medium: Ban count for yellow coloring.
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)
"""Update the map color threshold configuration."""
await util_set_map_color_thresholds(
db,
threshold_high=threshold_high,
threshold_medium=threshold_medium,
threshold_low=threshold_low,
)
log.info(
"map_color_thresholds_updated",

View File

@@ -43,9 +43,15 @@ async def _run_import(app: Any) -> None:
http_session = app.state.http_session
socket_path: str = app.state.settings.fail2ban_socket
from app.services import jail_service
log.info("blocklist_import_starting")
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(
"blocklist_import_finished",
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:
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(
database_path=str(tmp_path / "test_bangui.db"),
fail2ban_socket="/tmp/fake_fail2ban.sock",
fail2ban_config_dir=str(config_dir),
session_secret="test-secret-key-do-not-use-in-production",
session_duration_minutes=60,
timezone="UTC",

View File

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

View File

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

View File

@@ -203,9 +203,15 @@ class TestImport:
call_count += 1
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(
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.
@@ -226,7 +232,14 @@ class TestImport:
with patch(
"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.
assert len(result.results) == 1

View File

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

View File

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

View File

@@ -14,6 +14,7 @@ import {
makeStyles,
tokens,
} from "@fluentui/react-components";
import { useCardStyles } from "../theme/commonStyles";
import type { BanOriginFilter, TimeRange } from "../types/ban";
import {
BAN_ORIGIN_FILTER_LABELS,
@@ -57,20 +58,6 @@ const useStyles = makeStyles({
alignItems: "center",
flexWrap: "wrap",
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,
paddingBottom: tokens.spacingVerticalS,
paddingLeft: tokens.spacingHorizontalM,
@@ -107,9 +94,10 @@ export function DashboardFilterBar({
onOriginFilterChange,
}: DashboardFilterBarProps): React.JSX.Element {
const styles = useStyles();
const cardStyles = useCardStyles();
return (
<div className={styles.container}>
<div className={`${styles.container} ${cardStyles.card}`}>
{/* Time-range group */}
<div className={styles.group}>
<Text weight="semibold" size={300}>

View File

@@ -18,6 +18,7 @@ import {
tokens,
Tooltip,
} from "@fluentui/react-components";
import { useCardStyles } from "../theme/commonStyles";
import { ArrowClockwiseRegular, ShieldRegular } from "@fluentui/react-icons";
import { useServerStatus } from "../hooks/useServerStatus";
@@ -31,20 +32,6 @@ const useStyles = makeStyles({
alignItems: "center",
gap: 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,
flexWrap: "wrap",
},
@@ -85,8 +72,10 @@ export function ServerStatusBar(): React.JSX.Element {
const styles = useStyles();
const { status, loading, error, refresh } = useServerStatus();
const cardStyles = useCardStyles();
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 */}
{/* ---------------------------------------------------------------- */}

View File

@@ -10,6 +10,7 @@
import { useCallback, useState } from "react";
import { ComposableMap, ZoomableGroup, Geography, useGeographies } from "react-simple-maps";
import { Button, makeStyles, tokens } from "@fluentui/react-components";
import { useCardStyles } from "../theme/commonStyles";
import type { GeoPermissibleObjects } from "d3-geo";
import { ISO_NUMERIC_TO_ALPHA2 } from "../data/isoNumericToAlpha2";
import { getBanCountColor } from "../utils/mapColors";
@@ -29,9 +30,6 @@ const useStyles = makeStyles({
mapWrapper: {
width: "100%",
position: "relative",
backgroundColor: tokens.colorNeutralBackground2,
borderRadius: tokens.borderRadiusMedium,
border: `1px solid ${tokens.colorNeutralStroke1}`,
overflow: "hidden",
},
countLabel: {
@@ -211,6 +209,7 @@ export function WorldMap({
thresholdHigh = 100,
}: WorldMapProps): React.JSX.Element {
const styles = useStyles();
const cardStyles = useCardStyles();
const [zoom, setZoom] = useState<number>(1);
const [center, setCenter] = useState<[number, number]>([0, 0]);
@@ -229,7 +228,7 @@ export function WorldMap({
return (
<div
className={styles.mapWrapper}
className={`${cardStyles.card} ${styles.mapWrapper}`}
role="img"
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,
createTableColumn,
} from "@fluentui/react-components";
import { useCommonSectionStyles } from "../../theme/commonStyles";
import { formatTimestamp } from "../../utils/formatDate";
import {
ArrowClockwiseRegular,
@@ -54,26 +55,6 @@ const PAGE_SIZE_OPTIONS = [10, 25, 50, 100] as const;
// ---------------------------------------------------------------------------
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: {
display: "flex",
alignItems: "center",
@@ -134,7 +115,7 @@ const useStyles = makeStyles({
/** A row item augmented with an `onUnban` callback for the row action. */
interface BanRow {
ban: ActiveBan;
onUnban: (ip: string) => void;
onUnban: (ip: string) => Promise<void>;
}
const columns: TableColumnDefinition<BanRow>[] = [
@@ -187,9 +168,7 @@ const columns: TableColumnDefinition<BanRow>[] = [
size="small"
appearance="subtle"
icon={<DismissRegular />}
onClick={() => {
onUnban(ban.ip);
}}
onClick={() => { void onUnban(ban.ip); }}
aria-label={`Unban ${ban.ip}`}
/>
</Tooltip>
@@ -214,8 +193,8 @@ export interface BannedIpsSectionProps {
onSearch: (term: string) => void;
onPageChange: (page: number) => void;
onPageSizeChange: (size: number) => void;
onRefresh: () => void;
onUnban: (ip: string) => void;
onRefresh: () => Promise<void>;
onUnban: (ip: string) => Promise<void>;
}
// ---------------------------------------------------------------------------
@@ -243,6 +222,7 @@ export function BannedIpsSection({
onUnban,
}: BannedIpsSectionProps): React.JSX.Element {
const styles = useStyles();
const sectionStyles = useCommonSectionStyles();
const rows: BanRow[] = items.map((ban) => ({
ban,
@@ -252,7 +232,7 @@ export function BannedIpsSection({
const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 1;
return (
<div className={styles.root}>
<div className={sectionStyles.section}>
{/* Section header */}
<div className={styles.header}>
<div className={styles.headerLeft}>
@@ -265,7 +245,7 @@ export function BannedIpsSection({
size="small"
appearance="subtle"
icon={<ArrowClockwiseRegular />}
onClick={onRefresh}
onClick={() => { void onRefresh(); }}
aria-label="Refresh banned IPs"
/>
</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.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useConfigItem } from "./useConfigItem";
import { fetchAction, updateAction } from "../api/config";
import type { ActionConfig, ActionConfigUpdate } from "../types/config";
@@ -23,67 +23,28 @@ export interface UseActionConfigResult {
* @param name - Action base name (e.g. ``"iptables"``).
*/
export function useActionConfig(name: string): UseActionConfigResult {
const [config, setConfig] = useState<ActionConfig | 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 { data, loading, error, saving, saveError, refresh, save } = useConfigItem<
ActionConfig,
ActionConfigUpdate
>({
fetchFn: () => fetchAction(name),
saveFn: (update) => updateAction(name, update),
mergeOnSave: (prev, update) =>
prev
? {
...prev,
...Object.fromEntries(Object.entries(update).filter(([, v]) => v != null)),
}
: prev,
});
const load = useCallback((): void => {
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
setLoading(true);
setError(null);
fetchAction(name)
.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 };
return {
config: data,
loading,
error,
saving,
saveError,
refresh,
save,
};
}

View File

@@ -7,6 +7,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchBanTrend } from "../api/dashboard";
import { handleFetchError } from "../utils/fetchError";
import type { BanTrendBucket, BanOriginFilter, TimeRange } from "../types/ban";
// ---------------------------------------------------------------------------
@@ -65,7 +66,7 @@ export function useBanTrend(
})
.catch((err: unknown) => {
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(() => {
if (!controller.signal.aborted) {

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchJails } from "../api/jails";
import { fetchJailConfigs } from "../api/config";
import { handleFetchError } from "../utils/fetchError";
import type { JailConfig } from "../types/config";
import type { JailSummary } from "../types/jail";
@@ -110,7 +111,7 @@ export function useConfigActiveStatus(): UseConfigActiveStatusResult {
})
.catch((err: unknown) => {
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);
});
}, []);

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 { fetchBansByCountry } from "../api/map";
import { handleFetchError } from "../utils/fetchError";
import type { DashboardBanItem, BanOriginFilter, TimeRange } from "../types/ban";
// ---------------------------------------------------------------------------
@@ -77,7 +78,7 @@ export function useDashboardCountryData(
})
.catch((err: unknown) => {
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(() => {
if (!controller.signal.aborted) {

View File

@@ -2,7 +2,7 @@
* 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 type { FilterConfig, FilterConfigUpdate } from "../types/config";
@@ -23,69 +23,28 @@ export interface UseFilterConfigResult {
* @param name - Filter base name (e.g. ``"sshd"``).
*/
export function useFilterConfig(name: string): UseFilterConfigResult {
const [config, setConfig] = useState<FilterConfig | 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 { data, loading, error, saving, saveError, refresh, save } = useConfigItem<
FilterConfig,
FilterConfigUpdate
>({
fetchFn: () => fetchParsedFilter(name),
saveFn: (update) => updateParsedFilter(name, update),
mergeOnSave: (prev, update) =>
prev
? {
...prev,
...Object.fromEntries(Object.entries(update).filter(([, v]) => v != null)),
}
: prev,
});
const load = useCallback((): void => {
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
setLoading(true);
setError(null);
fetchParsedFilter(name)
.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 };
return {
config: data,
loading,
error,
saving,
saveError,
refresh,
save,
};
}

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
* 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 type { JailFileConfig, JailFileConfigUpdate } from "../types/config";
@@ -21,56 +21,23 @@ export interface UseJailFileConfigResult {
* @param filename - Filename including extension (e.g. ``"sshd.conf"``).
*/
export function useJailFileConfig(filename: string): UseJailFileConfigResult {
const [config, setConfig] = useState<JailFileConfig | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const { data, loading, error, refresh, save } = useConfigItem<
JailFileConfig,
JailFileConfigUpdate
>({
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 => {
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
setLoading(true);
setError(null);
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 };
return {
config: data,
loading,
error,
refresh,
save,
};
}

View File

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

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from "react";
import { fetchMapColorThresholds, updateMapColorThresholds } from "../api/config";
import { handleFetchError } from "../utils/fetchError";
import type {
MapColorThresholdsResponse,
MapColorThresholdsUpdate,
@@ -26,7 +27,7 @@ export function useMapColorThresholds(): UseMapColorThresholdsResult {
const data = await fetchMapColorThresholds();
setThresholds(data);
} 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 {
setLoading(false);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,341 +1,18 @@
/**
* BlocklistsPage — external IP blocklist source management.
*
* Provides three sections:
* 1. **Blocklist Sources** — table of configured URLs with enable/disable
* toggle, edit, delete, and preview actions.
* 2. **Import Schedule** — frequency preset (hourly/daily/weekly) + time
* picker + "Run Now" button showing last/next run times.
* 3. **Import Log** — paginated table of completed import runs.
* Responsible for composition of sources, schedule, and import log sections.
*/
import { useCallback, useState } from "react";
import {
Badge,
Button,
Dialog,
DialogActions,
DialogBody,
DialogContent,
DialogSurface,
DialogTitle,
Field,
Input,
MessageBar,
MessageBarBody,
Select,
Spinner,
Switch,
Table,
TableBody,
TableCell,
TableCellLayout,
TableHeader,
TableHeaderCell,
TableRow,
Text,
makeStyles,
tokens,
} from "@fluentui/react-components";
import {
AddRegular,
ArrowClockwiseRegular,
DeleteRegular,
EditRegular,
EyeRegular,
PlayRegular,
} from "@fluentui/react-icons";
import {
useBlocklists,
useImportLog,
useRunImport,
useSchedule,
} from "../hooks/useBlocklist";
import type {
BlocklistSource,
ImportRunResult,
PreviewResponse,
ScheduleConfig,
ScheduleFrequency,
} from "../types/blocklist";
import { Button, MessageBar, MessageBarBody, Text } from "@fluentui/react-components";
import { useBlocklistStyles } from "../theme/commonStyles";
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const useStyles = makeStyles({
root: {
display: "flex",
flexDirection: "column",
gap: tokens.spacingVerticalXL,
},
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
// ---------------------------------------------------------------------------
import { BlocklistSourcesSection } from "../components/blocklist/BlocklistSourcesSection";
import { BlocklistScheduleSection } from "../components/blocklist/BlocklistScheduleSection";
import { BlocklistImportLogSection } from "../components/blocklist/BlocklistImportLogSection";
import { useRunImport } from "../hooks/useBlocklist";
import type { ImportRunResult } from "../types/blocklist";
interface ImportResultDialogProps {
open: boolean;
@@ -344,592 +21,29 @@ interface ImportResultDialogProps {
}
function ImportResultDialog({ open, result, onClose }: ImportResultDialogProps): React.JSX.Element {
const styles = useStyles();
if (!result) return <></>;
if (!open || !result) return <></>;
return (
<Dialog
open={open}
onOpenChange={(_ev, d) => {
if (!d.open) onClose();
}}
>
<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
<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 }}>
<div style={{ background: "white", padding: "24px", borderRadius: "8px", maxWidth: "520px", minWidth: "300px" }}>
<Text as="h2" size={500} weight="semibold">
Import Complete
</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
<Text size={200} style={{ marginTop: "12px" }}>
Total imported: {result.total_imported} | Skipped: {result.total_skipped} | Sources with errors: {result.errors_count}
</Text>
<div style={{ marginTop: "16px", textAlign: "right" }}>
<Button appearance="primary" onClick={onClose}>
Close
</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>
);
}
// ---------------------------------------------------------------------------
// 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 {
const styles = useStyles();
const safeUseBlocklistStyles = useBlocklistStyles as unknown as () => { root: string };
const styles = safeUseBlocklistStyles();
const { running, lastResult, error: importError, runNow } = useRunImport();
const [importResultOpen, setImportResultOpen] = useState(false);
@@ -951,18 +65,15 @@ export function BlocklistsPage(): React.JSX.Element {
</MessageBar>
)}
<SourcesSection onRunImport={handleRunImport} runImportRunning={running} />
<ScheduleSection onRunImport={handleRunImport} runImportRunning={running} />
<ImportLogSection />
<BlocklistSourcesSection onRunImport={handleRunImport} runImportRunning={running} />
<BlocklistScheduleSection onRunImport={handleRunImport} runImportRunning={running} />
<BlocklistImportLogSection />
<ImportResultDialog
open={importResultOpen}
result={lastResult}
onClose={() => {
setImportResultOpen(false);
}}
onClose={() => { setImportResultOpen(false); }}
/>
</div>
);
}

View File

@@ -15,6 +15,7 @@ import { DashboardFilterBar } from "../components/DashboardFilterBar";
import { ServerStatusBar } from "../components/ServerStatusBar";
import { TopCountriesBarChart } from "../components/TopCountriesBarChart";
import { TopCountriesPieChart } from "../components/TopCountriesPieChart";
import { useCommonSectionStyles } from "../theme/commonStyles";
import { useDashboardCountryData } from "../hooks/useDashboardCountryData";
import type { BanOriginFilter, TimeRange } from "../types/ban";
@@ -29,26 +30,6 @@ const useStyles = makeStyles({
flexDirection: "column",
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: {
display: "flex",
alignItems: "center",
@@ -93,6 +74,8 @@ export function DashboardPage(): React.JSX.Element {
const { countries, countryNames, isLoading: countryLoading, error: countryError, reload: reloadCountry } =
useDashboardCountryData(timeRange, originFilter);
const sectionStyles = useCommonSectionStyles();
return (
<div className={styles.root}>
{/* ------------------------------------------------------------------ */}
@@ -113,7 +96,7 @@ export function DashboardPage(): React.JSX.Element {
{/* ------------------------------------------------------------------ */}
{/* Ban Trend section */}
{/* ------------------------------------------------------------------ */}
<div className={styles.section}>
<div className={sectionStyles.section}>
<div className={styles.sectionHeader}>
<Text as="h2" size={500} weight="semibold">
Ban Trend
@@ -127,7 +110,7 @@ export function DashboardPage(): React.JSX.Element {
{/* ------------------------------------------------------------------ */}
{/* Charts section */}
{/* ------------------------------------------------------------------ */}
<div className={styles.section}>
<div className={sectionStyles.section}>
<div className={styles.sectionHeader}>
<Text as="h2" size={500} weight="semibold">
Top Countries
@@ -162,7 +145,7 @@ export function DashboardPage(): React.JSX.Element {
{/* ------------------------------------------------------------------ */}
{/* Ban list section */}
{/* ------------------------------------------------------------------ */}
<div className={styles.section}>
<div className={sectionStyles.section}>
<div className={styles.sectionHeader}>
<Text as="h2" size={500} weight="semibold">
Ban List

View File

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

View File

@@ -23,6 +23,7 @@ import {
makeStyles,
tokens,
} from "@fluentui/react-components";
import { useCommonSectionStyles } from "../theme/commonStyles";
import {
ArrowClockwiseRegular,
ArrowLeftRegular,
@@ -53,36 +54,7 @@ const useStyles = makeStyles({
alignItems: "center",
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: {
display: "flex",
alignItems: "center",
@@ -181,6 +153,7 @@ interface JailInfoProps {
function JailInfoSection({ jail, onRefresh, onStart, onStop, onSetIdle, onReload }: JailInfoProps): React.JSX.Element {
const styles = useStyles();
const sectionStyles = useCommonSectionStyles();
const navigate = useNavigate();
const [ctrlError, setCtrlError] = useState<string | null>(null);
@@ -206,8 +179,8 @@ function JailInfoSection({ jail, onRefresh, onStart, onStop, onSetIdle, onReload
};
return (
<div className={styles.section}>
<div className={styles.sectionHeader}>
<div className={sectionStyles.section}>
<div className={sectionStyles.sectionHeader}>
<div className={styles.headerRow}>
<Text
size={600}
@@ -334,10 +307,10 @@ function JailInfoSection({ jail, onRefresh, onStart, onStop, onSetIdle, onReload
// ---------------------------------------------------------------------------
function PatternsSection({ jail }: { jail: Jail }): React.JSX.Element {
const styles = useStyles();
const sectionStyles = useCommonSectionStyles();
return (
<div className={styles.section}>
<div className={styles.sectionHeader}>
<div className={sectionStyles.section}>
<div className={sectionStyles.sectionHeader}>
<Text as="h2" size={500} weight="semibold">
Log Paths &amp; Patterns
</Text>
@@ -374,12 +347,13 @@ function PatternsSection({ jail }: { jail: Jail }): React.JSX.Element {
function BantimeEscalationSection({ jail }: { jail: Jail }): React.JSX.Element | null {
const styles = useStyles();
const sectionStyles = useCommonSectionStyles();
const esc = jail.bantime_escalation;
if (!esc?.increment) return null;
return (
<div className={styles.section}>
<div className={styles.sectionHeader}>
<div className={sectionStyles.section}>
<div className={sectionStyles.sectionHeader}>
<Text as="h2" size={500} weight="semibold">
Ban-time Escalation
</Text>
@@ -445,6 +419,7 @@ function IgnoreListSection({
onToggleIgnoreSelf,
}: IgnoreListSectionProps): React.JSX.Element {
const styles = useStyles();
const sectionStyles = useCommonSectionStyles();
const [inputVal, setInputVal] = useState("");
const [opError, setOpError] = useState<string | null>(null);
@@ -470,8 +445,8 @@ function IgnoreListSection({
};
return (
<div className={styles.section}>
<div className={styles.sectionHeader}>
<div className={sectionStyles.section}>
<div className={sectionStyles.sectionHeader}>
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM }}>
<Text as="h2" size={500} weight="semibold">
Ignore List (IP Whitelist)

View File

@@ -33,6 +33,7 @@ import {
type TableColumnDefinition,
createTableColumn,
} from "@fluentui/react-components";
import { useCardStyles, useCommonSectionStyles } from "../theme/commonStyles";
import {
ArrowClockwiseRegular,
ArrowSyncRegular,
@@ -58,36 +59,7 @@ const useStyles = makeStyles({
flexDirection: "column",
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" },
centred: {
display: "flex",
@@ -117,20 +89,6 @@ const useStyles = makeStyles({
gap: tokens.spacingVerticalS,
marginTop: 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: {
display: "flex",
@@ -208,6 +166,7 @@ const jailColumns: TableColumnDefinition<JailSummary>[] = [
function JailOverviewSection(): React.JSX.Element {
const styles = useStyles();
const sectionStyles = useCommonSectionStyles();
const { jails, total, loading, error, refresh, startJail, stopJail, setIdle, reloadJail, reloadAll } =
useJails();
const [opError, setOpError] = useState<string | null>(null);
@@ -220,8 +179,8 @@ function JailOverviewSection(): React.JSX.Element {
};
return (
<div className={styles.section}>
<div className={styles.sectionHeader}>
<div className={sectionStyles.section}>
<div className={sectionStyles.sectionHeader}>
<Text as="h2" size={500} weight="semibold">
Jail Overview
{total > 0 && (
@@ -358,6 +317,7 @@ interface BanUnbanFormProps {
function BanUnbanForm({ jailNames, onBan, onUnban }: BanUnbanFormProps): React.JSX.Element {
const styles = useStyles();
const sectionStyles = useCommonSectionStyles();
const [banIpVal, setBanIpVal] = useState("");
const [banJail, setBanJail] = useState("");
const [unbanIpVal, setUnbanIpVal] = useState("");
@@ -415,8 +375,8 @@ function BanUnbanForm({ jailNames, onBan, onUnban }: BanUnbanFormProps): React.J
};
return (
<div className={styles.section}>
<div className={styles.sectionHeader}>
<div className={sectionStyles.section}>
<div className={sectionStyles.sectionHeader}>
<Text as="h2" size={500} weight="semibold">
Ban / Unban IP
</Text>
@@ -544,6 +504,8 @@ function BanUnbanForm({ jailNames, onBan, onUnban }: BanUnbanFormProps): React.J
function IpLookupSection(): React.JSX.Element {
const styles = useStyles();
const sectionStyles = useCommonSectionStyles();
const cardStyles = useCardStyles();
const { result, loading, error, lookup, clear } = useIpLookup();
const [inputVal, setInputVal] = useState("");
@@ -554,8 +516,8 @@ function IpLookupSection(): React.JSX.Element {
};
return (
<div className={styles.section}>
<div className={styles.sectionHeader}>
<div className={sectionStyles.section}>
<div className={sectionStyles.sectionHeader}>
<Text as="h2" size={500} weight="semibold">
IP Lookup
</Text>
@@ -595,7 +557,7 @@ function IpLookupSection(): React.JSX.Element {
)}
{result && (
<div className={styles.lookupResult}>
<div className={`${cardStyles.card} ${styles.lookupResult}`}>
<div className={styles.lookupRow}>
<Text className={styles.lookupLabel}>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);
}