Compare commits
19 Commits
v0.9.14
...
2e3ac5f005
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e3ac5f005 | |||
| 90e42e96b4 | |||
| aff67b3a78 | |||
| ffaa5c3adb | |||
| 5a49106f4d | |||
| 452901913f | |||
| 25b4ebbd96 | |||
| 7627ae7edb | |||
| 377cc7ac88 | |||
| 77711e202d | |||
| 3568e9caf3 | |||
| 250bb1a2e5 | |||
| 6515164d53 | |||
| 25d43ffb96 | |||
| 29762664d7 | |||
| a2b8e14cbc | |||
| 68114924bb | |||
| 7866f9cbb2 | |||
| dcd8059b27 |
@@ -1 +1 @@
|
||||
v0.9.14
|
||||
v0.9.4
|
||||
|
||||
@@ -18,8 +18,8 @@ logpath = /dev/null
|
||||
backend = auto
|
||||
maxretry = 1
|
||||
findtime = 1d
|
||||
# Block imported IPs for 24 hours.
|
||||
bantime = 86400
|
||||
# Block imported IPs for one week.
|
||||
bantime = 1w
|
||||
|
||||
# Never ban the Docker bridge network or localhost.
|
||||
ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12
|
||||
|
||||
@@ -56,8 +56,11 @@ echo " Registry : ${REGISTRY}"
|
||||
echo " Tag : ${TAG}"
|
||||
echo "============================================"
|
||||
|
||||
log "Logging in to ${REGISTRY}"
|
||||
"${ENGINE}" login "${REGISTRY}"
|
||||
if [[ "${ENGINE}" == "podman" ]]; then
|
||||
if ! podman login --get-login "${REGISTRY}" &>/dev/null; then
|
||||
err "Not logged in. Run:\n podman login ${REGISTRY}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Build
|
||||
|
||||
@@ -69,24 +69,18 @@ sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${FRONT_VERSION}\"/" "${FRONT_P
|
||||
echo "frontend/package.json version updated → ${FRONT_VERSION}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Push containers
|
||||
# ---------------------------------------------------------------------------
|
||||
bash "${SCRIPT_DIR}/push.sh" "${NEW_TAG}"
|
||||
bash "${SCRIPT_DIR}/push.sh"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Git tag (local only; push after container build)
|
||||
# Git tag
|
||||
# ---------------------------------------------------------------------------
|
||||
cd "${SCRIPT_DIR}/.."
|
||||
git add Docker/VERSION frontend/package.json
|
||||
git commit -m "chore: release ${NEW_TAG}"
|
||||
git tag -a "${NEW_TAG}" -m "Release ${NEW_TAG}"
|
||||
echo "Local git commit + tag ${NEW_TAG} created."
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Push git commits & tag
|
||||
# ---------------------------------------------------------------------------
|
||||
git push origin HEAD
|
||||
git push origin "${NEW_TAG}"
|
||||
echo "Git commit and tag ${NEW_TAG} pushed."
|
||||
echo "Git tag ${NEW_TAG} created and pushed."
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Push
|
||||
# ---------------------------------------------------------------------------
|
||||
bash "${SCRIPT_DIR}/push.sh" "${NEW_TAG}"
|
||||
bash "${SCRIPT_DIR}/push.sh"
|
||||
|
||||
@@ -82,12 +82,10 @@ 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)
|
||||
│ ├── `db.py` # Database connection and initialization
|
||||
│ ├── `exceptions.py` # Shared domain exception classes
|
||||
│ ├── `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)
|
||||
│ ├── 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
|
||||
@@ -113,12 +111,6 @@ 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
|
||||
@@ -127,18 +119,17 @@ 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│ │ ├── 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
|
||||
│ │ ├── blocklist_repo.py # Blocklist sources and import log 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)│ │ ├── geo_re_resolve.py # Periodic re-resolution of stale geo cache records│ │ └── health_check.py # Periodic fail2ban connectivity probe
|
||||
│ │ ├── geo_cache_flush.py # Periodic geo cache persistence (dirty-set flush to SQLite)
|
||||
│ │ └── 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│ ├── 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.)
|
||||
│ ├── time_utils.py # Timezone-aware datetime helpers
|
||||
│ └── 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
|
||||
@@ -167,9 +158,8 @@ 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.
|
||||
|
||||
@@ -185,8 +175,7 @@ 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 |
|
||||
| `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) |
|
||||
| `conffile_parser.py` | Parses fail2ban `.conf` files into structured Python types (jail config, filter config, action config); also serialises back to text |
|
||||
| `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 |
|
||||
@@ -202,26 +191,15 @@ 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:
|
||||
|
||||
| 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 |
|
||||
- **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`)
|
||||
|
||||
#### Tasks (`app/tasks/`)
|
||||
|
||||
@@ -231,7 +209,6 @@ 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/`)
|
||||
@@ -242,16 +219,7 @@ 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,3 +3,193 @@
|
||||
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,3 +7,320 @@ 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.
|
||||
|
||||
@@ -1,68 +1 @@
|
||||
"""BanGUI backend application package.
|
||||
|
||||
This package exposes the application version based on the project metadata.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
import importlib.metadata
|
||||
import tomllib
|
||||
|
||||
PACKAGE_NAME: Final[str] = "bangui-backend"
|
||||
|
||||
|
||||
def _read_pyproject_version() -> str:
|
||||
"""Read the project version from ``pyproject.toml``.
|
||||
|
||||
This is used as a fallback when the package metadata is not available (e.g.
|
||||
when running directly from a source checkout without installing the package).
|
||||
"""
|
||||
|
||||
project_root = Path(__file__).resolve().parents[1]
|
||||
pyproject_path = project_root / "pyproject.toml"
|
||||
if not pyproject_path.exists():
|
||||
raise FileNotFoundError(f"pyproject.toml not found at {pyproject_path}")
|
||||
|
||||
data = tomllib.loads(pyproject_path.read_text(encoding="utf-8"))
|
||||
return str(data["project"]["version"])
|
||||
|
||||
|
||||
def _read_docker_version() -> str:
|
||||
"""Read the project version from ``Docker/VERSION``.
|
||||
|
||||
This file is the single source of truth for release scripts and must not be
|
||||
out of sync with the frontend and backend versions.
|
||||
"""
|
||||
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
version_path = repo_root / "Docker" / "VERSION"
|
||||
if not version_path.exists():
|
||||
raise FileNotFoundError(f"Docker/VERSION not found at {version_path}")
|
||||
|
||||
version = version_path.read_text(encoding="utf-8").strip()
|
||||
return version.lstrip("v")
|
||||
|
||||
|
||||
def _read_version() -> str:
|
||||
"""Return the current package version.
|
||||
|
||||
Prefer the release artifact in ``Docker/VERSION`` when available so the
|
||||
backend version always matches what the release tooling publishes.
|
||||
|
||||
If that file is missing (e.g. in a production wheel or a local checkout),
|
||||
fall back to ``pyproject.toml`` and finally installed package metadata.
|
||||
"""
|
||||
|
||||
try:
|
||||
return _read_docker_version()
|
||||
except FileNotFoundError:
|
||||
try:
|
||||
return _read_pyproject_version()
|
||||
except FileNotFoundError:
|
||||
return importlib.metadata.version(PACKAGE_NAME)
|
||||
|
||||
|
||||
__version__ = _read_version()
|
||||
"""BanGUI backend application package."""
|
||||
|
||||
@@ -6,10 +6,6 @@ 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."""
|
||||
@@ -25,29 +21,3 @@ 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)
|
||||
|
||||
@@ -31,7 +31,6 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from app import __version__
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import init_db
|
||||
from app.routers import (
|
||||
@@ -362,7 +361,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
app: FastAPI = FastAPI(
|
||||
title="BanGUI",
|
||||
description="Web interface for monitoring, managing, and configuring fail2ban.",
|
||||
version=__version__,
|
||||
version="0.1.0",
|
||||
lifespan=_lifespan,
|
||||
)
|
||||
|
||||
|
||||
@@ -1001,7 +1001,7 @@ class ServiceStatusResponse(BaseModel):
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
online: bool = Field(..., description="Whether fail2ban is reachable via its socket.")
|
||||
version: str | None = Field(default=None, description="BanGUI application version (or None when offline).")
|
||||
version: str | None = Field(default=None, description="fail2ban version string, or None when offline.")
|
||||
jail_count: int = Field(default=0, ge=0, description="Number of currently active jails.")
|
||||
total_bans: int = Field(default=0, ge=0, description="Aggregated current ban count across all jails.")
|
||||
total_failures: int = Field(default=0, ge=0, description="Aggregated current failure count across all jails.")
|
||||
|
||||
@@ -294,7 +294,6 @@ async def get_history_page(
|
||||
since: int | None = None,
|
||||
jail: str | None = None,
|
||||
ip_filter: str | None = None,
|
||||
origin: BanOrigin | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 100,
|
||||
) -> tuple[list[HistoryRecord], int]:
|
||||
@@ -315,12 +314,6 @@ async def get_history_page(
|
||||
wheres.append("ip LIKE ?")
|
||||
params.append(f"{ip_filter}%")
|
||||
|
||||
origin_clause, origin_params = _origin_sql_filter(origin)
|
||||
if origin_clause:
|
||||
origin_clause_clean = origin_clause.removeprefix(" AND ")
|
||||
wheres.append(origin_clause_clean)
|
||||
params.extend(origin_params)
|
||||
|
||||
where_sql: str = ("WHERE " + " AND ".join(wheres)) if wheres else ""
|
||||
|
||||
effective_page_size: int = page_size
|
||||
|
||||
@@ -131,8 +131,6 @@ 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, log_service
|
||||
from app.services import config_service, jail_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 log_service.test_regex(body)
|
||||
return config_service.test_regex(body)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -578,7 +578,7 @@ async def preview_log(
|
||||
Returns:
|
||||
:class:`~app.models.config.LogPreviewResponse` with per-line results.
|
||||
"""
|
||||
return await log_service.preview_log(body)
|
||||
return await config_service.preview_log(body)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1666,12 +1666,7 @@ 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,
|
||||
probe_fn=health_service.probe,
|
||||
)
|
||||
return await config_service.get_service_status(socket_path)
|
||||
except Fail2BanConnectionError as exc:
|
||||
raise _bad_gateway(exc) from exc
|
||||
|
||||
@@ -19,7 +19,6 @@ if TYPE_CHECKING:
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
|
||||
from app import __version__
|
||||
from app.dependencies import AuthDep
|
||||
from app.models.ban import (
|
||||
BanOrigin,
|
||||
@@ -70,7 +69,6 @@ async def get_server_status(
|
||||
"server_status",
|
||||
ServerStatus(online=False),
|
||||
)
|
||||
cached.version = __version__
|
||||
return ServerStatusResponse(status=cached)
|
||||
|
||||
|
||||
|
||||
@@ -51,8 +51,8 @@ from app.models.file_config import (
|
||||
JailConfigFileEnabledUpdate,
|
||||
JailConfigFilesResponse,
|
||||
)
|
||||
from app.services import raw_config_io_service
|
||||
from app.services.raw_config_io_service import (
|
||||
from app.services import file_config_service
|
||||
from app.services.file_config_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 raw_config_io_service.list_jail_config_files(config_dir)
|
||||
return await file_config_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 raw_config_io_service.get_jail_config_file(config_dir, filename)
|
||||
return await file_config_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 raw_config_io_service.write_jail_config_file(config_dir, filename, body)
|
||||
await file_config_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 raw_config_io_service.set_jail_config_enabled(
|
||||
await file_config_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 raw_config_io_service.create_jail_config_file(config_dir, body)
|
||||
filename = await file_config_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 raw_config_io_service.get_filter_file(config_dir, name)
|
||||
return await file_config_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 raw_config_io_service.write_filter_file(config_dir, name, body)
|
||||
await file_config_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 raw_config_io_service.create_filter_file(config_dir, body)
|
||||
filename = await file_config_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 raw_config_io_service.list_action_files(config_dir)
|
||||
return await file_config_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 raw_config_io_service.get_action_file(config_dir, name)
|
||||
return await file_config_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 raw_config_io_service.write_action_file(config_dir, name, body)
|
||||
await file_config_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 raw_config_io_service.create_action_file(config_dir, body)
|
||||
filename = await file_config_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 raw_config_io_service.get_parsed_filter_file(config_dir, name)
|
||||
return await file_config_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 raw_config_io_service.update_parsed_filter_file(config_dir, name, body)
|
||||
await file_config_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 raw_config_io_service.get_parsed_action_file(config_dir, name)
|
||||
return await file_config_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 raw_config_io_service.update_parsed_action_file(config_dir, name, body)
|
||||
await file_config_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 raw_config_io_service.get_parsed_jail_file(config_dir, filename)
|
||||
return await file_config_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 raw_config_io_service.update_parsed_jail_file(config_dir, filename, body)
|
||||
await file_config_service.update_parsed_jail_file(config_dir, filename, body)
|
||||
except ConfigFileNameError as exc:
|
||||
raise _bad_request(str(exc)) from exc
|
||||
except ConfigFileNotFoundError:
|
||||
|
||||
@@ -23,7 +23,7 @@ if TYPE_CHECKING:
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
|
||||
from app.dependencies import AuthDep
|
||||
from app.models.ban import BanOrigin, TimeRange
|
||||
from app.models.ban import TimeRange
|
||||
from app.models.history import HistoryListResponse, IpDetailResponse
|
||||
from app.services import geo_service, history_service
|
||||
|
||||
@@ -52,10 +52,6 @@ async def get_history(
|
||||
default=None,
|
||||
description="Restrict results to IPs matching this prefix.",
|
||||
),
|
||||
origin: BanOrigin | None = Query(
|
||||
default=None,
|
||||
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",
|
||||
),
|
||||
page: int = Query(default=1, ge=1, description="1-based page number."),
|
||||
page_size: int = Query(
|
||||
default=_DEFAULT_PAGE_SIZE,
|
||||
@@ -93,7 +89,6 @@ async def get_history(
|
||||
range_=range,
|
||||
jail=jail,
|
||||
ip_filter=ip,
|
||||
origin=origin,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
geo_enricher=_enricher,
|
||||
|
||||
@@ -26,13 +26,14 @@ from app.models.config import (
|
||||
AssignActionRequest,
|
||||
)
|
||||
from app.exceptions import JailNotFoundError
|
||||
from app.utils.config_file_utils import (
|
||||
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 ConfigWriteError, JailNotFoundInConfigError
|
||||
from app.utils import conffile_parser
|
||||
from app.utils.jail_utils import reload_jails
|
||||
|
||||
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
||||
|
||||
@@ -792,7 +793,7 @@ async def update_action(
|
||||
|
||||
if do_reload:
|
||||
try:
|
||||
await reload_jails(socket_path)
|
||||
await jail_service.reload_all(socket_path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning(
|
||||
"reload_after_action_update_failed",
|
||||
@@ -861,7 +862,7 @@ async def create_action(
|
||||
|
||||
if do_reload:
|
||||
try:
|
||||
await reload_jails(socket_path)
|
||||
await jail_service.reload_all(socket_path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning(
|
||||
"reload_after_action_create_failed",
|
||||
@@ -991,7 +992,7 @@ async def assign_action_to_jail(
|
||||
|
||||
if do_reload:
|
||||
try:
|
||||
await reload_jails(socket_path)
|
||||
await jail_service.reload_all(socket_path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning(
|
||||
"reload_after_assign_action_failed",
|
||||
@@ -1053,7 +1054,7 @@ async def remove_action_from_jail(
|
||||
|
||||
if do_reload:
|
||||
try:
|
||||
await reload_jails(socket_path)
|
||||
await jail_service.reload_all(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.utils.setup_utils import get_password_hash
|
||||
from app.services import setup_service
|
||||
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 get_password_hash(db)
|
||||
stored_hash = await setup_service.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,9 +77,6 @@ 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.
|
||||
|
||||
@@ -94,11 +91,10 @@ def _since_unix(range_: TimeRange) -> int:
|
||||
range_: One of the supported time-range presets.
|
||||
|
||||
Returns:
|
||||
Unix timestamp (seconds since epoch) equal to *now − range_* with a
|
||||
small slack window for clock drift and test seeding delays.
|
||||
Unix timestamp (seconds since epoch) equal to *now − range_*.
|
||||
"""
|
||||
seconds: int = TIME_RANGE_SECONDS[range_]
|
||||
return int(time.time()) - seconds - _TIME_RANGE_SLACK_SECONDS
|
||||
return int(time.time()) - seconds
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -14,9 +14,7 @@ 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
|
||||
@@ -31,7 +29,6 @@ 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
|
||||
|
||||
@@ -247,7 +244,6 @@ 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.
|
||||
|
||||
@@ -305,14 +301,8 @@ async def import_source(
|
||||
ban_error: str | None = None
|
||||
imported_ips: list[str] = []
|
||||
|
||||
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
|
||||
# Import jail_service here to avoid circular import at module level.
|
||||
from app.services import jail_service # noqa: PLC0415
|
||||
|
||||
for line in content.splitlines():
|
||||
stripped = line.strip()
|
||||
@@ -325,10 +315,10 @@ async def import_source(
|
||||
continue
|
||||
|
||||
try:
|
||||
await ban_ip_fn(socket_path, BLOCKLIST_JAIL, stripped)
|
||||
await jail_service.ban_ip(socket_path, BLOCKLIST_JAIL, stripped)
|
||||
imported += 1
|
||||
imported_ips.append(stripped)
|
||||
except JailNotFoundError as exc:
|
||||
except jail_service.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)
|
||||
@@ -397,7 +387,6 @@ 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.
|
||||
|
||||
@@ -428,7 +417,6 @@ 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 FilterInvalidRegexError, JailNotFoundError
|
||||
from app.exceptions import JailNotFoundError
|
||||
from app.services import jail_service
|
||||
from app.utils import conffile_parser
|
||||
from app.utils.jail_utils import reload_jails
|
||||
from app.utils.fail2ban_client import (
|
||||
Fail2BanClient,
|
||||
Fail2BanConnectionError,
|
||||
@@ -65,41 +65,6 @@ 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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -203,6 +168,21 @@ 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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1226,7 +1206,7 @@ async def activate_jail(
|
||||
# Activation reload — if it fails, roll back immediately #
|
||||
# ---------------------------------------------------------------------- #
|
||||
try:
|
||||
await _reload_all(socket_path, include_jails=[name])
|
||||
await jail_service.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.
|
||||
@@ -1369,7 +1349,7 @@ async def _rollback_activation_async(
|
||||
|
||||
# Step 2 — reload fail2ban with the restored config.
|
||||
try:
|
||||
await _reload_all(socket_path)
|
||||
await jail_service.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))
|
||||
@@ -1436,7 +1416,7 @@ async def deactivate_jail(
|
||||
)
|
||||
|
||||
try:
|
||||
await _reload_all(socket_path, exclude_jails=[name])
|
||||
await jail_service.reload_all(socket_path, exclude_jails=[name])
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning("reload_after_deactivate_failed", jail=name, error=str(exc))
|
||||
|
||||
@@ -1992,7 +1972,7 @@ async def update_filter(
|
||||
|
||||
if do_reload:
|
||||
try:
|
||||
await _reload_all(socket_path)
|
||||
await jail_service.reload_all(socket_path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning(
|
||||
"reload_after_filter_update_failed",
|
||||
@@ -2067,7 +2047,7 @@ async def create_filter(
|
||||
|
||||
if do_reload:
|
||||
try:
|
||||
await _reload_all(socket_path)
|
||||
await jail_service.reload_all(socket_path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning(
|
||||
"reload_after_filter_create_failed",
|
||||
@@ -2194,7 +2174,7 @@ async def assign_filter_to_jail(
|
||||
|
||||
if do_reload:
|
||||
try:
|
||||
await _reload_all(socket_path)
|
||||
await jail_service.reload_all(socket_path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning(
|
||||
"reload_after_assign_filter_failed",
|
||||
@@ -2846,7 +2826,7 @@ async def update_action(
|
||||
|
||||
if do_reload:
|
||||
try:
|
||||
await _reload_all(socket_path)
|
||||
await jail_service.reload_all(socket_path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning(
|
||||
"reload_after_action_update_failed",
|
||||
@@ -2915,7 +2895,7 @@ async def create_action(
|
||||
|
||||
if do_reload:
|
||||
try:
|
||||
await _reload_all(socket_path)
|
||||
await jail_service.reload_all(socket_path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning(
|
||||
"reload_after_action_create_failed",
|
||||
@@ -3046,7 +3026,7 @@ async def assign_action_to_jail(
|
||||
|
||||
if do_reload:
|
||||
try:
|
||||
await _reload_all(socket_path)
|
||||
await jail_service.reload_all(socket_path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning(
|
||||
"reload_after_assign_action_failed",
|
||||
@@ -3108,7 +3088,7 @@ async def remove_action_from_jail(
|
||||
|
||||
if do_reload:
|
||||
try:
|
||||
await _reload_all(socket_path)
|
||||
await jail_service.reload_all(socket_path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning(
|
||||
"reload_after_remove_action_failed",
|
||||
|
||||
@@ -23,12 +23,8 @@ import structlog
|
||||
from app.utils.fail2ban_client import Fail2BanCommand, Fail2BanResponse, Fail2BanToken
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
import aiosqlite
|
||||
|
||||
from app import __version__
|
||||
from app.exceptions import ConfigOperationError, ConfigValidationError, JailNotFoundError
|
||||
from app.models.config import (
|
||||
AddLogPathRequest,
|
||||
BantimeEscalation,
|
||||
@@ -39,6 +35,7 @@ from app.models.config import (
|
||||
JailConfigListResponse,
|
||||
JailConfigResponse,
|
||||
JailConfigUpdate,
|
||||
LogPreviewLine,
|
||||
LogPreviewRequest,
|
||||
LogPreviewResponse,
|
||||
MapColorThresholdsResponse,
|
||||
@@ -47,15 +44,9 @@ from app.models.config import (
|
||||
RegexTestResponse,
|
||||
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
|
||||
from app.utils.log_utils import test_regex as util_test_regex
|
||||
from app.utils.setup_utils import (
|
||||
get_map_color_thresholds as util_get_map_color_thresholds,
|
||||
)
|
||||
from app.utils.setup_utils import (
|
||||
set_map_color_thresholds as util_set_map_color_thresholds,
|
||||
)
|
||||
|
||||
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
||||
|
||||
@@ -503,8 +494,27 @@ async def update_global_config(socket_path: str, update: GlobalConfigUpdate) ->
|
||||
|
||||
|
||||
def test_regex(request: RegexTestRequest) -> RegexTestResponse:
|
||||
"""Proxy to log utilities for regex test without service imports."""
|
||||
return util_test_regex(request)
|
||||
"""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])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -582,14 +592,101 @@ 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,
|
||||
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)
|
||||
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()]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -606,7 +703,7 @@ async def get_map_color_thresholds(db: aiosqlite.Connection) -> MapColorThreshol
|
||||
Returns:
|
||||
A :class:`MapColorThresholdsResponse` containing the three threshold values.
|
||||
"""
|
||||
high, medium, low = await util_get_map_color_thresholds(db)
|
||||
high, medium, low = await setup_service.get_map_color_thresholds(db)
|
||||
return MapColorThresholdsResponse(
|
||||
threshold_high=high,
|
||||
threshold_medium=medium,
|
||||
@@ -627,7 +724,7 @@ async def update_map_color_thresholds(
|
||||
Raises:
|
||||
ValueError: If validation fails (thresholds must satisfy high > medium > low).
|
||||
"""
|
||||
await util_set_map_color_thresholds(
|
||||
await setup_service.set_map_color_thresholds(
|
||||
db,
|
||||
threshold_high=update.threshold_high,
|
||||
threshold_medium=update.threshold_medium,
|
||||
@@ -649,7 +746,16 @@ _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."""
|
||||
"""Count the total number of lines in *file_path* synchronously.
|
||||
|
||||
Uses a memory-efficient buffered read to avoid loading the whole file.
|
||||
|
||||
Args:
|
||||
file_path: Absolute path to the file.
|
||||
|
||||
Returns:
|
||||
Total number of lines in the file.
|
||||
"""
|
||||
count = 0
|
||||
with open(file_path, "rb") as fh:
|
||||
for chunk in iter(lambda: fh.read(65536), b""):
|
||||
@@ -657,32 +763,6 @@ 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,
|
||||
@@ -777,27 +857,22 @@ async def read_fail2ban_log(
|
||||
)
|
||||
|
||||
|
||||
async def get_service_status(
|
||||
socket_path: str,
|
||||
probe_fn: Callable[[str], Awaitable[ServiceStatusResponse]] | None = None,
|
||||
) -> ServiceStatusResponse:
|
||||
async def get_service_status(socket_path: str) -> ServiceStatusResponse:
|
||||
"""Return fail2ban service health status with log configuration.
|
||||
|
||||
Delegates to an injectable *probe_fn* (defaults to
|
||||
:func:`~app.services.health_service.probe`). This avoids direct service-to-
|
||||
service imports inside this module.
|
||||
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.
|
||||
|
||||
Args:
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
probe_fn: Optional probe function.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.config.ServiceStatusResponse`.
|
||||
"""
|
||||
if probe_fn is None:
|
||||
raise ValueError("probe_fn is required to avoid service-to-service coupling")
|
||||
from app.services.health_service import probe # lazy import avoids circular dep
|
||||
|
||||
server_status = await probe_fn(socket_path)
|
||||
server_status = await probe(socket_path)
|
||||
|
||||
if server_status.online:
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
@@ -819,7 +894,7 @@ async def get_service_status(
|
||||
|
||||
return ServiceStatusResponse(
|
||||
online=server_status.online,
|
||||
version=__version__,
|
||||
version=server_status.version,
|
||||
jail_count=server_status.active_jails,
|
||||
total_bans=server_status.total_bans,
|
||||
total_failures=server_status.total_failures,
|
||||
|
||||
@@ -17,22 +17,23 @@ from pathlib import Path
|
||||
|
||||
import structlog
|
||||
|
||||
from app.exceptions import FilterInvalidRegexError
|
||||
from app.models.config import (
|
||||
AssignFilterRequest,
|
||||
FilterConfig,
|
||||
FilterConfigUpdate,
|
||||
FilterCreateRequest,
|
||||
FilterListResponse,
|
||||
FilterUpdateRequest,
|
||||
AssignFilterRequest,
|
||||
)
|
||||
from app.services.config_file_service import _TRUE_VALUES, ConfigWriteError, JailNotFoundInConfigError
|
||||
from app.utils import conffile_parser
|
||||
from app.utils.config_file_utils import (
|
||||
_get_active_jail_names,
|
||||
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.utils.jail_utils import reload_jails
|
||||
from app.utils import conffile_parser
|
||||
|
||||
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
||||
|
||||
@@ -82,6 +83,21 @@ 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."""
|
||||
|
||||
@@ -95,7 +111,6 @@ class JailNameError(Exception):
|
||||
"""Raised when a jail name contains invalid characters."""
|
||||
|
||||
|
||||
_SAFE_FILTER_NAME_RE: re.Pattern[str] = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||
_SAFE_JAIL_NAME_RE: re.Pattern[str] = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||
|
||||
|
||||
@@ -708,7 +723,7 @@ async def update_filter(
|
||||
|
||||
if do_reload:
|
||||
try:
|
||||
await reload_jails(socket_path)
|
||||
await jail_service.reload_all(socket_path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning(
|
||||
"reload_after_filter_update_failed",
|
||||
@@ -783,7 +798,7 @@ async def create_filter(
|
||||
|
||||
if do_reload:
|
||||
try:
|
||||
await reload_jails(socket_path)
|
||||
await jail_service.reload_all(socket_path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning(
|
||||
"reload_after_filter_create_failed",
|
||||
@@ -909,7 +924,7 @@ async def assign_filter_to_jail(
|
||||
|
||||
if do_reload:
|
||||
try:
|
||||
await reload_jails(socket_path)
|
||||
await jail_service.reload_all(socket_path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning(
|
||||
"reload_after_assign_filter_failed",
|
||||
|
||||
@@ -20,7 +20,9 @@ Usage::
|
||||
|
||||
import aiohttp
|
||||
import aiosqlite
|
||||
# Use the geo_service directly in application startup
|
||||
from app.services import geo_service
|
||||
|
||||
# warm the cache from the persistent store at startup
|
||||
async with aiosqlite.connect("bangui.db") as db:
|
||||
await geo_service.load_cache_from_db(db)
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import structlog
|
||||
if TYPE_CHECKING:
|
||||
from app.models.geo import GeoEnricher
|
||||
|
||||
from app.models.ban import TIME_RANGE_SECONDS, BanOrigin, TimeRange
|
||||
from app.models.ban import TIME_RANGE_SECONDS, TimeRange
|
||||
from app.models.history import (
|
||||
HistoryBanItem,
|
||||
HistoryListResponse,
|
||||
@@ -62,7 +62,6 @@ async def list_history(
|
||||
range_: TimeRange | None = None,
|
||||
jail: str | None = None,
|
||||
ip_filter: str | None = None,
|
||||
origin: BanOrigin | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = _DEFAULT_PAGE_SIZE,
|
||||
geo_enricher: GeoEnricher | None = None,
|
||||
@@ -109,7 +108,6 @@ async def list_history(
|
||||
since=since,
|
||||
jail=jail,
|
||||
ip_filter=ip_filter,
|
||||
origin=origin,
|
||||
page=page,
|
||||
page_size=effective_page_size,
|
||||
)
|
||||
|
||||
@@ -26,17 +26,16 @@ from app.models.config import (
|
||||
InactiveJail,
|
||||
InactiveJailListResponse,
|
||||
JailActivationResponse,
|
||||
JailValidationIssue,
|
||||
JailValidationResult,
|
||||
RollbackResponse,
|
||||
)
|
||||
from app.utils.config_file_utils import (
|
||||
_build_inactive_jail,
|
||||
_get_active_jail_names,
|
||||
_parse_jails_sync,
|
||||
_validate_jail_config_sync,
|
||||
from app.services import config_file_service, jail_service
|
||||
from app.utils.fail2ban_client import (
|
||||
Fail2BanClient,
|
||||
Fail2BanConnectionError,
|
||||
Fail2BanResponse,
|
||||
)
|
||||
from app.utils.fail2ban_client import Fail2BanClient
|
||||
from app.utils.jail_utils import reload_jails
|
||||
|
||||
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
||||
|
||||
@@ -305,7 +304,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.exceptions import FilterInvalidRegexError
|
||||
from app.services.filter_config_service import FilterInvalidRegexError
|
||||
raise FilterInvalidRegexError(pattern, str(exc)) from exc
|
||||
|
||||
|
||||
@@ -461,7 +460,12 @@ async def start_daemon(start_cmd_parts: list[str]) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
# Shared functions from config_file_service are imported from app.utils.config_file_utils
|
||||
# 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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -620,7 +624,7 @@ async def activate_jail(
|
||||
# Activation reload — if it fails, roll back immediately #
|
||||
# ---------------------------------------------------------------------- #
|
||||
try:
|
||||
await reload_jails(socket_path, include_jails=[name])
|
||||
await jail_service.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.
|
||||
@@ -763,7 +767,7 @@ async def _rollback_activation_async(
|
||||
|
||||
# Step 2 — reload fail2ban with the restored config.
|
||||
try:
|
||||
await reload_jails(socket_path)
|
||||
await jail_service.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))
|
||||
@@ -830,7 +834,7 @@ async def deactivate_jail(
|
||||
)
|
||||
|
||||
try:
|
||||
await reload_jails(socket_path, exclude_jails=[name])
|
||||
await jail_service.reload_all(socket_path, exclude_jails=[name])
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning("reload_after_deactivate_failed", jail=name, error=str(exc))
|
||||
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
"""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,20 +102,30 @@ 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."""
|
||||
return await util_get_password_hash(db)
|
||||
"""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)
|
||||
|
||||
|
||||
async def get_timezone(db: aiosqlite.Connection) -> str:
|
||||
"""Return the configured IANA timezone string."""
|
||||
"""Return the configured IANA timezone string.
|
||||
|
||||
Falls back to ``"UTC"`` when no timezone has been stored (e.g. before
|
||||
setup completes or for legacy databases).
|
||||
|
||||
Args:
|
||||
db: Active aiosqlite connection.
|
||||
|
||||
Returns:
|
||||
An IANA timezone identifier such as ``"Europe/Berlin"`` or ``"UTC"``.
|
||||
"""
|
||||
tz = await settings_repo.get_setting(db, _KEY_TIMEZONE)
|
||||
return tz if tz else "UTC"
|
||||
|
||||
@@ -123,8 +133,31 @@ 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)."""
|
||||
return await util_get_map_color_thresholds(db)
|
||||
"""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,
|
||||
)
|
||||
|
||||
|
||||
async def set_map_color_thresholds(
|
||||
@@ -134,12 +167,31 @@ async def set_map_color_thresholds(
|
||||
threshold_medium: int,
|
||||
threshold_low: int,
|
||||
) -> None:
|
||||
"""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,
|
||||
"""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)
|
||||
)
|
||||
log.info(
|
||||
"map_color_thresholds_updated",
|
||||
|
||||
@@ -43,15 +43,9 @@ 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,
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
"""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",
|
||||
]
|
||||
@@ -49,7 +49,7 @@ logpath = /dev/null
|
||||
backend = auto
|
||||
maxretry = 1
|
||||
findtime = 1d
|
||||
bantime = 86400
|
||||
bantime = 1w
|
||||
ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12
|
||||
"""
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
"""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,
|
||||
)
|
||||
@@ -1,14 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,47 +0,0 @@
|
||||
"""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))
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "bangui-backend"
|
||||
version = "0.9.8"
|
||||
version = "0.9.0"
|
||||
description = "BanGUI backend — fail2ban web management interface"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
|
||||
@@ -37,15 +37,9 @@ 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",
|
||||
|
||||
@@ -1,276 +0,0 @@
|
||||
"""Regression tests for the four 500-error bugs discovered on 2026-03-22.
|
||||
|
||||
Each test targets the exact code path that caused a 500 Internal Server Error.
|
||||
These tests call the **real** service/repository functions (not the router)
|
||||
so they fail even if the route layer is mocked in router-level tests.
|
||||
|
||||
Bugs covered:
|
||||
1. ``list_history`` rejected the ``origin`` keyword argument (TypeError).
|
||||
2. ``jail_config_service`` used ``_get_active_jail_names`` without importing it.
|
||||
3. ``filter_config_service`` used ``_parse_jails_sync`` / ``_get_active_jail_names``
|
||||
without importing them.
|
||||
4. ``config_service.get_service_status`` omitted the required ``bangui_version``
|
||||
field from the ``ServiceStatusResponse`` constructor (Pydantic ValidationError).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import aiosqlite
|
||||
import pytest
|
||||
|
||||
# ── Bug 1 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestHistoryOriginParameter:
|
||||
"""Bug 1: ``origin`` parameter must be threaded through service → repo."""
|
||||
|
||||
# -- Service layer --
|
||||
|
||||
async def test_list_history_accepts_origin_kwarg(self) -> None:
|
||||
"""``history_service.list_history()`` must accept an ``origin`` keyword."""
|
||||
from app.services import history_service
|
||||
|
||||
sig = inspect.signature(history_service.list_history)
|
||||
assert "origin" in sig.parameters, (
|
||||
"list_history() is missing the 'origin' parameter — "
|
||||
"the router passes origin=… which would cause a TypeError"
|
||||
)
|
||||
|
||||
async def test_list_history_forwards_origin_to_repo(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""``list_history(origin='blocklist')`` must forward origin to the DB repo."""
|
||||
from app.services import history_service
|
||||
|
||||
db_path = str(tmp_path / "f2b.db")
|
||||
async with aiosqlite.connect(db_path) as db:
|
||||
await db.execute(
|
||||
"CREATE TABLE jails (name TEXT, enabled INTEGER DEFAULT 1)"
|
||||
)
|
||||
await db.execute(
|
||||
"CREATE TABLE bans "
|
||||
"(jail TEXT, ip TEXT, timeofban INTEGER, bantime INTEGER, "
|
||||
"bancount INTEGER DEFAULT 1, data JSON)"
|
||||
)
|
||||
await db.execute(
|
||||
"INSERT INTO bans VALUES (?, ?, ?, ?, ?, ?)",
|
||||
("blocklist-import", "10.0.0.1", int(time.time()), 3600, 1, "{}"),
|
||||
)
|
||||
await db.execute(
|
||||
"INSERT INTO bans VALUES (?, ?, ?, ?, ?, ?)",
|
||||
("sshd", "10.0.0.2", int(time.time()), 3600, 1, "{}"),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
with patch(
|
||||
"app.services.history_service.get_fail2ban_db_path",
|
||||
new=AsyncMock(return_value=db_path),
|
||||
):
|
||||
result = await history_service.list_history(
|
||||
"fake_socket", origin="blocklist"
|
||||
)
|
||||
|
||||
assert all(
|
||||
item.jail == "blocklist-import" for item in result.items
|
||||
), "origin='blocklist' must filter to blocklist-import jail only"
|
||||
|
||||
# -- Repository layer --
|
||||
|
||||
async def test_get_history_page_accepts_origin_kwarg(self) -> None:
|
||||
"""``fail2ban_db_repo.get_history_page()`` must accept ``origin``."""
|
||||
from app.repositories import fail2ban_db_repo
|
||||
|
||||
sig = inspect.signature(fail2ban_db_repo.get_history_page)
|
||||
assert "origin" in sig.parameters, (
|
||||
"get_history_page() is missing the 'origin' parameter"
|
||||
)
|
||||
|
||||
async def test_get_history_page_filters_by_origin(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""``get_history_page(origin='selfblock')`` excludes blocklist-import."""
|
||||
from app.repositories import fail2ban_db_repo
|
||||
|
||||
db_path = str(tmp_path / "f2b.db")
|
||||
async with aiosqlite.connect(db_path) as db:
|
||||
await db.execute(
|
||||
"CREATE TABLE bans "
|
||||
"(jail TEXT, ip TEXT, timeofban INTEGER, bancount INTEGER, data TEXT)"
|
||||
)
|
||||
await db.executemany(
|
||||
"INSERT INTO bans VALUES (?, ?, ?, ?, ?)",
|
||||
[
|
||||
("blocklist-import", "10.0.0.1", 100, 1, "{}"),
|
||||
("sshd", "10.0.0.2", 200, 1, "{}"),
|
||||
("sshd", "10.0.0.3", 300, 1, "{}"),
|
||||
],
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
rows, total = await fail2ban_db_repo.get_history_page(
|
||||
db_path=db_path, origin="selfblock"
|
||||
)
|
||||
|
||||
assert total == 2
|
||||
assert all(r.jail != "blocklist-import" for r in rows)
|
||||
|
||||
|
||||
# ── Bug 2 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestJailConfigImports:
|
||||
"""Bug 2: ``jail_config_service`` must import ``_get_active_jail_names``."""
|
||||
|
||||
async def test_get_active_jail_names_is_importable(self) -> None:
|
||||
"""The module must successfully import ``_get_active_jail_names``."""
|
||||
import app.services.jail_config_service as mod
|
||||
|
||||
assert hasattr(mod, "_get_active_jail_names") or callable(
|
||||
getattr(mod, "_get_active_jail_names", None)
|
||||
), (
|
||||
"_get_active_jail_names is not available in jail_config_service — "
|
||||
"any call site will raise NameError → 500"
|
||||
)
|
||||
|
||||
async def test_list_inactive_jails_does_not_raise_name_error(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""``list_inactive_jails`` must not crash with NameError."""
|
||||
from app.services import jail_config_service
|
||||
|
||||
config_dir = str(tmp_path / "fail2ban")
|
||||
Path(config_dir).mkdir()
|
||||
(Path(config_dir) / "jail.conf").write_text("[DEFAULT]\n")
|
||||
|
||||
with patch(
|
||||
"app.services.jail_config_service._get_active_jail_names",
|
||||
new=AsyncMock(return_value=set()),
|
||||
):
|
||||
result = await jail_config_service.list_inactive_jails(
|
||||
config_dir, "/fake/socket"
|
||||
)
|
||||
|
||||
assert result.total >= 0
|
||||
|
||||
|
||||
# ── Bug 3 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestFilterConfigImports:
|
||||
"""Bug 3: ``filter_config_service`` must import ``_parse_jails_sync``
|
||||
and ``_get_active_jail_names``."""
|
||||
|
||||
async def test_parse_jails_sync_is_available(self) -> None:
|
||||
"""``_parse_jails_sync`` must be resolvable at module scope."""
|
||||
import app.services.filter_config_service as mod
|
||||
|
||||
assert hasattr(mod, "_parse_jails_sync"), (
|
||||
"_parse_jails_sync is not available in filter_config_service — "
|
||||
"list_filters() will raise NameError → 500"
|
||||
)
|
||||
|
||||
async def test_get_active_jail_names_is_available(self) -> None:
|
||||
"""``_get_active_jail_names`` must be resolvable at module scope."""
|
||||
import app.services.filter_config_service as mod
|
||||
|
||||
assert hasattr(mod, "_get_active_jail_names"), (
|
||||
"_get_active_jail_names is not available in filter_config_service — "
|
||||
"list_filters() will raise NameError → 500"
|
||||
)
|
||||
|
||||
async def test_list_filters_does_not_raise_name_error(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""``list_filters`` must not crash with NameError."""
|
||||
from app.services import filter_config_service
|
||||
|
||||
config_dir = str(tmp_path / "fail2ban")
|
||||
filter_d = Path(config_dir) / "filter.d"
|
||||
filter_d.mkdir(parents=True)
|
||||
|
||||
# Create a minimal filter file so _parse_filters_sync has something to scan.
|
||||
(filter_d / "sshd.conf").write_text(
|
||||
"[Definition]\nfailregex = ^Failed password\n"
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.services.filter_config_service._parse_jails_sync",
|
||||
return_value=({}, {}),
|
||||
),
|
||||
patch(
|
||||
"app.services.filter_config_service._get_active_jail_names",
|
||||
new=AsyncMock(return_value=set()),
|
||||
),
|
||||
):
|
||||
result = await filter_config_service.list_filters(
|
||||
config_dir, "/fake/socket"
|
||||
)
|
||||
|
||||
assert result.total >= 0
|
||||
|
||||
|
||||
# ── Bug 4 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestServiceStatusBanguiVersion:
|
||||
"""Bug 4: ``get_service_status`` must include application version
|
||||
in the ``version`` field of the ``ServiceStatusResponse``."""
|
||||
|
||||
async def test_online_response_contains_bangui_version(self) -> None:
|
||||
"""The returned model must contain the ``bangui_version`` field."""
|
||||
from app.models.server import ServerStatus
|
||||
from app.services import config_service
|
||||
import app
|
||||
|
||||
online_status = ServerStatus(
|
||||
online=True,
|
||||
version="1.0.0",
|
||||
active_jails=2,
|
||||
total_bans=5,
|
||||
total_failures=3,
|
||||
)
|
||||
|
||||
async def _send(command: list[Any]) -> Any:
|
||||
key = "|".join(str(c) for c in command)
|
||||
if key == "get|loglevel":
|
||||
return (0, "INFO")
|
||||
if key == "get|logtarget":
|
||||
return (0, "/var/log/fail2ban.log")
|
||||
return (0, None)
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, **_kw: Any) -> None:
|
||||
self.send = AsyncMock(side_effect=_send)
|
||||
|
||||
with patch("app.services.config_service.Fail2BanClient", _FakeClient):
|
||||
result = await config_service.get_service_status(
|
||||
"/fake/socket",
|
||||
probe_fn=AsyncMock(return_value=online_status),
|
||||
)
|
||||
|
||||
assert result.version == app.__version__, (
|
||||
"ServiceStatusResponse must expose BanGUI version in version field"
|
||||
)
|
||||
|
||||
async def test_offline_response_contains_bangui_version(self) -> None:
|
||||
"""Even when fail2ban is offline, ``bangui_version`` must be present."""
|
||||
from app.models.server import ServerStatus
|
||||
from app.services import config_service
|
||||
import app
|
||||
|
||||
offline_status = ServerStatus(online=False)
|
||||
|
||||
result = await config_service.get_service_status(
|
||||
"/fake/socket",
|
||||
probe_fn=AsyncMock(return_value=offline_status),
|
||||
)
|
||||
|
||||
assert result.version == app.__version__
|
||||
@@ -136,32 +136,3 @@ async def test_get_history_page_and_for_ip(tmp_path: Path) -> None:
|
||||
history_for_ip = await fail2ban_db_repo.get_history_for_ip(db_path=db_path, ip="2.2.2.2")
|
||||
assert len(history_for_ip) == 1
|
||||
assert history_for_ip[0].ip == "2.2.2.2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_history_page_origin_filter(tmp_path: Path) -> None:
|
||||
db_path = str(tmp_path / "fail2ban.db")
|
||||
async with aiosqlite.connect(db_path) as db:
|
||||
await _create_bans_table(db)
|
||||
await db.executemany(
|
||||
"INSERT INTO bans (jail, ip, timeofban, bancount, data) VALUES (?, ?, ?, ?, ?)",
|
||||
[
|
||||
("jail1", "1.1.1.1", 100, 1, "{}"),
|
||||
("blocklist-import", "2.2.2.2", 200, 1, "{}"),
|
||||
],
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
page, total = await fail2ban_db_repo.get_history_page(
|
||||
db_path=db_path,
|
||||
since=None,
|
||||
jail=None,
|
||||
ip_filter=None,
|
||||
origin="selfblock",
|
||||
page=1,
|
||||
page_size=10,
|
||||
)
|
||||
|
||||
assert total == 1
|
||||
assert len(page) == 1
|
||||
assert page[0].ip == "1.1.1.1"
|
||||
|
||||
@@ -9,8 +9,6 @@ import aiosqlite
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
import app
|
||||
|
||||
from app.config import Settings
|
||||
from app.db import init_db
|
||||
from app.main import create_app
|
||||
@@ -503,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.log_service.test_regex",
|
||||
"app.routers.config.config_service.test_regex",
|
||||
return_value=mock_response,
|
||||
):
|
||||
resp = await config_client.post(
|
||||
@@ -521,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.log_service.test_regex",
|
||||
"app.routers.config.config_service.test_regex",
|
||||
return_value=mock_response,
|
||||
):
|
||||
resp = await config_client.post(
|
||||
@@ -599,7 +597,7 @@ class TestPreviewLog:
|
||||
matched_count=1,
|
||||
)
|
||||
with patch(
|
||||
"app.routers.config.log_service.preview_log",
|
||||
"app.routers.config.config_service.preview_log",
|
||||
AsyncMock(return_value=mock_response),
|
||||
):
|
||||
resp = await config_client.post(
|
||||
@@ -727,7 +725,7 @@ class TestGetInactiveJails:
|
||||
mock_response = InactiveJailListResponse(jails=[mock_jail], total=1)
|
||||
|
||||
with patch(
|
||||
"app.routers.config.jail_config_service.list_inactive_jails",
|
||||
"app.routers.config.config_file_service.list_inactive_jails",
|
||||
AsyncMock(return_value=mock_response),
|
||||
):
|
||||
resp = await config_client.get("/api/config/jails/inactive")
|
||||
@@ -742,7 +740,7 @@ class TestGetInactiveJails:
|
||||
from app.models.config import InactiveJailListResponse
|
||||
|
||||
with patch(
|
||||
"app.routers.config.jail_config_service.list_inactive_jails",
|
||||
"app.routers.config.config_file_service.list_inactive_jails",
|
||||
AsyncMock(return_value=InactiveJailListResponse(jails=[], total=0)),
|
||||
):
|
||||
resp = await config_client.get("/api/config/jails/inactive")
|
||||
@@ -778,7 +776,7 @@ class TestActivateJail:
|
||||
message="Jail 'apache-auth' activated successfully.",
|
||||
)
|
||||
with patch(
|
||||
"app.routers.config.jail_config_service.activate_jail",
|
||||
"app.routers.config.config_file_service.activate_jail",
|
||||
AsyncMock(return_value=mock_response),
|
||||
):
|
||||
resp = await config_client.post(
|
||||
@@ -798,7 +796,7 @@ class TestActivateJail:
|
||||
name="apache-auth", active=True, message="Activated."
|
||||
)
|
||||
with patch(
|
||||
"app.routers.config.jail_config_service.activate_jail",
|
||||
"app.routers.config.config_file_service.activate_jail",
|
||||
AsyncMock(return_value=mock_response),
|
||||
) as mock_activate:
|
||||
resp = await config_client.post(
|
||||
@@ -814,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.jail_config_service import JailNotFoundInConfigError
|
||||
from app.services.config_file_service import JailNotFoundInConfigError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.jail_config_service.activate_jail",
|
||||
"app.routers.config.config_file_service.activate_jail",
|
||||
AsyncMock(side_effect=JailNotFoundInConfigError("missing")),
|
||||
):
|
||||
resp = await config_client.post(
|
||||
@@ -828,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.jail_config_service import JailAlreadyActiveError
|
||||
from app.services.config_file_service import JailAlreadyActiveError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.jail_config_service.activate_jail",
|
||||
"app.routers.config.config_file_service.activate_jail",
|
||||
AsyncMock(side_effect=JailAlreadyActiveError("sshd")),
|
||||
):
|
||||
resp = await config_client.post(
|
||||
@@ -842,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.jail_config_service import JailNameError
|
||||
from app.services.config_file_service import JailNameError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.jail_config_service.activate_jail",
|
||||
"app.routers.config.config_file_service.activate_jail",
|
||||
AsyncMock(side_effect=JailNameError("bad name")),
|
||||
):
|
||||
resp = await config_client.post(
|
||||
@@ -874,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.jail_config_service.activate_jail",
|
||||
"app.routers.config.config_file_service.activate_jail",
|
||||
AsyncMock(return_value=blocked_response),
|
||||
):
|
||||
resp = await config_client.post(
|
||||
@@ -907,7 +905,7 @@ class TestDeactivateJail:
|
||||
message="Jail 'sshd' deactivated successfully.",
|
||||
)
|
||||
with patch(
|
||||
"app.routers.config.jail_config_service.deactivate_jail",
|
||||
"app.routers.config.config_file_service.deactivate_jail",
|
||||
AsyncMock(return_value=mock_response),
|
||||
):
|
||||
resp = await config_client.post("/api/config/jails/sshd/deactivate")
|
||||
@@ -919,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.jail_config_service import JailNotFoundInConfigError
|
||||
from app.services.config_file_service import JailNotFoundInConfigError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.jail_config_service.deactivate_jail",
|
||||
"app.routers.config.config_file_service.deactivate_jail",
|
||||
AsyncMock(side_effect=JailNotFoundInConfigError("missing")),
|
||||
):
|
||||
resp = await config_client.post(
|
||||
@@ -933,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.jail_config_service import JailAlreadyInactiveError
|
||||
from app.services.config_file_service import JailAlreadyInactiveError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.jail_config_service.deactivate_jail",
|
||||
"app.routers.config.config_file_service.deactivate_jail",
|
||||
AsyncMock(side_effect=JailAlreadyInactiveError("apache-auth")),
|
||||
):
|
||||
resp = await config_client.post(
|
||||
@@ -947,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.jail_config_service import JailNameError
|
||||
from app.services.config_file_service import JailNameError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.jail_config_service.deactivate_jail",
|
||||
"app.routers.config.config_file_service.deactivate_jail",
|
||||
AsyncMock(side_effect=JailNameError("bad")),
|
||||
):
|
||||
resp = await config_client.post(
|
||||
@@ -978,7 +976,7 @@ class TestDeactivateJail:
|
||||
)
|
||||
with (
|
||||
patch(
|
||||
"app.routers.config.jail_config_service.deactivate_jail",
|
||||
"app.routers.config.config_file_service.deactivate_jail",
|
||||
AsyncMock(return_value=mock_response),
|
||||
),
|
||||
patch(
|
||||
@@ -1029,7 +1027,7 @@ class TestListFilters:
|
||||
total=1,
|
||||
)
|
||||
with patch(
|
||||
"app.routers.config.filter_config_service.list_filters",
|
||||
"app.routers.config.config_file_service.list_filters",
|
||||
AsyncMock(return_value=mock_response),
|
||||
):
|
||||
resp = await config_client.get("/api/config/filters")
|
||||
@@ -1045,7 +1043,7 @@ class TestListFilters:
|
||||
from app.models.config import FilterListResponse
|
||||
|
||||
with patch(
|
||||
"app.routers.config.filter_config_service.list_filters",
|
||||
"app.routers.config.config_file_service.list_filters",
|
||||
AsyncMock(return_value=FilterListResponse(filters=[], total=0)),
|
||||
):
|
||||
resp = await config_client.get("/api/config/filters")
|
||||
@@ -1068,7 +1066,7 @@ class TestListFilters:
|
||||
total=2,
|
||||
)
|
||||
with patch(
|
||||
"app.routers.config.filter_config_service.list_filters",
|
||||
"app.routers.config.config_file_service.list_filters",
|
||||
AsyncMock(return_value=mock_response),
|
||||
):
|
||||
resp = await config_client.get("/api/config/filters")
|
||||
@@ -1097,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.filter_config_service.get_filter",
|
||||
"app.routers.config.config_file_service.get_filter",
|
||||
AsyncMock(return_value=_make_filter_config("sshd")),
|
||||
):
|
||||
resp = await config_client.get("/api/config/filters/sshd")
|
||||
@@ -1110,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.filter_config_service import FilterNotFoundError
|
||||
from app.services.config_file_service import FilterNotFoundError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.filter_config_service.get_filter",
|
||||
"app.routers.config.config_file_service.get_filter",
|
||||
AsyncMock(side_effect=FilterNotFoundError("missing")),
|
||||
):
|
||||
resp = await config_client.get("/api/config/filters/missing")
|
||||
@@ -1140,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.filter_config_service.update_filter",
|
||||
"app.routers.config.config_file_service.update_filter",
|
||||
AsyncMock(return_value=_make_filter_config("sshd")),
|
||||
):
|
||||
resp = await config_client.put(
|
||||
@@ -1153,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.filter_config_service import FilterNotFoundError
|
||||
from app.services.config_file_service import FilterNotFoundError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.filter_config_service.update_filter",
|
||||
"app.routers.config.config_file_service.update_filter",
|
||||
AsyncMock(side_effect=FilterNotFoundError("missing")),
|
||||
):
|
||||
resp = await config_client.put(
|
||||
@@ -1168,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.filter_config_service import FilterInvalidRegexError
|
||||
from app.services.config_file_service import FilterInvalidRegexError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.filter_config_service.update_filter",
|
||||
"app.routers.config.config_file_service.update_filter",
|
||||
AsyncMock(side_effect=FilterInvalidRegexError("[bad", "unterminated")),
|
||||
):
|
||||
resp = await config_client.put(
|
||||
@@ -1183,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.filter_config_service import FilterNameError
|
||||
from app.services.config_file_service import FilterNameError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.filter_config_service.update_filter",
|
||||
"app.routers.config.config_file_service.update_filter",
|
||||
AsyncMock(side_effect=FilterNameError("bad")),
|
||||
):
|
||||
resp = await config_client.put(
|
||||
@@ -1199,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.filter_config_service.update_filter",
|
||||
"app.routers.config.config_file_service.update_filter",
|
||||
AsyncMock(return_value=_make_filter_config("sshd")),
|
||||
) as mock_update:
|
||||
resp = await config_client.put(
|
||||
@@ -1230,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.filter_config_service.create_filter",
|
||||
"app.routers.config.config_file_service.create_filter",
|
||||
AsyncMock(return_value=_make_filter_config("my-custom")),
|
||||
):
|
||||
resp = await config_client.post(
|
||||
@@ -1243,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.filter_config_service import FilterAlreadyExistsError
|
||||
from app.services.config_file_service import FilterAlreadyExistsError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.filter_config_service.create_filter",
|
||||
"app.routers.config.config_file_service.create_filter",
|
||||
AsyncMock(side_effect=FilterAlreadyExistsError("sshd")),
|
||||
):
|
||||
resp = await config_client.post(
|
||||
@@ -1258,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.filter_config_service import FilterInvalidRegexError
|
||||
from app.services.config_file_service import FilterInvalidRegexError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.filter_config_service.create_filter",
|
||||
"app.routers.config.config_file_service.create_filter",
|
||||
AsyncMock(side_effect=FilterInvalidRegexError("[bad", "unterminated")),
|
||||
):
|
||||
resp = await config_client.post(
|
||||
@@ -1273,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.filter_config_service import FilterNameError
|
||||
from app.services.config_file_service import FilterNameError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.filter_config_service.create_filter",
|
||||
"app.routers.config.config_file_service.create_filter",
|
||||
AsyncMock(side_effect=FilterNameError("bad")),
|
||||
):
|
||||
resp = await config_client.post(
|
||||
@@ -1306,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.filter_config_service.delete_filter",
|
||||
"app.routers.config.config_file_service.delete_filter",
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
resp = await config_client.delete("/api/config/filters/my-custom")
|
||||
@@ -1315,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.filter_config_service import FilterNotFoundError
|
||||
from app.services.config_file_service import FilterNotFoundError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.filter_config_service.delete_filter",
|
||||
"app.routers.config.config_file_service.delete_filter",
|
||||
AsyncMock(side_effect=FilterNotFoundError("missing")),
|
||||
):
|
||||
resp = await config_client.delete("/api/config/filters/missing")
|
||||
@@ -1327,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.filter_config_service import FilterReadonlyError
|
||||
from app.services.config_file_service import FilterReadonlyError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.filter_config_service.delete_filter",
|
||||
"app.routers.config.config_file_service.delete_filter",
|
||||
AsyncMock(side_effect=FilterReadonlyError("sshd")),
|
||||
):
|
||||
resp = await config_client.delete("/api/config/filters/sshd")
|
||||
@@ -1339,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.filter_config_service import FilterNameError
|
||||
from app.services.config_file_service import FilterNameError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.filter_config_service.delete_filter",
|
||||
"app.routers.config.config_file_service.delete_filter",
|
||||
AsyncMock(side_effect=FilterNameError("bad")),
|
||||
):
|
||||
resp = await config_client.delete("/api/config/filters/bad")
|
||||
@@ -1369,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.filter_config_service.assign_filter_to_jail",
|
||||
"app.routers.config.config_file_service.assign_filter_to_jail",
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
resp = await config_client.post(
|
||||
@@ -1381,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.jail_config_service import JailNotFoundInConfigError
|
||||
from app.services.config_file_service import JailNotFoundInConfigError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.filter_config_service.assign_filter_to_jail",
|
||||
"app.routers.config.config_file_service.assign_filter_to_jail",
|
||||
AsyncMock(side_effect=JailNotFoundInConfigError("missing")),
|
||||
):
|
||||
resp = await config_client.post(
|
||||
@@ -1396,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.filter_config_service import FilterNotFoundError
|
||||
from app.services.config_file_service import FilterNotFoundError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.filter_config_service.assign_filter_to_jail",
|
||||
"app.routers.config.config_file_service.assign_filter_to_jail",
|
||||
AsyncMock(side_effect=FilterNotFoundError("missing-filter")),
|
||||
):
|
||||
resp = await config_client.post(
|
||||
@@ -1411,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.jail_config_service import JailNameError
|
||||
from app.services.config_file_service import JailNameError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.filter_config_service.assign_filter_to_jail",
|
||||
"app.routers.config.config_file_service.assign_filter_to_jail",
|
||||
AsyncMock(side_effect=JailNameError("bad")),
|
||||
):
|
||||
resp = await config_client.post(
|
||||
@@ -1426,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.filter_config_service import FilterNameError
|
||||
from app.services.config_file_service import FilterNameError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.filter_config_service.assign_filter_to_jail",
|
||||
"app.routers.config.config_file_service.assign_filter_to_jail",
|
||||
AsyncMock(side_effect=FilterNameError("bad")),
|
||||
):
|
||||
resp = await config_client.post(
|
||||
@@ -1442,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.filter_config_service.assign_filter_to_jail",
|
||||
"app.routers.config.config_file_service.assign_filter_to_jail",
|
||||
AsyncMock(return_value=None),
|
||||
) as mock_assign:
|
||||
resp = await config_client.post(
|
||||
@@ -1480,7 +1478,7 @@ class TestListActionsRouter:
|
||||
mock_response = ActionListResponse(actions=[mock_action], total=1)
|
||||
|
||||
with patch(
|
||||
"app.routers.config.action_config_service.list_actions",
|
||||
"app.routers.config.config_file_service.list_actions",
|
||||
AsyncMock(return_value=mock_response),
|
||||
):
|
||||
resp = await config_client.get("/api/config/actions")
|
||||
@@ -1498,7 +1496,7 @@ class TestListActionsRouter:
|
||||
mock_response = ActionListResponse(actions=[inactive, active], total=2)
|
||||
|
||||
with patch(
|
||||
"app.routers.config.action_config_service.list_actions",
|
||||
"app.routers.config.config_file_service.list_actions",
|
||||
AsyncMock(return_value=mock_response),
|
||||
):
|
||||
resp = await config_client.get("/api/config/actions")
|
||||
@@ -1526,7 +1524,7 @@ class TestGetActionRouter:
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.routers.config.action_config_service.get_action",
|
||||
"app.routers.config.config_file_service.get_action",
|
||||
AsyncMock(return_value=mock_action),
|
||||
):
|
||||
resp = await config_client.get("/api/config/actions/iptables")
|
||||
@@ -1535,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.action_config_service import ActionNotFoundError
|
||||
from app.services.config_file_service import ActionNotFoundError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.action_config_service.get_action",
|
||||
"app.routers.config.config_file_service.get_action",
|
||||
AsyncMock(side_effect=ActionNotFoundError("missing")),
|
||||
):
|
||||
resp = await config_client.get("/api/config/actions/missing")
|
||||
@@ -1565,7 +1563,7 @@ class TestUpdateActionRouter:
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.routers.config.action_config_service.update_action",
|
||||
"app.routers.config.config_file_service.update_action",
|
||||
AsyncMock(return_value=updated),
|
||||
):
|
||||
resp = await config_client.put(
|
||||
@@ -1577,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.action_config_service import ActionNotFoundError
|
||||
from app.services.config_file_service import ActionNotFoundError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.action_config_service.update_action",
|
||||
"app.routers.config.config_file_service.update_action",
|
||||
AsyncMock(side_effect=ActionNotFoundError("missing")),
|
||||
):
|
||||
resp = await config_client.put(
|
||||
@@ -1590,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.action_config_service import ActionNameError
|
||||
from app.services.config_file_service import ActionNameError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.action_config_service.update_action",
|
||||
"app.routers.config.config_file_service.update_action",
|
||||
AsyncMock(side_effect=ActionNameError()),
|
||||
):
|
||||
resp = await config_client.put(
|
||||
@@ -1622,7 +1620,7 @@ class TestCreateActionRouter:
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.routers.config.action_config_service.create_action",
|
||||
"app.routers.config.config_file_service.create_action",
|
||||
AsyncMock(return_value=created),
|
||||
):
|
||||
resp = await config_client.post(
|
||||
@@ -1634,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.action_config_service import ActionAlreadyExistsError
|
||||
from app.services.config_file_service import ActionAlreadyExistsError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.action_config_service.create_action",
|
||||
"app.routers.config.config_file_service.create_action",
|
||||
AsyncMock(side_effect=ActionAlreadyExistsError("iptables")),
|
||||
):
|
||||
resp = await config_client.post(
|
||||
@@ -1648,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.action_config_service import ActionNameError
|
||||
from app.services.config_file_service import ActionNameError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.action_config_service.create_action",
|
||||
"app.routers.config.config_file_service.create_action",
|
||||
AsyncMock(side_effect=ActionNameError()),
|
||||
):
|
||||
resp = await config_client.post(
|
||||
@@ -1673,7 +1671,7 @@ class TestCreateActionRouter:
|
||||
class TestDeleteActionRouter:
|
||||
async def test_204_on_delete(self, config_client: AsyncClient) -> None:
|
||||
with patch(
|
||||
"app.routers.config.action_config_service.delete_action",
|
||||
"app.routers.config.config_file_service.delete_action",
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
resp = await config_client.delete("/api/config/actions/custom")
|
||||
@@ -1681,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.action_config_service import ActionNotFoundError
|
||||
from app.services.config_file_service import ActionNotFoundError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.action_config_service.delete_action",
|
||||
"app.routers.config.config_file_service.delete_action",
|
||||
AsyncMock(side_effect=ActionNotFoundError("missing")),
|
||||
):
|
||||
resp = await config_client.delete("/api/config/actions/missing")
|
||||
@@ -1692,10 +1690,10 @@ class TestDeleteActionRouter:
|
||||
assert resp.status_code == 404
|
||||
|
||||
async def test_409_when_readonly(self, config_client: AsyncClient) -> None:
|
||||
from app.services.action_config_service import ActionReadonlyError
|
||||
from app.services.config_file_service import ActionReadonlyError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.action_config_service.delete_action",
|
||||
"app.routers.config.config_file_service.delete_action",
|
||||
AsyncMock(side_effect=ActionReadonlyError("iptables")),
|
||||
):
|
||||
resp = await config_client.delete("/api/config/actions/iptables")
|
||||
@@ -1703,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.action_config_service import ActionNameError
|
||||
from app.services.config_file_service import ActionNameError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.action_config_service.delete_action",
|
||||
"app.routers.config.config_file_service.delete_action",
|
||||
AsyncMock(side_effect=ActionNameError()),
|
||||
):
|
||||
resp = await config_client.delete("/api/config/actions/badname")
|
||||
@@ -1725,7 +1723,7 @@ class TestDeleteActionRouter:
|
||||
class TestAssignActionToJailRouter:
|
||||
async def test_204_on_success(self, config_client: AsyncClient) -> None:
|
||||
with patch(
|
||||
"app.routers.config.action_config_service.assign_action_to_jail",
|
||||
"app.routers.config.config_file_service.assign_action_to_jail",
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
resp = await config_client.post(
|
||||
@@ -1736,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.jail_config_service import JailNotFoundInConfigError
|
||||
from app.services.config_file_service import JailNotFoundInConfigError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.action_config_service.assign_action_to_jail",
|
||||
"app.routers.config.config_file_service.assign_action_to_jail",
|
||||
AsyncMock(side_effect=JailNotFoundInConfigError("missing")),
|
||||
):
|
||||
resp = await config_client.post(
|
||||
@@ -1750,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.action_config_service import ActionNotFoundError
|
||||
from app.services.config_file_service import ActionNotFoundError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.action_config_service.assign_action_to_jail",
|
||||
"app.routers.config.config_file_service.assign_action_to_jail",
|
||||
AsyncMock(side_effect=ActionNotFoundError("missing")),
|
||||
):
|
||||
resp = await config_client.post(
|
||||
@@ -1764,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.jail_config_service import JailNameError
|
||||
from app.services.config_file_service import JailNameError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.action_config_service.assign_action_to_jail",
|
||||
"app.routers.config.config_file_service.assign_action_to_jail",
|
||||
AsyncMock(side_effect=JailNameError()),
|
||||
):
|
||||
resp = await config_client.post(
|
||||
@@ -1778,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.action_config_service import ActionNameError
|
||||
from app.services.config_file_service import ActionNameError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.action_config_service.assign_action_to_jail",
|
||||
"app.routers.config.config_file_service.assign_action_to_jail",
|
||||
AsyncMock(side_effect=ActionNameError()),
|
||||
):
|
||||
resp = await config_client.post(
|
||||
@@ -1793,7 +1791,7 @@ class TestAssignActionToJailRouter:
|
||||
|
||||
async def test_reload_param_passed(self, config_client: AsyncClient) -> None:
|
||||
with patch(
|
||||
"app.routers.config.action_config_service.assign_action_to_jail",
|
||||
"app.routers.config.config_file_service.assign_action_to_jail",
|
||||
AsyncMock(return_value=None),
|
||||
) as mock_assign:
|
||||
resp = await config_client.post(
|
||||
@@ -1816,7 +1814,7 @@ class TestAssignActionToJailRouter:
|
||||
class TestRemoveActionFromJailRouter:
|
||||
async def test_204_on_success(self, config_client: AsyncClient) -> None:
|
||||
with patch(
|
||||
"app.routers.config.action_config_service.remove_action_from_jail",
|
||||
"app.routers.config.config_file_service.remove_action_from_jail",
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
resp = await config_client.delete(
|
||||
@@ -1826,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.jail_config_service import JailNotFoundInConfigError
|
||||
from app.services.config_file_service import JailNotFoundInConfigError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.action_config_service.remove_action_from_jail",
|
||||
"app.routers.config.config_file_service.remove_action_from_jail",
|
||||
AsyncMock(side_effect=JailNotFoundInConfigError("missing")),
|
||||
):
|
||||
resp = await config_client.delete(
|
||||
@@ -1839,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.jail_config_service import JailNameError
|
||||
from app.services.config_file_service import JailNameError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.action_config_service.remove_action_from_jail",
|
||||
"app.routers.config.config_file_service.remove_action_from_jail",
|
||||
AsyncMock(side_effect=JailNameError()),
|
||||
):
|
||||
resp = await config_client.delete(
|
||||
@@ -1852,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.action_config_service import ActionNameError
|
||||
from app.services.config_file_service import ActionNameError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.action_config_service.remove_action_from_jail",
|
||||
"app.routers.config.config_file_service.remove_action_from_jail",
|
||||
AsyncMock(side_effect=ActionNameError()),
|
||||
):
|
||||
resp = await config_client.delete(
|
||||
@@ -1866,7 +1864,7 @@ class TestRemoveActionFromJailRouter:
|
||||
|
||||
async def test_reload_param_passed(self, config_client: AsyncClient) -> None:
|
||||
with patch(
|
||||
"app.routers.config.action_config_service.remove_action_from_jail",
|
||||
"app.routers.config.config_file_service.remove_action_from_jail",
|
||||
AsyncMock(return_value=None),
|
||||
) as mock_rm:
|
||||
resp = await config_client.delete(
|
||||
@@ -2001,7 +1999,7 @@ class TestGetServiceStatus:
|
||||
def _mock_status(self, online: bool = True) -> ServiceStatusResponse:
|
||||
return ServiceStatusResponse(
|
||||
online=online,
|
||||
version=app.__version__,
|
||||
version="1.0.0" if online else None,
|
||||
jail_count=2 if online else 0,
|
||||
total_bans=10 if online else 0,
|
||||
total_failures=3 if online else 0,
|
||||
@@ -2020,7 +2018,6 @@ class TestGetServiceStatus:
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["online"] is True
|
||||
assert data["version"] == app.__version__
|
||||
assert data["jail_count"] == 2
|
||||
assert data["log_level"] == "INFO"
|
||||
|
||||
@@ -2034,7 +2031,6 @@ class TestGetServiceStatus:
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["version"] == app.__version__
|
||||
assert data["online"] is False
|
||||
assert data["log_level"] == "UNKNOWN"
|
||||
|
||||
@@ -2064,7 +2060,7 @@ class TestValidateJailEndpoint:
|
||||
jail_name="sshd", valid=True, issues=[]
|
||||
)
|
||||
with patch(
|
||||
"app.routers.config.jail_config_service.validate_jail_config",
|
||||
"app.routers.config.config_file_service.validate_jail_config",
|
||||
AsyncMock(return_value=mock_result),
|
||||
):
|
||||
resp = await config_client.post("/api/config/jails/sshd/validate")
|
||||
@@ -2084,7 +2080,7 @@ class TestValidateJailEndpoint:
|
||||
jail_name="sshd", valid=False, issues=[issue]
|
||||
)
|
||||
with patch(
|
||||
"app.routers.config.jail_config_service.validate_jail_config",
|
||||
"app.routers.config.config_file_service.validate_jail_config",
|
||||
AsyncMock(return_value=mock_result),
|
||||
):
|
||||
resp = await config_client.post("/api/config/jails/sshd/validate")
|
||||
@@ -2097,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.jail_config_service import JailNameError
|
||||
from app.services.config_file_service import JailNameError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.jail_config_service.validate_jail_config",
|
||||
"app.routers.config.config_file_service.validate_jail_config",
|
||||
AsyncMock(side_effect=JailNameError("bad name")),
|
||||
):
|
||||
resp = await config_client.post("/api/config/jails/bad-name/validate")
|
||||
@@ -2192,7 +2188,7 @@ class TestRollbackEndpoint:
|
||||
message="Jail 'sshd' disabled and fail2ban restarted.",
|
||||
)
|
||||
with patch(
|
||||
"app.routers.config.jail_config_service.rollback_jail",
|
||||
"app.routers.config.config_file_service.rollback_jail",
|
||||
AsyncMock(return_value=mock_result),
|
||||
):
|
||||
resp = await config_client.post("/api/config/jails/sshd/rollback")
|
||||
@@ -2229,7 +2225,7 @@ class TestRollbackEndpoint:
|
||||
message="fail2ban did not come back online.",
|
||||
)
|
||||
with patch(
|
||||
"app.routers.config.jail_config_service.rollback_jail",
|
||||
"app.routers.config.config_file_service.rollback_jail",
|
||||
AsyncMock(return_value=mock_result),
|
||||
):
|
||||
resp = await config_client.post("/api/config/jails/sshd/rollback")
|
||||
@@ -2242,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.jail_config_service import JailNameError
|
||||
from app.services.config_file_service import JailNameError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.jail_config_service.rollback_jail",
|
||||
"app.routers.config.config_file_service.rollback_jail",
|
||||
AsyncMock(side_effect=JailNameError("bad")),
|
||||
):
|
||||
resp = await config_client.post("/api/config/jails/bad/rollback")
|
||||
|
||||
@@ -9,8 +9,6 @@ import aiosqlite
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
import app
|
||||
|
||||
from app.config import Settings
|
||||
from app.db import init_db
|
||||
from app.main import create_app
|
||||
@@ -153,7 +151,6 @@ class TestDashboardStatus:
|
||||
body = response.json()
|
||||
|
||||
assert "status" in body
|
||||
|
||||
status = body["status"]
|
||||
assert "online" in status
|
||||
assert "version" in status
|
||||
@@ -166,11 +163,10 @@ class TestDashboardStatus:
|
||||
) -> None:
|
||||
"""Endpoint returns the exact values from ``app.state.server_status``."""
|
||||
response = await dashboard_client.get("/api/dashboard/status")
|
||||
body = response.json()
|
||||
status = body["status"]
|
||||
status = response.json()["status"]
|
||||
|
||||
assert status["online"] is True
|
||||
assert status["version"] == app.__version__
|
||||
assert status["version"] == "1.0.2"
|
||||
assert status["active_jails"] == 2
|
||||
assert status["total_bans"] == 10
|
||||
assert status["total_failures"] == 5
|
||||
@@ -181,11 +177,10 @@ class TestDashboardStatus:
|
||||
"""Endpoint returns online=False when the cache holds an offline snapshot."""
|
||||
response = await offline_dashboard_client.get("/api/dashboard/status")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
status = body["status"]
|
||||
status = response.json()["status"]
|
||||
|
||||
assert status["online"] is False
|
||||
assert status["version"] == app.__version__
|
||||
assert status["version"] is None
|
||||
assert status["active_jails"] == 0
|
||||
assert status["total_bans"] == 0
|
||||
assert status["total_failures"] == 0
|
||||
|
||||
@@ -26,7 +26,7 @@ from app.models.file_config import (
|
||||
JailConfigFileContent,
|
||||
JailConfigFilesResponse,
|
||||
)
|
||||
from app.services.raw_config_io_service import (
|
||||
from app.services.file_config_service import (
|
||||
ConfigDirError,
|
||||
ConfigFileExistsError,
|
||||
ConfigFileNameError,
|
||||
@@ -112,7 +112,7 @@ class TestListJailConfigFiles:
|
||||
self, file_config_client: AsyncClient
|
||||
) -> None:
|
||||
with patch(
|
||||
"app.routers.file_config.raw_config_io_service.list_jail_config_files",
|
||||
"app.routers.file_config.file_config_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.raw_config_io_service.list_jail_config_files",
|
||||
"app.routers.file_config.file_config_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.raw_config_io_service.get_jail_config_file",
|
||||
"app.routers.file_config.file_config_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.raw_config_io_service.get_jail_config_file",
|
||||
"app.routers.file_config.file_config_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.raw_config_io_service.get_jail_config_file",
|
||||
"app.routers.file_config.file_config_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.raw_config_io_service.set_jail_config_enabled",
|
||||
"app.routers.file_config.file_config_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.raw_config_io_service.set_jail_config_enabled",
|
||||
"app.routers.file_config.file_config_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.raw_config_io_service.get_filter_file",
|
||||
"app.routers.file_config.file_config_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.raw_config_io_service.get_filter_file",
|
||||
"app.routers.file_config.file_config_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.raw_config_io_service.write_filter_file",
|
||||
"app.routers.file_config.file_config_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.raw_config_io_service.write_filter_file",
|
||||
"app.routers.file_config.file_config_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.raw_config_io_service.create_filter_file",
|
||||
"app.routers.file_config.file_config_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.raw_config_io_service.create_filter_file",
|
||||
"app.routers.file_config.file_config_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.raw_config_io_service.create_filter_file",
|
||||
"app.routers.file_config.file_config_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.action_config_service.list_actions",
|
||||
"app.routers.config.config_file_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.action_config_service.create_action",
|
||||
"app.routers.config.config_file_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.raw_config_io_service.get_action_file",
|
||||
"app.routers.file_config.file_config_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.raw_config_io_service.get_action_file",
|
||||
"app.routers.file_config.file_config_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.raw_config_io_service.get_action_file",
|
||||
"app.routers.file_config.file_config_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.raw_config_io_service.write_action_file",
|
||||
"app.routers.file_config.file_config_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.raw_config_io_service.write_action_file",
|
||||
"app.routers.file_config.file_config_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.raw_config_io_service.write_action_file",
|
||||
"app.routers.file_config.file_config_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.raw_config_io_service.write_action_file",
|
||||
"app.routers.file_config.file_config_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.raw_config_io_service.create_jail_config_file",
|
||||
"app.routers.file_config.file_config_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.raw_config_io_service.create_jail_config_file",
|
||||
"app.routers.file_config.file_config_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.raw_config_io_service.create_jail_config_file",
|
||||
"app.routers.file_config.file_config_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.raw_config_io_service.create_jail_config_file",
|
||||
"app.routers.file_config.file_config_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.raw_config_io_service.get_parsed_filter_file",
|
||||
"app.routers.file_config.file_config_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.raw_config_io_service.get_parsed_filter_file",
|
||||
"app.routers.file_config.file_config_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.raw_config_io_service.get_parsed_filter_file",
|
||||
"app.routers.file_config.file_config_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.raw_config_io_service.update_parsed_filter_file",
|
||||
"app.routers.file_config.file_config_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.raw_config_io_service.update_parsed_filter_file",
|
||||
"app.routers.file_config.file_config_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.raw_config_io_service.update_parsed_filter_file",
|
||||
"app.routers.file_config.file_config_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.raw_config_io_service.get_parsed_action_file",
|
||||
"app.routers.file_config.file_config_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.raw_config_io_service.get_parsed_action_file",
|
||||
"app.routers.file_config.file_config_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.raw_config_io_service.get_parsed_action_file",
|
||||
"app.routers.file_config.file_config_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.raw_config_io_service.update_parsed_action_file",
|
||||
"app.routers.file_config.file_config_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.raw_config_io_service.update_parsed_action_file",
|
||||
"app.routers.file_config.file_config_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.raw_config_io_service.update_parsed_action_file",
|
||||
"app.routers.file_config.file_config_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.raw_config_io_service.get_parsed_jail_file",
|
||||
"app.routers.file_config.file_config_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.raw_config_io_service.get_parsed_jail_file",
|
||||
"app.routers.file_config.file_config_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.raw_config_io_service.get_parsed_jail_file",
|
||||
"app.routers.file_config.file_config_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.raw_config_io_service.update_parsed_jail_file",
|
||||
"app.routers.file_config.file_config_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.raw_config_io_service.update_parsed_jail_file",
|
||||
"app.routers.file_config.file_config_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.raw_config_io_service.update_parsed_jail_file",
|
||||
"app.routers.file_config.file_config_service.update_parsed_jail_file",
|
||||
AsyncMock(side_effect=ConfigFileWriteError("disk full")),
|
||||
):
|
||||
resp = await file_config_client.put(
|
||||
|
||||
@@ -213,18 +213,6 @@ class TestHistoryList:
|
||||
_args, kwargs = mock_fn.call_args
|
||||
assert kwargs.get("range_") == "7d"
|
||||
|
||||
async def test_forwards_origin_filter(self, history_client: AsyncClient) -> None:
|
||||
"""The ``origin`` query parameter is forwarded to the service."""
|
||||
mock_fn = AsyncMock(return_value=_make_history_list(n=0))
|
||||
with patch(
|
||||
"app.routers.history.history_service.list_history",
|
||||
new=mock_fn,
|
||||
):
|
||||
await history_client.get("/api/history?origin=blocklist")
|
||||
|
||||
_args, kwargs = mock_fn.call_args
|
||||
assert kwargs.get("origin") == "blocklist"
|
||||
|
||||
async def test_empty_result(self, history_client: AsyncClient) -> None:
|
||||
"""An empty history returns items=[] and total=0."""
|
||||
with patch(
|
||||
|
||||
@@ -203,15 +203,9 @@ class TestImport:
|
||||
call_count += 1
|
||||
raise JailNotFoundError(jail)
|
||||
|
||||
with patch("app.services.jail_service.ban_ip", side_effect=_raise_jail_not_found) as mocked_ban_ip:
|
||||
from app.services import jail_service
|
||||
|
||||
with patch("app.services.jail_service.ban_ip", side_effect=_raise_jail_not_found):
|
||||
result = await blocklist_service.import_source(
|
||||
source,
|
||||
session,
|
||||
"/tmp/fake.sock",
|
||||
db,
|
||||
ban_ip=jail_service.ban_ip,
|
||||
source, session, "/tmp/fake.sock", db
|
||||
)
|
||||
|
||||
# Must abort after the first JailNotFoundError — only one ban attempt.
|
||||
@@ -232,14 +226,7 @@ class TestImport:
|
||||
with patch(
|
||||
"app.services.jail_service.ban_ip", new_callable=AsyncMock
|
||||
):
|
||||
from app.services import jail_service
|
||||
|
||||
result = await blocklist_service.import_all(
|
||||
db,
|
||||
session,
|
||||
"/tmp/fake.sock",
|
||||
ban_ip=jail_service.ban_ip,
|
||||
)
|
||||
result = await blocklist_service.import_all(db, session, "/tmp/fake.sock")
|
||||
|
||||
# Only S1 is enabled, S2 is disabled.
|
||||
assert len(result.results) == 1
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
@@ -257,27 +256,6 @@ class TestUpdateJailConfig:
|
||||
assert "bantime" in keys
|
||||
assert "maxretry" in keys
|
||||
|
||||
async def test_ignores_backend_field(self) -> None:
|
||||
"""update_jail_config does not send a set command for backend."""
|
||||
sent_commands: list[list[Any]] = []
|
||||
|
||||
async def _send(command: list[Any]) -> Any:
|
||||
sent_commands.append(command)
|
||||
return (0, "OK")
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, **_kw: Any) -> None:
|
||||
self.send = AsyncMock(side_effect=_send)
|
||||
|
||||
from app.models.config import JailConfigUpdate
|
||||
|
||||
update = JailConfigUpdate(backend="polling")
|
||||
with patch("app.services.config_service.Fail2BanClient", _FakeClient):
|
||||
await config_service.update_jail_config(_SOCKET, "sshd", update)
|
||||
|
||||
keys = [cmd[2] for cmd in sent_commands if len(cmd) >= 3 and cmd[0] == "set"]
|
||||
assert "backend" not in keys
|
||||
|
||||
async def test_raises_validation_error_on_bad_regex(self) -> None:
|
||||
"""update_jail_config raises ConfigValidationError for invalid regex."""
|
||||
from app.models.config import JailConfigUpdate
|
||||
@@ -743,16 +721,12 @@ class TestGetServiceStatus:
|
||||
def __init__(self, **_kw: Any) -> None:
|
||||
self.send = AsyncMock(side_effect=_send)
|
||||
|
||||
with patch("app.services.config_service.Fail2BanClient", _FakeClient):
|
||||
result = await config_service.get_service_status(
|
||||
_SOCKET,
|
||||
probe_fn=AsyncMock(return_value=online_status),
|
||||
)
|
||||
|
||||
from app import __version__
|
||||
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)
|
||||
|
||||
assert result.online is True
|
||||
assert result.version == __version__
|
||||
assert result.version == "1.0.0"
|
||||
assert result.jail_count == 2
|
||||
assert result.total_bans == 5
|
||||
assert result.total_failures == 3
|
||||
@@ -765,71 +739,10 @@ class TestGetServiceStatus:
|
||||
|
||||
offline_status = ServerStatus(online=False)
|
||||
|
||||
result = await config_service.get_service_status(
|
||||
_SOCKET,
|
||||
probe_fn=AsyncMock(return_value=offline_status),
|
||||
)
|
||||
with patch("app.services.health_service.probe", AsyncMock(return_value=offline_status)):
|
||||
result = await config_service.get_service_status(_SOCKET)
|
||||
|
||||
assert result.online is False
|
||||
assert result.jail_count == 0
|
||||
assert result.log_level == "UNKNOWN"
|
||||
assert result.log_target == "UNKNOWN"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestConfigModuleIntegration:
|
||||
async def test_jail_config_service_list_inactive_jails_uses_imports(self, tmp_path: Any) -> None:
|
||||
from app.services.jail_config_service import list_inactive_jails
|
||||
|
||||
# Arrange: fake parse_jails output with one active and one inactive
|
||||
def fake_parse_jails_sync(path: Path) -> tuple[dict[str, dict[str, str]], dict[str, str]]:
|
||||
return (
|
||||
{
|
||||
"sshd": {
|
||||
"enabled": "true",
|
||||
"filter": "sshd",
|
||||
"logpath": "/var/log/auth.log",
|
||||
},
|
||||
"apache-auth": {
|
||||
"enabled": "false",
|
||||
"filter": "apache-auth",
|
||||
"logpath": "/var/log/apache2/error.log",
|
||||
},
|
||||
},
|
||||
{
|
||||
"sshd": str(path / "jail.conf"),
|
||||
"apache-auth": str(path / "jail.conf"),
|
||||
},
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.services.jail_config_service._parse_jails_sync",
|
||||
new=fake_parse_jails_sync,
|
||||
), patch(
|
||||
"app.services.jail_config_service._get_active_jail_names",
|
||||
new=AsyncMock(return_value={"sshd"}),
|
||||
):
|
||||
result = await list_inactive_jails(str(tmp_path), "/fake.sock")
|
||||
|
||||
names = {j.name for j in result.jails}
|
||||
assert "apache-auth" in names
|
||||
assert "sshd" not in names
|
||||
|
||||
async def test_filter_config_service_list_filters_uses_imports(self, tmp_path: Any) -> None:
|
||||
from app.services.filter_config_service import list_filters
|
||||
|
||||
# Arrange minimal filter and jail config files
|
||||
filter_d = tmp_path / "filter.d"
|
||||
filter_d.mkdir(parents=True)
|
||||
(filter_d / "sshd.conf").write_text("[Definition]\nfailregex = ^%(__prefix_line)s.*$\n")
|
||||
(tmp_path / "jail.conf").write_text("[sshd]\nfilter = sshd\nenabled = true\n")
|
||||
|
||||
with patch(
|
||||
"app.services.filter_config_service._get_active_jail_names",
|
||||
new=AsyncMock(return_value={"sshd"}),
|
||||
):
|
||||
result = await list_filters(str(tmp_path), "/fake.sock")
|
||||
|
||||
assert result.total == 1
|
||||
assert result.filters[0].name == "sshd"
|
||||
assert result.filters[0].active is True
|
||||
|
||||
@@ -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.raw_config_io_service import (
|
||||
from app.services.file_config_service import (
|
||||
ConfigDirError,
|
||||
ConfigFileExistsError,
|
||||
ConfigFileNameError,
|
||||
|
||||
@@ -179,19 +179,6 @@ class TestListHistory:
|
||||
# 2 sshd bans for 1.2.3.4
|
||||
assert result.total == 2
|
||||
|
||||
async def test_origin_filter_selfblock(self, f2b_db_path: str) -> None:
|
||||
"""Origin filter should include only selfblock entries."""
|
||||
with patch(
|
||||
"app.services.history_service.get_fail2ban_db_path",
|
||||
new=AsyncMock(return_value=f2b_db_path),
|
||||
):
|
||||
result = await history_service.list_history(
|
||||
"fake_socket", origin="selfblock"
|
||||
)
|
||||
|
||||
assert result.total == 4
|
||||
assert all(item.jail != "blocklist-import" for item in result.items)
|
||||
|
||||
async def test_unknown_ip_returns_empty(self, f2b_db_path: str) -> None:
|
||||
"""Filtering by a non-existent IP returns an empty result set."""
|
||||
with patch(
|
||||
|
||||
@@ -65,10 +65,6 @@ class TestEnsureJailConfigs:
|
||||
content = _read(jail_d, conf_file)
|
||||
assert "enabled = false" in content
|
||||
|
||||
# Blocklist-import jail must have a 24-hour ban time
|
||||
blocklist_conf = _read(jail_d, _BLOCKLIST_CONF)
|
||||
assert "bantime = 86400" in blocklist_conf
|
||||
|
||||
# .local files must set enabled = true and nothing else
|
||||
for local_file in (_MANUAL_LOCAL, _BLOCKLIST_LOCAL):
|
||||
content = _read(jail_d, local_file)
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import app
|
||||
|
||||
|
||||
def test_app_version_matches_docker_version() -> None:
|
||||
"""The backend version should match the signed off Docker release version."""
|
||||
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
version_file = repo_root / "Docker" / "VERSION"
|
||||
expected = version_file.read_text(encoding="utf-8").strip().lstrip("v")
|
||||
|
||||
assert app.__version__ == expected
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bangui-frontend",
|
||||
"version": "0.9.10",
|
||||
"version": "0.9.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bangui-frontend",
|
||||
"version": "0.9.10",
|
||||
"version": "0.9.4",
|
||||
"dependencies": {
|
||||
"@fluentui/react-components": "^9.55.0",
|
||||
"@fluentui/react-icons": "^2.0.257",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "bangui-frontend",
|
||||
"private": true,
|
||||
"version": "0.9.14",
|
||||
"version": "0.9.4",
|
||||
"description": "BanGUI frontend — fail2ban web management interface",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -18,7 +18,6 @@ export async function fetchHistory(
|
||||
): Promise<HistoryListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (query.range) params.set("range", query.range);
|
||||
if (query.origin) params.set("origin", query.origin);
|
||||
if (query.jail) params.set("jail", query.jail);
|
||||
if (query.ip) params.set("ip", query.ip);
|
||||
if (query.page !== undefined) params.set("page", String(query.page));
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
makeStyles,
|
||||
tokens,
|
||||
} from "@fluentui/react-components";
|
||||
import { useCardStyles } from "../theme/commonStyles";
|
||||
import type { BanOriginFilter, TimeRange } from "../types/ban";
|
||||
import {
|
||||
BAN_ORIGIN_FILTER_LABELS,
|
||||
@@ -58,6 +57,20 @@ 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,
|
||||
@@ -94,10 +107,9 @@ export function DashboardFilterBar({
|
||||
onOriginFilterChange,
|
||||
}: DashboardFilterBarProps): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const cardStyles = useCardStyles();
|
||||
|
||||
return (
|
||||
<div className={`${styles.container} ${cardStyles.card}`}>
|
||||
<div className={styles.container}>
|
||||
{/* Time-range group */}
|
||||
<div className={styles.group}>
|
||||
<Text weight="semibold" size={300}>
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
tokens,
|
||||
Tooltip,
|
||||
} from "@fluentui/react-components";
|
||||
import { useCardStyles } from "../theme/commonStyles";
|
||||
import { ArrowClockwiseRegular, ShieldRegular } from "@fluentui/react-icons";
|
||||
import { useServerStatus } from "../hooks/useServerStatus";
|
||||
|
||||
@@ -32,6 +31,20 @@ 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",
|
||||
},
|
||||
@@ -72,10 +85,8 @@ export function ServerStatusBar(): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const { status, loading, error, refresh } = useServerStatus();
|
||||
|
||||
const cardStyles = useCardStyles();
|
||||
|
||||
return (
|
||||
<div className={`${cardStyles.card} ${styles.bar}`} role="status" aria-label="fail2ban server status">
|
||||
<div className={styles.bar} role="status" aria-label="fail2ban server status">
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{/* Online / Offline badge */}
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
@@ -98,7 +109,7 @@ export function ServerStatusBar(): React.JSX.Element {
|
||||
{/* Version */}
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{status?.version != null && (
|
||||
<Tooltip content="BanGUI version" relationship="description">
|
||||
<Tooltip content="fail2ban daemon version" relationship="description">
|
||||
<Text size={200} className={styles.statValue}>
|
||||
v{status.version}
|
||||
</Text>
|
||||
|
||||
@@ -7,11 +7,9 @@
|
||||
* country filters the companion table.
|
||||
*/
|
||||
|
||||
import { createPortal } from "react-dom";
|
||||
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";
|
||||
@@ -31,6 +29,9 @@ const useStyles = makeStyles({
|
||||
mapWrapper: {
|
||||
width: "100%",
|
||||
position: "relative",
|
||||
backgroundColor: tokens.colorNeutralBackground2,
|
||||
borderRadius: tokens.borderRadiusMedium,
|
||||
border: `1px solid ${tokens.colorNeutralStroke1}`,
|
||||
overflow: "hidden",
|
||||
},
|
||||
countLabel: {
|
||||
@@ -49,28 +50,6 @@ const useStyles = makeStyles({
|
||||
gap: tokens.spacingVerticalXS,
|
||||
zIndex: 10,
|
||||
},
|
||||
tooltip: {
|
||||
position: "fixed",
|
||||
zIndex: 9999,
|
||||
pointerEvents: "none",
|
||||
backgroundColor: tokens.colorNeutralBackground1,
|
||||
border: `1px solid ${tokens.colorNeutralStroke2}`,
|
||||
borderRadius: tokens.borderRadiusSmall,
|
||||
padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalS}`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: tokens.spacingVerticalXXS,
|
||||
boxShadow: tokens.shadow4,
|
||||
},
|
||||
tooltipCountry: {
|
||||
fontSize: tokens.fontSizeBase200,
|
||||
fontWeight: tokens.fontWeightSemibold,
|
||||
color: tokens.colorNeutralForeground1,
|
||||
},
|
||||
tooltipCount: {
|
||||
fontSize: tokens.fontSizeBase200,
|
||||
color: tokens.colorNeutralForeground2,
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -79,7 +58,6 @@ const useStyles = makeStyles({
|
||||
|
||||
interface GeoLayerProps {
|
||||
countries: Record<string, number>;
|
||||
countryNames?: Record<string, string>;
|
||||
selectedCountry: string | null;
|
||||
onSelectCountry: (cc: string | null) => void;
|
||||
thresholdLow: number;
|
||||
@@ -89,7 +67,6 @@ interface GeoLayerProps {
|
||||
|
||||
function GeoLayer({
|
||||
countries,
|
||||
countryNames,
|
||||
selectedCountry,
|
||||
onSelectCountry,
|
||||
thresholdLow,
|
||||
@@ -99,17 +76,6 @@ function GeoLayer({
|
||||
const styles = useStyles();
|
||||
const { geographies, path } = useGeographies({ geography: GEO_URL });
|
||||
|
||||
const [tooltip, setTooltip] = useState<
|
||||
| {
|
||||
cc: string;
|
||||
count: number;
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
| null
|
||||
>(null);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(cc: string | null): void => {
|
||||
onSelectCountry(selectedCountry === cc ? null : cc);
|
||||
@@ -132,7 +98,7 @@ function GeoLayer({
|
||||
const cc: string | null = ISO_NUMERIC_TO_ALPHA2[numericId] ?? null;
|
||||
const count: number = cc !== null ? (countries[cc] ?? 0) : 0;
|
||||
const isSelected = cc !== null && selectedCountry === cc;
|
||||
|
||||
|
||||
// Compute the fill color based on ban count
|
||||
const fillColor = getBanCountColor(
|
||||
count,
|
||||
@@ -140,7 +106,7 @@ function GeoLayer({
|
||||
thresholdMedium,
|
||||
thresholdHigh,
|
||||
);
|
||||
|
||||
|
||||
// Only calculate centroid if path is available
|
||||
let cx: number | undefined;
|
||||
let cy: number | undefined;
|
||||
@@ -170,30 +136,6 @@ function GeoLayer({
|
||||
handleClick(cc);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(e): void => {
|
||||
if (!cc) return;
|
||||
setTooltip({
|
||||
cc,
|
||||
count,
|
||||
name: countryNames?.[cc] ?? cc,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
});
|
||||
}}
|
||||
onMouseMove={(e): void => {
|
||||
setTooltip((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
}}
|
||||
onMouseLeave={(): void => {
|
||||
setTooltip(null);
|
||||
}}
|
||||
>
|
||||
<Geography
|
||||
geography={geo}
|
||||
@@ -237,22 +179,6 @@ function GeoLayer({
|
||||
);
|
||||
},
|
||||
)}
|
||||
|
||||
{tooltip &&
|
||||
createPortal(
|
||||
<div
|
||||
className={styles.tooltip}
|
||||
style={{ left: tooltip.x + 12, top: tooltip.y + 12 }}
|
||||
role="tooltip"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span className={styles.tooltipCountry}>{tooltip.name}</span>
|
||||
<span className={styles.tooltipCount}>
|
||||
{tooltip.count.toLocaleString()} ban{tooltip.count !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -264,8 +190,6 @@ function GeoLayer({
|
||||
export interface WorldMapProps {
|
||||
/** ISO alpha-2 country code → ban count. */
|
||||
countries: Record<string, number>;
|
||||
/** Optional mapping from country code to display name. */
|
||||
countryNames?: Record<string, string>;
|
||||
/** Currently selected country filter (null means no filter). */
|
||||
selectedCountry: string | null;
|
||||
/** Called when the user clicks a country or deselects. */
|
||||
@@ -280,7 +204,6 @@ export interface WorldMapProps {
|
||||
|
||||
export function WorldMap({
|
||||
countries,
|
||||
countryNames,
|
||||
selectedCountry,
|
||||
onSelectCountry,
|
||||
thresholdLow = 20,
|
||||
@@ -288,7 +211,6 @@ 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]);
|
||||
|
||||
@@ -307,7 +229,7 @@ export function WorldMap({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${cardStyles.card} ${styles.mapWrapper}`}
|
||||
className={styles.mapWrapper}
|
||||
role="img"
|
||||
aria-label="World map showing banned IP counts by country. Click a country to filter the table below."
|
||||
>
|
||||
@@ -364,7 +286,6 @@ export function WorldMap({
|
||||
>
|
||||
<GeoLayer
|
||||
countries={countries}
|
||||
countryNames={countryNames}
|
||||
selectedCountry={selectedCountry}
|
||||
onSelectCountry={onSelectCountry}
|
||||
thresholdLow={thresholdLow}
|
||||
|
||||
@@ -101,23 +101,6 @@ describe("ServerStatusBar", () => {
|
||||
expect(screen.getByText("v1.2.3")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render a separate BanGUI version badge", () => {
|
||||
mockedUseServerStatus.mockReturnValue({
|
||||
status: {
|
||||
online: true,
|
||||
version: "1.2.3",
|
||||
active_jails: 1,
|
||||
total_bans: 0,
|
||||
total_failures: 0,
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
renderBar();
|
||||
expect(screen.queryByText("BanGUI v9.9.9")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render the version element when version is null", () => {
|
||||
mockedUseServerStatus.mockReturnValue({
|
||||
status: {
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
/**
|
||||
* Tests for WorldMap component.
|
||||
*
|
||||
* Verifies that hovering a country shows a tooltip with the country name and ban count.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||
|
||||
// Mock react-simple-maps to avoid fetching real TopoJSON and to control geometry.
|
||||
vi.mock("react-simple-maps", () => ({
|
||||
ComposableMap: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
ZoomableGroup: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
Geography: ({ children }: { children?: React.ReactNode }) => <g>{children}</g>,
|
||||
useGeographies: () => ({
|
||||
geographies: [{ rsmKey: "geo-1", id: 840 }],
|
||||
path: { centroid: () => [10, 10] },
|
||||
}),
|
||||
}));
|
||||
|
||||
import { WorldMap } from "../WorldMap";
|
||||
|
||||
describe("WorldMap", () => {
|
||||
it("shows a tooltip with country name and ban count on hover", () => {
|
||||
render(
|
||||
<FluentProvider theme={webLightTheme}>
|
||||
<WorldMap
|
||||
countries={{ US: 42 }}
|
||||
countryNames={{ US: "United States" }}
|
||||
selectedCountry={null}
|
||||
onSelectCountry={vi.fn()}
|
||||
/>
|
||||
</FluentProvider>,
|
||||
);
|
||||
|
||||
// Tooltip should not be present initially
|
||||
expect(screen.queryByRole("tooltip")).toBeNull();
|
||||
|
||||
const countryButton = screen.getByRole("button", { name: /US: 42 bans/i });
|
||||
fireEvent.mouseEnter(countryButton, { clientX: 10, clientY: 10 });
|
||||
|
||||
const tooltip = screen.getByRole("tooltip");
|
||||
expect(tooltip).toHaveTextContent("United States");
|
||||
expect(tooltip).toHaveTextContent("42 bans");
|
||||
expect(tooltip).toHaveStyle({ left: "22px", top: "22px" });
|
||||
|
||||
fireEvent.mouseLeave(countryButton);
|
||||
expect(screen.queryByRole("tooltip")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,105 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,392 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
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 },
|
||||
});
|
||||
@@ -216,6 +216,7 @@ function JailConfigDetail({
|
||||
ignore_regex: ignoreRegex,
|
||||
date_pattern: datePattern !== "" ? datePattern : null,
|
||||
dns_mode: dnsMode,
|
||||
backend,
|
||||
log_encoding: logEncoding,
|
||||
prefregex: prefRegex !== "" ? prefRegex : null,
|
||||
bantime_escalation: {
|
||||
@@ -230,7 +231,7 @@ function JailConfigDetail({
|
||||
}),
|
||||
[
|
||||
banTime, findTime, maxRetry, failRegex, ignoreRegex, datePattern,
|
||||
dnsMode, logEncoding, prefRegex, escEnabled, escFactor,
|
||||
dnsMode, backend, logEncoding, prefRegex, escEnabled, escFactor,
|
||||
escFormula, escMultipliers, escMaxTime, escRndTime, escOverallJails,
|
||||
jail.ban_time, jail.find_time, jail.max_retry,
|
||||
],
|
||||
@@ -757,12 +758,7 @@ function InactiveJailDetail({
|
||||
*
|
||||
* @returns JSX element.
|
||||
*/
|
||||
interface JailsTabProps {
|
||||
/** Jail name to pre-select when the component mounts. */
|
||||
initialJail?: string;
|
||||
}
|
||||
|
||||
export function JailsTab({ initialJail }: JailsTabProps): React.JSX.Element {
|
||||
export function JailsTab(): React.JSX.Element {
|
||||
const styles = useConfigStyles();
|
||||
const { jails, loading, error, refresh, updateJail } =
|
||||
useJailConfigs();
|
||||
@@ -823,13 +819,6 @@ export function JailsTab({ initialJail }: JailsTabProps): React.JSX.Element {
|
||||
return [...activeItems, ...inactiveItems];
|
||||
}, [jails, inactiveJails]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialJail || selectedName) return;
|
||||
if (listItems.some((item) => item.name === initialJail)) {
|
||||
setSelectedName(initialJail);
|
||||
}
|
||||
}, [initialJail, listItems, selectedName]);
|
||||
|
||||
const activeJailMap = useMemo(
|
||||
() => new Map(jails.map((j) => [j.name, j])),
|
||||
[jails],
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||
|
||||
import { JailsTab } from "../JailsTab";
|
||||
import type { JailConfig } from "../../../types/config";
|
||||
import { useAutoSave } from "../../../hooks/useAutoSave";
|
||||
import { useJailConfigs } from "../../../hooks/useConfig";
|
||||
import { useConfigActiveStatus } from "../../../hooks/useConfigActiveStatus";
|
||||
|
||||
vi.mock("../../../hooks/useAutoSave");
|
||||
vi.mock("../../../hooks/useConfig");
|
||||
vi.mock("../../../hooks/useConfigActiveStatus");
|
||||
vi.mock("../../../api/config", () => ({
|
||||
fetchInactiveJails: vi.fn().mockResolvedValue({ jails: [] }),
|
||||
deactivateJail: vi.fn(),
|
||||
deleteJailLocalOverride: vi.fn(),
|
||||
addLogPath: vi.fn(),
|
||||
deleteLogPath: vi.fn(),
|
||||
fetchJailConfigFileContent: vi.fn(),
|
||||
updateJailConfigFile: vi.fn(),
|
||||
validateJailConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockUseAutoSave = vi.mocked(useAutoSave);
|
||||
const mockUseJailConfigs = vi.mocked(useJailConfigs);
|
||||
const mockUseConfigActiveStatus = vi.mocked(useConfigActiveStatus);
|
||||
|
||||
const basicJail: JailConfig = {
|
||||
name: "sshd",
|
||||
ban_time: 600,
|
||||
max_retry: 5,
|
||||
find_time: 600,
|
||||
fail_regex: [],
|
||||
ignore_regex: [],
|
||||
log_paths: [],
|
||||
date_pattern: null,
|
||||
log_encoding: "auto",
|
||||
backend: "polling",
|
||||
use_dns: "warn",
|
||||
prefregex: "",
|
||||
actions: [],
|
||||
bantime_escalation: null,
|
||||
};
|
||||
|
||||
describe("JailsTab", () => {
|
||||
it("does not include backend in auto-save payload", () => {
|
||||
const autoSavePayloads: Array<Record<string, unknown>> = [];
|
||||
mockUseAutoSave.mockImplementation((value) => {
|
||||
autoSavePayloads.push(value as Record<string, unknown>);
|
||||
return { status: "idle", errorText: null, retry: vi.fn() };
|
||||
});
|
||||
|
||||
mockUseJailConfigs.mockReturnValue({
|
||||
jails: [basicJail],
|
||||
total: 1,
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
updateJail: vi.fn(),
|
||||
reloadAll: vi.fn(),
|
||||
});
|
||||
|
||||
mockUseConfigActiveStatus.mockReturnValue({
|
||||
activeJails: new Set<string>(),
|
||||
activeFilters: new Set<string>(),
|
||||
activeActions: new Set<string>(),
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<FluentProvider theme={webLightTheme}>
|
||||
<JailsTab initialJail="sshd" />
|
||||
</FluentProvider>,
|
||||
);
|
||||
|
||||
expect(autoSavePayloads.length).toBeGreaterThan(0);
|
||||
const lastPayload = autoSavePayloads[autoSavePayloads.length - 1];
|
||||
|
||||
expect(lastPayload).not.toHaveProperty("backend");
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||
import { ServerHealthSection } from "../ServerHealthSection";
|
||||
|
||||
vi.mock("../../../api/config");
|
||||
|
||||
import { fetchFail2BanLog, fetchServiceStatus } from "../../../api/config";
|
||||
|
||||
const mockedFetchServiceStatus = vi.mocked(fetchServiceStatus);
|
||||
const mockedFetchFail2BanLog = vi.mocked(fetchFail2BanLog);
|
||||
|
||||
describe("ServerHealthSection", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows the version in the service health panel", async () => {
|
||||
mockedFetchServiceStatus.mockResolvedValue({
|
||||
online: true,
|
||||
version: "1.2.3",
|
||||
jail_count: 2,
|
||||
total_bans: 5,
|
||||
total_failures: 1,
|
||||
log_level: "INFO",
|
||||
log_target: "STDOUT",
|
||||
});
|
||||
|
||||
mockedFetchFail2BanLog.mockResolvedValue({
|
||||
log_path: "/var/log/fail2ban.log",
|
||||
lines: ["2026-01-01 fail2ban[123]: INFO Test"],
|
||||
total_lines: 1,
|
||||
log_level: "INFO",
|
||||
log_target: "STDOUT",
|
||||
});
|
||||
|
||||
render(
|
||||
<FluentProvider theme={webLightTheme}>
|
||||
<ServerHealthSection />
|
||||
</FluentProvider>,
|
||||
);
|
||||
|
||||
// The service health panel should render and include the version.
|
||||
const versionLabel = await screen.findByText("Version");
|
||||
expect(versionLabel).toBeInTheDocument();
|
||||
|
||||
const versionCard = versionLabel.closest("div");
|
||||
expect(versionCard).toHaveTextContent("1.2.3");
|
||||
});
|
||||
});
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
type TableColumnDefinition,
|
||||
createTableColumn,
|
||||
} from "@fluentui/react-components";
|
||||
import { useCommonSectionStyles } from "../../theme/commonStyles";
|
||||
import { formatTimestamp } from "../../utils/formatDate";
|
||||
import {
|
||||
ArrowClockwiseRegular,
|
||||
@@ -55,6 +54,26 @@ 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",
|
||||
@@ -115,7 +134,7 @@ const useStyles = makeStyles({
|
||||
/** A row item augmented with an `onUnban` callback for the row action. */
|
||||
interface BanRow {
|
||||
ban: ActiveBan;
|
||||
onUnban: (ip: string) => Promise<void>;
|
||||
onUnban: (ip: string) => void;
|
||||
}
|
||||
|
||||
const columns: TableColumnDefinition<BanRow>[] = [
|
||||
@@ -168,7 +187,9 @@ const columns: TableColumnDefinition<BanRow>[] = [
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<DismissRegular />}
|
||||
onClick={() => { void onUnban(ban.ip); }}
|
||||
onClick={() => {
|
||||
onUnban(ban.ip);
|
||||
}}
|
||||
aria-label={`Unban ${ban.ip}`}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -193,8 +214,8 @@ export interface BannedIpsSectionProps {
|
||||
onSearch: (term: string) => void;
|
||||
onPageChange: (page: number) => void;
|
||||
onPageSizeChange: (size: number) => void;
|
||||
onRefresh: () => Promise<void>;
|
||||
onUnban: (ip: string) => Promise<void>;
|
||||
onRefresh: () => void;
|
||||
onUnban: (ip: string) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -222,7 +243,6 @@ export function BannedIpsSection({
|
||||
onUnban,
|
||||
}: BannedIpsSectionProps): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const sectionStyles = useCommonSectionStyles();
|
||||
|
||||
const rows: BanRow[] = items.map((ban) => ({
|
||||
ban,
|
||||
@@ -232,7 +252,7 @@ export function BannedIpsSection({
|
||||
const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 1;
|
||||
|
||||
return (
|
||||
<div className={sectionStyles.section}>
|
||||
<div className={styles.root}>
|
||||
{/* Section header */}
|
||||
<div className={styles.header}>
|
||||
<div className={styles.headerLeft}>
|
||||
@@ -245,7 +265,7 @@ export function BannedIpsSection({
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<ArrowClockwiseRegular />}
|
||||
onClick={() => { void onRefresh(); }}
|
||||
onClick={onRefresh}
|
||||
aria-label="Refresh banned IPs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -36,7 +36,7 @@ describe("useJailDetail control methods", () => {
|
||||
});
|
||||
|
||||
it("calls start() and refetches jail data", async () => {
|
||||
vi.mocked(jailsApi.startJail).mockResolvedValue({ message: "jail started", jail: "sshd" });
|
||||
vi.mocked(jailsApi.startJail).mockResolvedValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useJailDetail("sshd"));
|
||||
|
||||
@@ -58,7 +58,7 @@ describe("useJailDetail control methods", () => {
|
||||
});
|
||||
|
||||
it("calls stop() and refetches jail data", async () => {
|
||||
vi.mocked(jailsApi.stopJail).mockResolvedValue({ message: "jail stopped", jail: "sshd" });
|
||||
vi.mocked(jailsApi.stopJail).mockResolvedValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useJailDetail("sshd"));
|
||||
|
||||
@@ -77,7 +77,7 @@ describe("useJailDetail control methods", () => {
|
||||
});
|
||||
|
||||
it("calls reload() and refetches jail data", async () => {
|
||||
vi.mocked(jailsApi.reloadJail).mockResolvedValue({ message: "jail reloaded", jail: "sshd" });
|
||||
vi.mocked(jailsApi.reloadJail).mockResolvedValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useJailDetail("sshd"));
|
||||
|
||||
@@ -96,7 +96,7 @@ describe("useJailDetail control methods", () => {
|
||||
});
|
||||
|
||||
it("calls setIdle() with correct parameter and refetches jail data", async () => {
|
||||
vi.mocked(jailsApi.setJailIdle).mockResolvedValue({ message: "jail idle toggled", jail: "sshd" });
|
||||
vi.mocked(jailsApi.setJailIdle).mockResolvedValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useJailDetail("sshd"));
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* React hook for loading and updating a single parsed action config.
|
||||
*/
|
||||
|
||||
import { useConfigItem } from "./useConfigItem";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { fetchAction, updateAction } from "../api/config";
|
||||
import type { ActionConfig, ActionConfigUpdate } from "../types/config";
|
||||
|
||||
@@ -23,28 +23,67 @@ export interface UseActionConfigResult {
|
||||
* @param name - Action base name (e.g. ``"iptables"``).
|
||||
*/
|
||||
export function useActionConfig(name: string): UseActionConfigResult {
|
||||
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 [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);
|
||||
|
||||
return {
|
||||
config: data,
|
||||
loading,
|
||||
error,
|
||||
saving,
|
||||
saveError,
|
||||
refresh,
|
||||
save,
|
||||
};
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
|
||||
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";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -66,7 +65,7 @@ export function useBanTrend(
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
handleFetchError(err, setError, "Failed to fetch trend data");
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch trend data");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) {
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
|
||||
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. */
|
||||
@@ -64,7 +63,7 @@ export function useBans(
|
||||
setBanItems(data.items);
|
||||
setTotal(data.total);
|
||||
} catch (err: unknown) {
|
||||
handleFetchError(err, setError, "Failed to fetch bans");
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -14,14 +14,12 @@ import {
|
||||
updateBlocklist,
|
||||
updateSchedule,
|
||||
} from "../api/blocklist";
|
||||
import { handleFetchError } from "../utils/fetchError";
|
||||
import type {
|
||||
BlocklistSource,
|
||||
BlocklistSourceCreate,
|
||||
BlocklistSourceUpdate,
|
||||
ImportLogListResponse,
|
||||
ImportRunResult,
|
||||
PreviewResponse,
|
||||
ScheduleConfig,
|
||||
ScheduleInfo,
|
||||
} from "../types/blocklist";
|
||||
@@ -67,7 +65,7 @@ export function useBlocklists(): UseBlocklistsReturn {
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!ctrl.signal.aborted) {
|
||||
handleFetchError(err, setError, "Failed to load blocklists");
|
||||
setError(err instanceof Error ? err.message : "Failed to load blocklists");
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
@@ -146,7 +144,7 @@ export function useSchedule(): UseScheduleReturn {
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
handleFetchError(err, setError, "Failed to load schedule");
|
||||
setError(err instanceof Error ? err.message : "Failed to load schedule");
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
@@ -202,7 +200,7 @@ export function useImportLog(
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!ctrl.signal.aborted) {
|
||||
handleFetchError(err, setError, "Failed to load import log");
|
||||
setError(err instanceof Error ? err.message : "Failed to load import log");
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
@@ -244,7 +242,7 @@ export function useRunImport(): UseRunImportReturn {
|
||||
const result = await runImportNow();
|
||||
setLastResult(result);
|
||||
} catch (err: unknown) {
|
||||
handleFetchError(err, setError, "Import failed");
|
||||
setError(err instanceof Error ? err.message : "Import failed");
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
|
||||
@@ -12,13 +12,11 @@ import {
|
||||
flushLogs,
|
||||
previewLog,
|
||||
reloadConfig,
|
||||
restartFail2Ban,
|
||||
testRegex,
|
||||
updateGlobalConfig,
|
||||
updateJailConfig,
|
||||
updateServerSettings,
|
||||
} from "../api/config";
|
||||
import { handleFetchError } from "../utils/fetchError";
|
||||
import type {
|
||||
AddLogPathRequest,
|
||||
GlobalConfig,
|
||||
@@ -67,7 +65,9 @@ export function useJailConfigs(): UseJailConfigsResult {
|
||||
setTotal(resp.total);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
handleFetchError(err, setError, "Failed to fetch jail configs");
|
||||
if (err instanceof Error && err.name !== "AbortError") {
|
||||
setError(err.message);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
@@ -128,7 +128,9 @@ export function useJailConfigDetail(name: string): UseJailConfigDetailResult {
|
||||
setJail(resp.jail);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
handleFetchError(err, setError, "Failed to fetch jail config");
|
||||
if (err instanceof Error && err.name !== "AbortError") {
|
||||
setError(err.message);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
@@ -189,7 +191,9 @@ export function useGlobalConfig(): UseGlobalConfigResult {
|
||||
fetchGlobalConfig()
|
||||
.then(setConfig)
|
||||
.catch((err: unknown) => {
|
||||
handleFetchError(err, setError, "Failed to fetch global config");
|
||||
if (err instanceof Error && err.name !== "AbortError") {
|
||||
setError(err.message);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
@@ -247,7 +251,9 @@ export function useServerSettings(): UseServerSettingsResult {
|
||||
setSettings(resp.settings);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
handleFetchError(err, setError, "Failed to fetch server settings");
|
||||
if (err instanceof Error && err.name !== "AbortError") {
|
||||
setError(err.message);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
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";
|
||||
|
||||
@@ -111,7 +110,7 @@ export function useConfigActiveStatus(): UseConfigActiveStatusResult {
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
handleFetchError(err, setError, "Failed to load active status.");
|
||||
setError(err instanceof Error ? err.message : "Failed to load status.");
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
/**
|
||||
* 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,7 +9,6 @@
|
||||
|
||||
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";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -78,7 +77,7 @@ export function useDashboardCountryData(
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
handleFetchError(err, setError, "Failed to fetch dashboard country data");
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch data");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* React hook for loading and updating a single parsed filter config.
|
||||
*/
|
||||
|
||||
import { useConfigItem } from "./useConfigItem";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { fetchParsedFilter, updateParsedFilter } from "../api/config";
|
||||
import type { FilterConfig, FilterConfigUpdate } from "../types/config";
|
||||
|
||||
@@ -23,28 +23,69 @@ export interface UseFilterConfigResult {
|
||||
* @param name - Filter base name (e.g. ``"sshd"``).
|
||||
*/
|
||||
export function useFilterConfig(name: string): UseFilterConfigResult {
|
||||
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 [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);
|
||||
|
||||
return {
|
||||
config: data,
|
||||
loading,
|
||||
error,
|
||||
saving,
|
||||
saveError,
|
||||
refresh,
|
||||
save,
|
||||
};
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { fetchHistory, fetchIpHistory } from "../api/history";
|
||||
import { handleFetchError } from "../utils/fetchError";
|
||||
import type {
|
||||
HistoryBanItem,
|
||||
HistoryQuery,
|
||||
@@ -45,7 +44,9 @@ export function useHistory(query: HistoryQuery = {}): UseHistoryResult {
|
||||
setTotal(resp.total);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
handleFetchError(err, setError, "Failed to fetch history");
|
||||
if (err instanceof Error && err.name !== "AbortError") {
|
||||
setError(err.message);
|
||||
}
|
||||
})
|
||||
.finally((): void => {
|
||||
setLoading(false);
|
||||
@@ -90,7 +91,9 @@ export function useIpHistory(ip: string): UseIpHistoryResult {
|
||||
setDetail(resp);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
handleFetchError(err, setError, "Failed to fetch IP history");
|
||||
if (err instanceof Error && err.name !== "AbortError") {
|
||||
setError(err.message);
|
||||
}
|
||||
})
|
||||
.finally((): void => {
|
||||
setLoading(false);
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
|
||||
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";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -66,7 +65,9 @@ export function useJailDistribution(
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
handleFetchError(err, setError, "Failed to fetch jail distribution");
|
||||
setError(
|
||||
err instanceof Error ? err.message : "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 { useConfigItem } from "./useConfigItem";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { fetchParsedJailFile, updateParsedJailFile } from "../api/config";
|
||||
import type { JailFileConfig, JailFileConfigUpdate } from "../types/config";
|
||||
|
||||
@@ -21,23 +21,56 @@ export interface UseJailFileConfigResult {
|
||||
* @param filename - Filename including extension (e.g. ``"sshd.conf"``).
|
||||
*/
|
||||
export function useJailFileConfig(filename: string): UseJailFileConfigResult {
|
||||
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 [config, setConfig] = useState<JailFileConfig | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
return {
|
||||
config: data,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
save,
|
||||
};
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { handleFetchError } from "../utils/fetchError";
|
||||
import {
|
||||
addIgnoreIp,
|
||||
banIp,
|
||||
@@ -93,7 +92,7 @@ export function useJails(): UseJailsResult {
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!ctrl.signal.aborted) {
|
||||
handleFetchError(err, setError, "Failed to load jails");
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -196,7 +195,7 @@ export function useJailDetail(name: string): UseJailDetailResult {
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!ctrl.signal.aborted) {
|
||||
handleFetchError(err, setError, "Failed to fetch jail detail");
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -310,7 +309,7 @@ export function useJailBannedIps(jailName: string): UseJailBannedIpsResult {
|
||||
setItems(resp.items);
|
||||
setTotal(resp.total);
|
||||
} catch (err: unknown) {
|
||||
handleFetchError(err, setError, "Failed to fetch jailed IPs");
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -416,7 +415,7 @@ export function useActiveBans(): UseActiveBansResult {
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!ctrl.signal.aborted) {
|
||||
handleFetchError(err, setError, "Failed to fetch active bans");
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -497,7 +496,7 @@ export function useIpLookup(): UseIpLookupResult {
|
||||
setResult(res);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
handleFetchError(err, setError, "Failed to lookup IP");
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { fetchMapColorThresholds, updateMapColorThresholds } from "../api/config";
|
||||
import { handleFetchError } from "../utils/fetchError";
|
||||
import type {
|
||||
MapColorThresholdsResponse,
|
||||
MapColorThresholdsUpdate,
|
||||
@@ -27,7 +26,7 @@ export function useMapColorThresholds(): UseMapColorThresholdsResult {
|
||||
const data = await fetchMapColorThresholds();
|
||||
setThresholds(data);
|
||||
} catch (err: unknown) {
|
||||
handleFetchError(err, setError, "Failed to fetch map color thresholds");
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch map color thresholds");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
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";
|
||||
|
||||
@@ -69,7 +68,9 @@ export function useMapData(
|
||||
setData(resp);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
handleFetchError(err, setError, "Failed to fetch map data");
|
||||
if (err instanceof Error && err.name !== "AbortError") {
|
||||
setError(err.message);
|
||||
}
|
||||
})
|
||||
.finally((): void => {
|
||||
setLoading(false);
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
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). */
|
||||
@@ -46,7 +45,7 @@ export function useServerStatus(): UseServerStatusResult {
|
||||
setStatus(data.status);
|
||||
setError(null);
|
||||
} catch (err: unknown) {
|
||||
handleFetchError(err, setError, "Failed to fetch server status");
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch server status");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
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,
|
||||
@@ -45,11 +44,9 @@ export function useSetup(): UseSetupResult {
|
||||
const resp = await getSetupStatus();
|
||||
setStatus(resp);
|
||||
} catch (err: unknown) {
|
||||
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);
|
||||
}
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to fetch setup status";
|
||||
console.warn("Setup status check failed:", errorMessage);
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { fetchTimezone } from "../api/setup";
|
||||
import { handleFetchError } from "../utils/fetchError";
|
||||
|
||||
export interface UseTimezoneDataResult {
|
||||
timezone: string;
|
||||
@@ -22,7 +21,7 @@ export function useTimezoneData(): UseTimezoneDataResult {
|
||||
const resp = await fetchTimezone();
|
||||
setTimezone(resp.timezone);
|
||||
} catch (err: unknown) {
|
||||
handleFetchError(err, setError, "Failed to fetch timezone");
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch timezone");
|
||||
setTimezone("UTC");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
||||
@@ -313,7 +313,7 @@ export function MainLayout(): React.JSX.Element {
|
||||
<div className={styles.sidebarFooter}>
|
||||
{!collapsed && (
|
||||
<Text className={styles.versionText}>
|
||||
BanGUI
|
||||
BanGUI v{__APP_VERSION__}
|
||||
</Text>
|
||||
)}
|
||||
<Tooltip
|
||||
|
||||
@@ -63,16 +63,16 @@ describe("MainLayout", () => {
|
||||
expect(screen.getByRole("navigation", { name: "Main navigation" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show the BanGUI application version in the sidebar footer", () => {
|
||||
it("shows the BanGUI version in the sidebar footer when expanded", () => {
|
||||
renderLayout();
|
||||
// __APP_VERSION__ is stubbed to "0.0.0-test" via vitest.config.ts define.
|
||||
expect(screen.queryByText(/BanGUI v/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText("BanGUI v0.0.0-test")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the logo text when the sidebar is collapsed", async () => {
|
||||
it("hides the BanGUI version text when the sidebar is collapsed", async () => {
|
||||
renderLayout();
|
||||
const toggleButton = screen.getByRole("button", { name: /collapse sidebar/i });
|
||||
await userEvent.click(toggleButton);
|
||||
expect(screen.queryByText("BanGUI")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("BanGUI v0.0.0-test")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,341 @@
|
||||
/**
|
||||
* BlocklistsPage — external IP blocklist source management.
|
||||
*
|
||||
* Responsible for composition of sources, schedule, and import log sections.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { Button, MessageBar, MessageBarBody, Text } from "@fluentui/react-components";
|
||||
import { useBlocklistStyles } from "../components/blocklist/blocklistStyles";
|
||||
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 { 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";
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ImportResultDialogProps {
|
||||
open: boolean;
|
||||
@@ -21,29 +344,592 @@ interface ImportResultDialogProps {
|
||||
}
|
||||
|
||||
function ImportResultDialog({ open, result, onClose }: ImportResultDialogProps): React.JSX.Element {
|
||||
if (!open || !result) return <></>;
|
||||
const styles = useStyles();
|
||||
if (!result) return <></>;
|
||||
return (
|
||||
<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
|
||||
<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
|
||||
</Text>
|
||||
<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
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 safeUseBlocklistStyles = useBlocklistStyles as unknown as () => { root: string };
|
||||
const styles = safeUseBlocklistStyles();
|
||||
const styles = useStyles();
|
||||
const { running, lastResult, error: importError, runNow } = useRunImport();
|
||||
const [importResultOpen, setImportResultOpen] = useState(false);
|
||||
|
||||
@@ -65,15 +951,18 @@ export function BlocklistsPage(): React.JSX.Element {
|
||||
</MessageBar>
|
||||
)}
|
||||
|
||||
<BlocklistSourcesSection onRunImport={handleRunImport} runImportRunning={running} />
|
||||
<BlocklistScheduleSection onRunImport={handleRunImport} runImportRunning={running} />
|
||||
<BlocklistImportLogSection />
|
||||
<SourcesSection onRunImport={handleRunImport} runImportRunning={running} />
|
||||
<ScheduleSection onRunImport={handleRunImport} runImportRunning={running} />
|
||||
<ImportLogSection />
|
||||
|
||||
<ImportResultDialog
|
||||
open={importResultOpen}
|
||||
result={lastResult}
|
||||
onClose={() => { setImportResultOpen(false); }}
|
||||
onClose={() => {
|
||||
setImportResultOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,8 +13,7 @@
|
||||
* Export — raw file editors for jail, filter, and action files
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useState } from "react";
|
||||
import { Tab, TabList, Text, makeStyles, tokens } from "@fluentui/react-components";
|
||||
import {
|
||||
ActionsTab,
|
||||
@@ -59,16 +58,8 @@ type TabValue =
|
||||
|
||||
export function ConfigPage(): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const location = useLocation();
|
||||
const [tab, setTab] = useState<TabValue>("jails");
|
||||
|
||||
useEffect(() => {
|
||||
const state = location.state as { tab?: string; jail?: string } | null;
|
||||
if (state?.tab === "jails") {
|
||||
setTab("jails");
|
||||
}
|
||||
}, [location.state]);
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.header}>
|
||||
@@ -95,11 +86,7 @@ export function ConfigPage(): React.JSX.Element {
|
||||
</TabList>
|
||||
|
||||
<div className={styles.tabContent} key={tab}>
|
||||
{tab === "jails" && (
|
||||
<JailsTab
|
||||
initialJail={(location.state as { jail?: string } | null)?.jail}
|
||||
/>
|
||||
)}
|
||||
{tab === "jails" && <JailsTab />}
|
||||
{tab === "filters" && <FiltersTab />}
|
||||
{tab === "actions" && <ActionsTab />}
|
||||
{tab === "server" && <ServerTab />}
|
||||
|
||||
@@ -15,7 +15,6 @@ 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";
|
||||
|
||||
@@ -30,6 +29,26 @@ 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",
|
||||
@@ -74,8 +93,6 @@ 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}>
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
@@ -96,7 +113,7 @@ export function DashboardPage(): React.JSX.Element {
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Ban Trend section */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<div className={sectionStyles.section}>
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<Text as="h2" size={500} weight="semibold">
|
||||
Ban Trend
|
||||
@@ -110,7 +127,7 @@ export function DashboardPage(): React.JSX.Element {
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Charts section */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<div className={sectionStyles.section}>
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<Text as="h2" size={500} weight="semibold">
|
||||
Top Countries
|
||||
@@ -145,7 +162,7 @@ export function DashboardPage(): React.JSX.Element {
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Ban list section */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<div className={sectionStyles.section}>
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<Text as="h2" size={500} weight="semibold">
|
||||
Ban List
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Input,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
Select,
|
||||
Spinner,
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -35,17 +36,14 @@ import {
|
||||
makeStyles,
|
||||
tokens,
|
||||
} from "@fluentui/react-components";
|
||||
import { useCardStyles } from "../theme/commonStyles";
|
||||
import {
|
||||
ArrowCounterclockwiseRegular,
|
||||
ArrowLeftRegular,
|
||||
ChevronLeftRegular,
|
||||
ChevronRightRegular,
|
||||
} from "@fluentui/react-icons";
|
||||
import { DashboardFilterBar } from "../components/DashboardFilterBar";
|
||||
import { useHistory, useIpHistory } from "../hooks/useHistory";
|
||||
import type { HistoryBanItem, HistoryQuery, TimeRange } from "../types/history";
|
||||
import type { BanOriginFilter } from "../types/ban";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
@@ -56,6 +54,13 @@ const HIGH_BAN_THRESHOLD = 5;
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const TIME_RANGE_OPTIONS: { label: string; value: TimeRange }[] = [
|
||||
{ label: "Last 24 hours", value: "24h" },
|
||||
{ label: "Last 7 days", value: "7d" },
|
||||
{ label: "Last 30 days", value: "30d" },
|
||||
{ label: "Last 365 days", value: "365d" },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -113,6 +118,9 @@ 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: {
|
||||
@@ -214,7 +222,6 @@ 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) {
|
||||
@@ -271,7 +278,7 @@ function IpDetailView({ ip, onBack }: IpDetailViewProps): React.JSX.Element {
|
||||
</div>
|
||||
|
||||
{/* Summary grid */}
|
||||
<div className={`${cardStyles.card} ${styles.detailGrid}`}>
|
||||
<div className={styles.detailGrid}>
|
||||
<div className={styles.detailField}>
|
||||
<span className={styles.detailLabel}>Total Bans</span>
|
||||
<span className={styles.detailValue}>{String(detail.total_bans)}</span>
|
||||
@@ -374,8 +381,7 @@ export function HistoryPage(): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
|
||||
// Filter state
|
||||
const [range, setRange] = useState<TimeRange>("24h");
|
||||
const [originFilter, setOriginFilter] = useState<BanOriginFilter>("all");
|
||||
const [range, setRange] = useState<TimeRange | undefined>(undefined);
|
||||
const [jailFilter, setJailFilter] = useState("");
|
||||
const [ipFilter, setIpFilter] = useState("");
|
||||
const [appliedQuery, setAppliedQuery] = useState<HistoryQuery>({
|
||||
@@ -391,12 +397,11 @@ export function HistoryPage(): React.JSX.Element {
|
||||
const applyFilters = useCallback((): void => {
|
||||
setAppliedQuery({
|
||||
range: range,
|
||||
origin: originFilter !== "all" ? originFilter : undefined,
|
||||
jail: jailFilter.trim() || undefined,
|
||||
ip: ipFilter.trim() || undefined,
|
||||
page_size: PAGE_SIZE,
|
||||
});
|
||||
}, [range, originFilter, jailFilter, ipFilter]);
|
||||
}, [range, jailFilter, ipFilter]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
@@ -447,16 +452,24 @@ export function HistoryPage(): React.JSX.Element {
|
||||
{/* Filter bar */}
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
<div className={styles.filterRow}>
|
||||
<DashboardFilterBar
|
||||
timeRange={range}
|
||||
onTimeRangeChange={(value) => {
|
||||
setRange(value);
|
||||
}}
|
||||
originFilter={originFilter}
|
||||
onOriginFilterChange={(value) => {
|
||||
setOriginFilter(value);
|
||||
}}
|
||||
/>
|
||||
<div className={styles.filterLabel}>
|
||||
<Text size={200}>Time range</Text>
|
||||
<Select
|
||||
aria-label="Time range"
|
||||
value={range ?? ""}
|
||||
onChange={(_ev, data): void => {
|
||||
setRange(data.value === "" ? undefined : (data.value as TimeRange));
|
||||
}}
|
||||
size="small"
|
||||
>
|
||||
<option value="">All time</option>
|
||||
{TIME_RANGE_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className={styles.filterLabel}>
|
||||
<Text size={200}>Jail</Text>
|
||||
@@ -493,8 +506,7 @@ export function HistoryPage(): React.JSX.Element {
|
||||
appearance="subtle"
|
||||
size="small"
|
||||
onClick={(): void => {
|
||||
setRange("24h");
|
||||
setOriginFilter("all");
|
||||
setRange(undefined);
|
||||
setJailFilter("");
|
||||
setIpFilter("");
|
||||
setAppliedQuery({ page_size: PAGE_SIZE });
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
makeStyles,
|
||||
tokens,
|
||||
} from "@fluentui/react-components";
|
||||
import { useCommonSectionStyles } from "../theme/commonStyles";
|
||||
import {
|
||||
ArrowClockwiseRegular,
|
||||
ArrowLeftRegular,
|
||||
@@ -54,7 +53,36 @@ 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",
|
||||
@@ -153,7 +181,6 @@ 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);
|
||||
|
||||
@@ -179,8 +206,8 @@ function JailInfoSection({ jail, onRefresh, onStart, onStop, onSetIdle, onReload
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={sectionStyles.section}>
|
||||
<div className={sectionStyles.sectionHeader}>
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<div className={styles.headerRow}>
|
||||
<Text
|
||||
size={600}
|
||||
@@ -307,10 +334,10 @@ function JailInfoSection({ jail, onRefresh, onStart, onStop, onSetIdle, onReload
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function PatternsSection({ jail }: { jail: Jail }): React.JSX.Element {
|
||||
const sectionStyles = useCommonSectionStyles();
|
||||
const styles = useStyles();
|
||||
return (
|
||||
<div className={sectionStyles.section}>
|
||||
<div className={sectionStyles.sectionHeader}>
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<Text as="h2" size={500} weight="semibold">
|
||||
Log Paths & Patterns
|
||||
</Text>
|
||||
@@ -347,13 +374,12 @@ 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={sectionStyles.section}>
|
||||
<div className={sectionStyles.sectionHeader}>
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<Text as="h2" size={500} weight="semibold">
|
||||
Ban-time Escalation
|
||||
</Text>
|
||||
@@ -419,7 +445,6 @@ function IgnoreListSection({
|
||||
onToggleIgnoreSelf,
|
||||
}: IgnoreListSectionProps): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const sectionStyles = useCommonSectionStyles();
|
||||
const [inputVal, setInputVal] = useState("");
|
||||
const [opError, setOpError] = useState<string | null>(null);
|
||||
|
||||
@@ -445,8 +470,8 @@ function IgnoreListSection({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={sectionStyles.section}>
|
||||
<div className={sectionStyles.sectionHeader}>
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM }}>
|
||||
<Text as="h2" size={500} weight="semibold">
|
||||
Ignore List (IP Whitelist)
|
||||
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
type TableColumnDefinition,
|
||||
createTableColumn,
|
||||
} from "@fluentui/react-components";
|
||||
import { useCardStyles, useCommonSectionStyles } from "../theme/commonStyles";
|
||||
import {
|
||||
ArrowClockwiseRegular,
|
||||
ArrowSyncRegular,
|
||||
@@ -59,7 +58,36 @@ 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",
|
||||
@@ -89,6 +117,20 @@ 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",
|
||||
@@ -166,7 +208,6 @@ 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);
|
||||
@@ -179,8 +220,8 @@ function JailOverviewSection(): React.JSX.Element {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={sectionStyles.section}>
|
||||
<div className={sectionStyles.sectionHeader}>
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<Text as="h2" size={500} weight="semibold">
|
||||
Jail Overview
|
||||
{total > 0 && (
|
||||
@@ -317,7 +358,6 @@ 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("");
|
||||
@@ -375,8 +415,8 @@ function BanUnbanForm({ jailNames, onBan, onUnban }: BanUnbanFormProps): React.J
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={sectionStyles.section}>
|
||||
<div className={sectionStyles.sectionHeader}>
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<Text as="h2" size={500} weight="semibold">
|
||||
Ban / Unban IP
|
||||
</Text>
|
||||
@@ -504,8 +544,6 @@ 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("");
|
||||
|
||||
@@ -516,8 +554,8 @@ function IpLookupSection(): React.JSX.Element {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={sectionStyles.section}>
|
||||
<div className={sectionStyles.sectionHeader}>
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<Text as="h2" size={500} weight="semibold">
|
||||
IP Lookup
|
||||
</Text>
|
||||
@@ -557,7 +595,7 @@ function IpLookupSection(): React.JSX.Element {
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className={`${cardStyles.card} ${styles.lookupResult}`}>
|
||||
<div className={styles.lookupResult}>
|
||||
<div className={styles.lookupRow}>
|
||||
<Text className={styles.lookupLabel}>IP:</Text>
|
||||
<Text className={styles.mono}>{result.ip}</Text>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Button,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
Select,
|
||||
Spinner,
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -21,17 +22,19 @@ import {
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
Text,
|
||||
Toolbar,
|
||||
ToolbarButton,
|
||||
Tooltip,
|
||||
makeStyles,
|
||||
tokens,
|
||||
} from "@fluentui/react-components";
|
||||
import { ArrowCounterclockwiseRegular, DismissRegular } from "@fluentui/react-icons";
|
||||
import { DashboardFilterBar } from "../components/DashboardFilterBar";
|
||||
import { WorldMap } from "../components/WorldMap";
|
||||
import { useMapData } from "../hooks/useMapData";
|
||||
import { useMapColorThresholds } from "../hooks/useMapColorThresholds";
|
||||
import type { TimeRange } from "../types/map";
|
||||
import type { BanOriginFilter } from "../types/ban";
|
||||
import { BAN_ORIGIN_FILTER_LABELS } from "../types/ban";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
@@ -53,23 +56,34 @@ const useStyles = makeStyles({
|
||||
flexWrap: "wrap",
|
||||
gap: tokens.spacingHorizontalM,
|
||||
},
|
||||
filterBar: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: tokens.spacingHorizontalM,
|
||||
padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`,
|
||||
background: tokens.colorNeutralBackground3,
|
||||
borderRadius: tokens.borderRadiusMedium,
|
||||
border: `1px solid ${tokens.colorNeutralStroke2}`,
|
||||
},
|
||||
tableWrapper: {
|
||||
overflow: "auto",
|
||||
maxHeight: "420px",
|
||||
borderRadius: tokens.borderRadiusMedium,
|
||||
border: `1px solid ${tokens.colorNeutralStroke1}`,
|
||||
},
|
||||
filterBar: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: tokens.spacingHorizontalM,
|
||||
padding: tokens.spacingVerticalS,
|
||||
borderRadius: tokens.borderRadiusMedium,
|
||||
backgroundColor: tokens.colorNeutralBackground2,
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Time-range options
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TIME_RANGE_OPTIONS: { label: string; value: TimeRange }[] = [
|
||||
{ label: "Last 24 hours", value: "24h" },
|
||||
{ label: "Last 7 days", value: "7d" },
|
||||
{ label: "Last 30 days", value: "30d" },
|
||||
{ label: "Last 365 days", value: "365d" },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MapPage
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -119,20 +133,41 @@ export function MapPage(): React.JSX.Element {
|
||||
World Map
|
||||
</Text>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM, flexWrap: "wrap" }}>
|
||||
<DashboardFilterBar
|
||||
timeRange={range}
|
||||
onTimeRangeChange={(value) => {
|
||||
setRange(value);
|
||||
<Toolbar size="small">
|
||||
<Select
|
||||
aria-label="Time range"
|
||||
value={range}
|
||||
onChange={(_ev, data): void => {
|
||||
setRange(data.value as TimeRange);
|
||||
setSelectedCountry(null);
|
||||
}}
|
||||
originFilter={originFilter}
|
||||
onOriginFilterChange={(value) => {
|
||||
setOriginFilter(value);
|
||||
size="small"
|
||||
>
|
||||
{TIME_RANGE_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
{/* Origin filter */}
|
||||
<Select
|
||||
aria-label="Origin filter"
|
||||
value={originFilter}
|
||||
onChange={(_ev, data): void => {
|
||||
setOriginFilter(data.value as BanOriginFilter);
|
||||
setSelectedCountry(null);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
>
|
||||
{(["all", "blocklist", "selfblock"] as BanOriginFilter[]).map((f) => (
|
||||
<option key={f} value={f}>
|
||||
{BAN_ORIGIN_FILTER_LABELS[f]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<ToolbarButton
|
||||
icon={<ArrowCounterclockwiseRegular />}
|
||||
onClick={(): void => {
|
||||
refresh();
|
||||
@@ -140,7 +175,7 @@ export function MapPage(): React.JSX.Element {
|
||||
disabled={loading}
|
||||
title="Refresh"
|
||||
/>
|
||||
</div>
|
||||
</Toolbar>
|
||||
</div>
|
||||
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
@@ -164,7 +199,6 @@ export function MapPage(): React.JSX.Element {
|
||||
{!loading && !error && (
|
||||
<WorldMap
|
||||
countries={countries}
|
||||
countryNames={countryNames}
|
||||
selectedCountry={selectedCountry}
|
||||
onSelectCountry={setSelectedCountry}
|
||||
thresholdLow={thresholdLow}
|
||||
|
||||
@@ -6,11 +6,7 @@ import { ConfigPage } from "../ConfigPage";
|
||||
|
||||
// Mock all tab components to avoid deep render trees and API calls.
|
||||
vi.mock("../../components/config", () => ({
|
||||
JailsTab: ({ initialJail }: { initialJail?: string }) => (
|
||||
<div data-testid="jails-tab" data-initial-jail={initialJail}>
|
||||
JailsTab
|
||||
</div>
|
||||
),
|
||||
JailsTab: () => <div data-testid="jails-tab">JailsTab</div>,
|
||||
FiltersTab: () => <div data-testid="filters-tab">FiltersTab</div>,
|
||||
ActionsTab: () => <div data-testid="actions-tab">ActionsTab</div>,
|
||||
ServerTab: () => <div data-testid="server-tab">ServerTab</div>,
|
||||
@@ -57,22 +53,4 @@ describe("ConfigPage", () => {
|
||||
renderPage();
|
||||
expect(screen.getByRole("heading", { name: /configuration/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("selects the Jails tab based on location state", () => {
|
||||
render(
|
||||
<MemoryRouter
|
||||
initialEntries={[
|
||||
{ pathname: "/config", state: { tab: "jails", jail: "sshd" } },
|
||||
]}
|
||||
>
|
||||
<FluentProvider theme={webLightTheme}>
|
||||
<ConfigPage />
|
||||
</FluentProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const jailsTab = screen.getByTestId("jails-tab");
|
||||
expect(jailsTab).toBeInTheDocument();
|
||||
expect(jailsTab).toHaveAttribute("data-initial-jail", "sshd");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||
import { HistoryPage } from "../HistoryPage";
|
||||
|
||||
let lastQuery: Record<string, unknown> | null = null;
|
||||
const mockUseHistory = vi.fn((query: Record<string, unknown>) => {
|
||||
lastQuery = query;
|
||||
return {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
loading: false,
|
||||
error: null,
|
||||
setPage: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../hooks/useHistory", () => ({
|
||||
useHistory: (query: Record<string, unknown>) => mockUseHistory(query),
|
||||
useIpHistory: () => ({ detail: null, loading: false, error: null, refresh: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("../components/WorldMap", () => ({
|
||||
WorldMap: () => <div data-testid="world-map" />,
|
||||
}));
|
||||
|
||||
vi.mock("../api/config", () => ({
|
||||
fetchMapColorThresholds: async () => ({
|
||||
threshold_low: 10,
|
||||
threshold_medium: 50,
|
||||
threshold_high: 100,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("HistoryPage", () => {
|
||||
it("renders DashboardFilterBar and applies origin+range filters", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<FluentProvider theme={webLightTheme}>
|
||||
<HistoryPage />
|
||||
</FluentProvider>,
|
||||
);
|
||||
|
||||
// Initial load should include the default query.
|
||||
expect(lastQuery).toEqual({ page_size: 50 });
|
||||
|
||||
// Change the time-range and origin filter, then apply.
|
||||
await user.click(screen.getByRole("button", { name: /Last 7 days/i }));
|
||||
await user.click(screen.getByRole("button", { name: /Blocklist/i }));
|
||||
await user.click(screen.getByRole("button", { name: /Apply/i }));
|
||||
|
||||
expect(lastQuery).toMatchObject({ range: "7d", origin: "blocklist" });
|
||||
});
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { JailsPage } from "../JailsPage";
|
||||
import type { JailSummary } from "../../types/jail";
|
||||
|
||||
const mockNavigate = vi.fn();
|
||||
|
||||
vi.mock("react-router-dom", async () => {
|
||||
const actual = (await vi.importActual<typeof import("react-router-dom")>(
|
||||
"react-router-dom",
|
||||
)) as unknown as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../hooks/useJails", () => ({
|
||||
useJails: () => ({
|
||||
jails: [
|
||||
{
|
||||
name: "sshd",
|
||||
enabled: true,
|
||||
running: true,
|
||||
idle: false,
|
||||
backend: "systemd",
|
||||
find_time: 600,
|
||||
ban_time: 3600,
|
||||
max_retry: 5,
|
||||
status: {
|
||||
currently_banned: 1,
|
||||
total_banned: 10,
|
||||
currently_failed: 0,
|
||||
total_failed: 0,
|
||||
},
|
||||
},
|
||||
] as JailSummary[],
|
||||
total: 1,
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
startJail: vi.fn().mockResolvedValue(undefined),
|
||||
stopJail: vi.fn().mockResolvedValue(undefined),
|
||||
setIdle: vi.fn().mockResolvedValue(undefined),
|
||||
reloadJail: vi.fn().mockResolvedValue(undefined),
|
||||
reloadAll: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
}));
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<FluentProvider theme={webLightTheme}>
|
||||
<JailsPage />
|
||||
</FluentProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("JailsPage", () => {
|
||||
it("navigates to Configuration → Jails when a jail is clicked", async () => {
|
||||
renderPage();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByText("sshd"));
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/config", {
|
||||
state: { tab: "jails", jail: "sshd" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||
import { MapPage } from "../MapPage";
|
||||
|
||||
const mockFetchMapColorThresholds = vi.fn(async () => ({
|
||||
threshold_low: 10,
|
||||
threshold_medium: 50,
|
||||
threshold_high: 100,
|
||||
}));
|
||||
|
||||
let lastArgs: { range: string; origin: string } = { range: "", origin: "" };
|
||||
const mockUseMapData = vi.fn((range: string, origin: string) => {
|
||||
lastArgs = { range, origin };
|
||||
return {
|
||||
countries: {},
|
||||
countryNames: {},
|
||||
bans: [],
|
||||
total: 0,
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../hooks/useMapData", () => ({
|
||||
useMapData: (range: string, origin: string) => mockUseMapData(range, origin),
|
||||
}));
|
||||
|
||||
vi.mock("../api/config", async () => ({
|
||||
fetchMapColorThresholds: mockFetchMapColorThresholds,
|
||||
}));
|
||||
|
||||
const mockWorldMap = vi.fn((_props: unknown) => <div data-testid="world-map" />);
|
||||
vi.mock("../components/WorldMap", () => ({
|
||||
WorldMap: (props: unknown) => {
|
||||
mockWorldMap(props);
|
||||
return <div data-testid="world-map" />;
|
||||
},
|
||||
}));
|
||||
|
||||
describe("MapPage", () => {
|
||||
it("renders DashboardFilterBar and updates data when filters change", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<FluentProvider theme={webLightTheme}>
|
||||
<MapPage />
|
||||
</FluentProvider>,
|
||||
);
|
||||
|
||||
// Initial load should call useMapData with default filters.
|
||||
expect(lastArgs).toEqual({ range: "24h", origin: "all" });
|
||||
|
||||
// Map should receive country names from the hook so tooltips can show human-readable labels.
|
||||
expect(mockWorldMap).toHaveBeenCalled();
|
||||
const firstCallArgs = mockWorldMap.mock.calls[0]?.[0];
|
||||
expect(firstCallArgs).toMatchObject({ countryNames: {} });
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Last 7 days/i }));
|
||||
expect(lastArgs.range).toBe("7d");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Blocklist/i }));
|
||||
expect(lastArgs.origin).toBe("blocklist");
|
||||
});
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user