Compare commits
10 Commits
2e3ac5f005
...
a92a8220c2
| Author | SHA1 | Date | |
|---|---|---|---|
| a92a8220c2 | |||
| 9c5b7ba091 | |||
| 20dd890746 | |||
| 7306b98a54 | |||
| e0c21dcc10 | |||
| e2876fc35c | |||
| 96370ee6aa | |||
| 2022bcde99 | |||
| 1f4ee360f6 | |||
| 9646b1c119 |
@@ -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`)
|
||||
|
||||
@@ -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` | L57–58 | 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 |
|
||||
317
Docs/Tasks.md
317
Docs/Tasks.md
@@ -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 1–7 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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
128
backend/app/services/log_service.py
Normal file
128
backend/app/services/log_service.py
Normal 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()]
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
21
backend/app/utils/config_file_utils.py
Normal file
21
backend/app/utils/config_file_utils.py
Normal 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",
|
||||
]
|
||||
20
backend/app/utils/jail_utils.py
Normal file
20
backend/app/utils/jail_utils.py
Normal 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,
|
||||
)
|
||||
14
backend/app/utils/log_utils.py
Normal file
14
backend/app/utils/log_utils.py
Normal 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)
|
||||
47
backend/app/utils/setup_utils.py
Normal file
47
backend/app/utils/setup_utils.py
Normal 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))
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 */}
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
|
||||
@@ -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."
|
||||
>
|
||||
|
||||
105
frontend/src/components/blocklist/BlocklistImportLogSection.tsx
Normal file
105
frontend/src/components/blocklist/BlocklistImportLogSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
175
frontend/src/components/blocklist/BlocklistScheduleSection.tsx
Normal file
175
frontend/src/components/blocklist/BlocklistScheduleSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
392
frontend/src/components/blocklist/BlocklistSourcesSection.tsx
Normal file
392
frontend/src/components/blocklist/BlocklistSourcesSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
62
frontend/src/components/blocklist/blocklistStyles.ts
Normal file
62
frontend/src/components/blocklist/blocklistStyles.ts
Normal 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 },
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
88
frontend/src/hooks/__tests__/useConfigItem.test.ts
Normal file
88
frontend/src/hooks/__tests__/useConfigItem.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}, []);
|
||||
|
||||
85
frontend/src/hooks/useConfigItem.ts
Normal file
85
frontend/src/hooks/useConfigItem.ts
Normal 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 };
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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} | Skipped:
|
||||
{result.total_skipped} | 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & 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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
30
frontend/src/theme/commonStyles.ts
Normal file
30
frontend/src/theme/commonStyles.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
14
frontend/src/utils/fetchError.ts
Normal file
14
frontend/src/utils/fetchError.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user