Standardize API response envelope shapes across all endpoints

This commit standardizes how API responses are wrapped, solving issue #24.

Problem:
- Inconsistent response envelopes (jails vs items vs bans vs no wrapper)
- Frontend required multiple field name variants
- Integration bugs from branching logic
- No clear pattern for different response types

Solution:
- Created response.py with base classes: PaginatedListResponse,
  CollectionResponse, CommandResponse
- Standardized all list/collection responses to use 'items' field
- Domain-specific field names for detail and aggregation responses
- Updated all backends routers and mappers
- Updated frontend types and hooks to match

Changes:
Backend:
- backend/app/models/response.py (new): Base response models
- backend/app/models/ban.py: Updated responses to inherit from bases
- backend/app/models/jail.py: Updated JailListResponse, JailCommandResponse
- backend/app/models/config.py: Updated collection responses
- backend/app/services/jail_service.py: Updated return statements
- backend/app/mappers/ban_mappers.py: Updated 'bans' to 'items'
- backend/tests/test_mappers/test_ban_mappers.py: Updated tests

Frontend:
- frontend/src/types/jail.ts: Updated response interfaces
- frontend/src/types/config.ts: Updated response interfaces
- frontend/src/hooks/useActiveBans.ts: Updated selector
- frontend/src/hooks/useJailList.ts: Updated selector
- frontend/src/hooks/useJailConfigs.ts: Updated selector
- frontend/src/hooks/useConfigActiveStatus.ts: Updated field access
- frontend/src/hooks/useJailAdmin.ts: Updated field access

Documentation:
- Docs/Backend-Development.md: Added § 4.1 API Response Envelope Policy

The policy defines:
1. Paginated lists use PaginatedListResponse (items, total, page, page_size)
2. Non-paginated collections use CollectionResponse (items, total)
3. Detail responses use entity-specific field names (jail, status, settings)
4. Command responses use CommandResponse (message, success, optional target)
5. Aggregations use domain-specific fields (jails, countries, buckets, bans)

All responses now follow one of these patterns, reducing frontend complexity.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-28 10:12:55 +02:00
parent 7ba1cf7ca2
commit 1c673d600c
16 changed files with 415 additions and 86 deletions

View File

@@ -310,6 +310,158 @@ async def login(
--- ---
## 4.1 API Response Envelope Policy
All API responses must follow a **consistent wrapper pattern**. This standardization reduces frontend branching logic, prevents integration bugs, and makes the API easier to document and maintain.
### Response Patterns
#### Pattern 1: Paginated Lists
Use `PaginatedListResponse[T]` for endpoints that return paginated collections:
```python
from app.models.response import PaginatedListResponse
class JailListResponse(PaginatedListResponse[JailSummary]):
"""Response for ``GET /api/jails``."""
pass
# Returns:
{
"items": [...], # T[]
"total": 100, # int: total items across all pages
"page": 2, # int: current page (1-based)
"page_size": 20 # int: items per page
}
```
**When to use:** Endpoints that support pagination parameters (`page`, `page_size`, `limit`, `offset`).
#### Pattern 2: Non-Paginated Collections
Use `CollectionResponse[T]` for endpoints that return a complete collection without pagination:
```python
from app.models.response import CollectionResponse
class JailConfigListResponse(CollectionResponse[JailConfig]):
"""Response for ``GET /api/config/jails``."""
pass
# Returns:
{
"items": [...], # T[]
"total": 42 # int: total items
}
```
**When to use:** Endpoints that return a complete collection (not paginated). The frontend can render all items without worrying about paging.
#### Pattern 3: Single-Item Detail Responses
Use **domain-specific field names** (not generic wrappers) for detail endpoints:
```python
class JailDetailResponse(BaseModel):
"""Response for ``GET /api/jails/{name}``."""
jail: Jail
ignore_list: list[str]
ignore_self: bool
# Returns:
{
"jail": { ... }, # Jail object
"ignore_list": [...], # Additional context
"ignore_self": true
}
```
**When to use:** Endpoints that fetch a single entity. Use the entity name as the field (jail, status, settings, etc.).
**Field naming:**
- Primary entity uses its own name: `jail`, `status`, `settings`, etc.
- Related or supplementary data uses descriptive names: `ignore_list`, `warnings`, `metadata`, etc.
#### Pattern 4: Command/Action Responses
Use `CommandResponse` for endpoints that execute commands:
```python
from app.models.response import CommandResponse
class JailCommandResponse(CommandResponse):
"""Generic response for jail control commands."""
jail: str # Target identifier (optional)
# Returns:
{
"message": "Jail 'sshd' started.",
"success": true,
"jail": "sshd" # Optional: target identifier
}
```
**When to use:** POST/PUT/DELETE endpoints that perform operations (start jail, ban IP, update config, etc.).
**Fields:**
- `message: str` — Human-readable result or error description.
- `success: bool` — Operation succeeded (default: true). Use false for non-exception error handlers.
- Optional domain-specific fields (jail, ip, etc.) to identify the affected resource.
#### Pattern 5: Aggregation Responses
Use domain-specific field names for aggregated data:
```python
class BansByJailResponse(BaseModel):
"""Response for ``GET /api/dashboard/bans/by-jail``."""
jails: list[JailBanCount] # Aggregated per-jail data
total: int # Total count across all jails
class BansByCountryResponse(BaseModel):
"""Response for ``GET /api/dashboard/bans/by-country``."""
countries: dict[str, int] # Country code → count
country_names: dict[str, str] # Country code → name
bans: list[DashboardBanItem] # Full list for rendering companion table
total: int # Total ban count
# Returns:
{
"jails": [ { "jail": "sshd", "count": 42 }, ... ],
"total": 500
}
```
**When to use:** Endpoints that return computed/aggregated data. Use field names that reflect the data (jails, countries, buckets, etc.).
### Summary Table
| Pattern | Used for | Field Names | Example |
|---------|----------|---|---|
| **PaginatedListResponse** | Paginated collections | `items`, `total`, `page`, `page_size` | `GET /api/dashboard/bans` |
| **CollectionResponse** | Complete collections | `items`, `total` | `GET /api/config/jails` |
| **Detail Response** | Single entity + metadata | Entity name + descriptors | `GET /api/jails/{name}` |
| **CommandResponse** | Action results | `message`, `success` + optional identifiers | `POST /api/jails/{name}/start` |
| **Aggregation Response** | Computed data | Domain-specific names | `GET /api/dashboard/bans/by-jail` |
### Rules
1. **Always wrap lists in `items` field** — Consistency aids frontend parsing.
- ✅ `{ "items": [...], "total": 100 }`
- ❌ `{ "jails": [...], "total": 100 }` (for list endpoints; OK for aggregations)
2. **Aggregation responses are exceptions** — They use domain-specific field names because the data represents computed results, not a simple list.
- ✅ `{ "countries": {...}, "jails": [...], "total": 100 }`
3. **Every response with >1 item must include `total`** — Enables frontend to understand scale.
4. **Paginated responses must include `page` and `page_size`** — Enables the frontend to render pagination controls.
5. **No ad-hoc wrapper objects** — Don't invent new response shapes. Use the patterns above.
---
## 5. Pydantic Models ## 5. Pydantic Models
- Every model inherits from `pydantic.BaseModel`. - Every model inherits from `pydantic.BaseModel`.

View File

@@ -1,21 +1,3 @@
## 23) No global cancellation policy on route transitions
- Where found:
- [frontend/src/hooks](frontend/src/hooks)
- Why this is needed:
- Many hooks cancel individually, but route-wide cancellation remains inconsistent.
- Goal:
- Provide a global request lifecycle cancellation mechanism.
- What to do:
- Introduce navigation-aware cancellation context/manager.
- Possible traps and issues:
- Over-cancel can break long-lived background fetches unintentionally.
- Docs changes needed:
- Add request lifecycle policy.
- Doc references:
- [Docs/Web-Development.md](Docs/Web-Development.md)
---
## 24) API response wrapper shape is inconsistent ## 24) API response wrapper shape is inconsistent
- Where found: - Where found:
- [backend/app/routers/dashboard.py](backend/app/routers/dashboard.py) - [backend/app/routers/dashboard.py](backend/app/routers/dashboard.py)

View File

@@ -47,7 +47,7 @@ def map_domain_active_ban_list_to_response(
) -> ActiveBanListResponse: ) -> ActiveBanListResponse:
"""Convert a domain active ban list to a response model.""" """Convert a domain active ban list to a response model."""
return ActiveBanListResponse( return ActiveBanListResponse(
bans=[map_domain_active_ban_to_response(ban) for ban in domain_list.bans], items=[map_domain_active_ban_to_response(ban) for ban in domain_list.bans],
total=domain_list.total, total=domain_list.total,
) )

View File

@@ -8,6 +8,8 @@ from typing import Literal
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from app.models.response import CollectionResponse, PaginatedListResponse
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Time-range selector # Time-range selector
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -100,13 +102,17 @@ class BanResponse(BaseModel):
ban: Ban ban: Ban
class BanListResponse(BaseModel): class BanListResponse(PaginatedListResponse[Ban]):
"""Paginated list of ban records.""" """Paginated list of ban records.
model_config = ConfigDict(strict=True) Request: `GET /api/bans` with optional pagination and filter parameters.
Response: Paginated collection of ban records with total count.
bans: list[Ban] = Field(default_factory=list) Note: Unlike most list endpoints, this endpoint uses `page` and `page_size`
total: int = Field(..., ge=0, description="Total number of matching records.") for pagination. When using this response, ensure the router provides these fields.
"""
pass
class ActiveBan(BaseModel): class ActiveBan(BaseModel):
@@ -125,13 +131,17 @@ class ActiveBan(BaseModel):
country: str | None = Field(default=None, description="ISO 3166-1 alpha-2 country code.") country: str | None = Field(default=None, description="ISO 3166-1 alpha-2 country code.")
class ActiveBanListResponse(BaseModel): class ActiveBanListResponse(CollectionResponse[ActiveBan]):
"""List of all currently active bans across all jails.""" """List of all currently active bans across all jails.
model_config = ConfigDict(strict=True) Request: `GET /api/bans/active` with optional filter parameters.
Response: Non-paginated collection of currently active bans with total count.
bans: list[ActiveBan] = Field(default_factory=list) Note: This endpoint does not support pagination. All matching bans are returned.
total: int = Field(..., ge=0) For paginated results, use individual jail endpoints or the dashboard ban-list view.
"""
pass
class UnbanAllResponse(BaseModel): class UnbanAllResponse(BaseModel):
@@ -186,15 +196,14 @@ class DashboardBanItem(BaseModel):
) )
class DashboardBanListResponse(BaseModel): class DashboardBanListResponse(PaginatedListResponse[DashboardBanItem]):
"""Paginated dashboard ban-list response.""" """Paginated dashboard ban-list response.
model_config = ConfigDict(strict=True) Request: `GET /api/dashboard/bans` with time range, page, and filter parameters.
Response: Paginated collection of dashboard ban items with geo-enrichment.
"""
items: list[DashboardBanItem] = Field(default_factory=list) pass
total: int = Field(..., ge=0, description="Total bans in the selected time window.")
page: int = Field(..., ge=1)
page_size: int = Field(..., ge=1)
class BansByCountryResponse(BaseModel): class BansByCountryResponse(BaseModel):
@@ -313,23 +322,14 @@ class BansByJailResponse(BaseModel):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class JailBannedIpsResponse(BaseModel): class JailBannedIpsResponse(PaginatedListResponse[ActiveBan]):
"""Paginated response for ``GET /api/jails/{name}/banned``. """Paginated response for ``GET /api/jails/{name}/banned``.
Contains only the current page of active ban entries for a single jail, Contains only the current page of active ban entries for a single jail,
geo-enriched exclusively for the page slice to avoid rate-limit issues. geo-enriched exclusively for the page slice to avoid rate-limit issues.
Request: `GET /api/jails/{name}/banned` with page and page_size parameters.
Response: Paginated collection of active bans for the specified jail.
""" """
model_config = ConfigDict(strict=True) pass
items: list[ActiveBan] = Field(
default_factory=list,
description="Active ban entries for the current page.",
)
total: int = Field(
...,
ge=0,
description="Total matching entries (after applying the search filter).",
)
page: int = Field(..., ge=1, description="Current page number (1-based).")
page_size: int = Field(..., ge=1, description="Number of items per page.")

View File

@@ -10,6 +10,7 @@ from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.config import get_settings from app.config import get_settings
from app.models.response import CollectionResponse
from app.utils.path_utils import validate_log_path from app.utils.path_utils import validate_log_path
DNSMode = Literal["yes", "warn", "no", "raw"] DNSMode = Literal["yes", "warn", "no", "raw"]
@@ -109,13 +110,13 @@ class JailConfigResponse(BaseModel):
jail: JailConfig jail: JailConfig
class JailConfigListResponse(BaseModel): class JailConfigListResponse(CollectionResponse[JailConfig]):
"""Response for ``GET /api/config/jails``.""" """Response for ``GET /api/config/jails``.
model_config = ConfigDict(strict=True) Returns a non-paginated collection of jail configurations.
"""
jails: list[JailConfig] = Field(default_factory=list) pass
total: int = Field(..., ge=0)
class JailConfigUpdate(BaseModel): class JailConfigUpdate(BaseModel):
@@ -920,13 +921,13 @@ class InactiveJail(BaseModel):
) )
class InactiveJailListResponse(BaseModel): class InactiveJailListResponse(CollectionResponse[InactiveJail]):
"""Response for ``GET /api/config/jails/inactive``.""" """Response for ``GET /api/config/jails/inactive``.
model_config = ConfigDict(strict=True) Returns a non-paginated collection of inactive jail configurations.
"""
jails: list[InactiveJail] = Field(default_factory=list) pass
total: int = Field(..., ge=0)
class ActivateJailRequest(BaseModel): class ActivateJailRequest(BaseModel):

View File

@@ -6,6 +6,7 @@ Request, response, and domain models used by the jails router and service.
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from app.models.config import BantimeEscalation from app.models.config import BantimeEscalation
from app.models.response import CommandResponse, CollectionResponse
class JailStatus(BaseModel): class JailStatus(BaseModel):
@@ -62,13 +63,13 @@ class JailSummary(BaseModel):
status: JailStatus | None = None status: JailStatus | None = None
class JailListResponse(BaseModel): class JailListResponse(CollectionResponse[JailSummary]):
"""Response for ``GET /api/jails``.""" """Response for ``GET /api/jails``.
model_config = ConfigDict(strict=True) Returns a non-paginated collection of jail summaries with their current status.
"""
jails: list[JailSummary] = Field(default_factory=list) pass
total: int = Field(..., ge=0)
class JailDetailResponse(BaseModel): class JailDetailResponse(BaseModel):
@@ -79,13 +80,13 @@ class JailDetailResponse(BaseModel):
jail: Jail jail: Jail
class JailCommandResponse(BaseModel): class JailCommandResponse(CommandResponse):
"""Generic response for jail control commands (start, stop, reload, idle).""" """Generic response for jail control commands (start, stop, reload, idle).
model_config = ConfigDict(strict=True) Extends the base CommandResponse with a jail field to identify the target.
"""
message: str jail: str = Field(..., description="Target jail name, or '*' for operations on all jails.")
jail: str
class IgnoreIpRequest(BaseModel): class IgnoreIpRequest(BaseModel):

View File

@@ -0,0 +1,193 @@
"""Base response wrapper models for standardized API envelopes.
All API endpoints should wrap their responses using the base classes defined here.
This ensures a consistent response shape across the entire API, reducing frontend
branching logic and integration bugs.
Response Patterns:
1. **Paginated List** — Use `PaginatedListResponse[T]` for endpoints returning paginated items.
Example: GET /api/jails, GET /api/dashboard/bans
```python
class MyListResponse(PaginatedListResponse[MyItem]):
pass
# Returns:
{
"items": [...],
"total": 100,
"page": 1,
"page_size": 20
}
```
2. **Simple Collection** — Use `CollectionResponse[T]` for non-paginated collections.
Example: GET /api/bans/active
```python
class MyCollectionResponse(CollectionResponse[MyItem]):
pass
# Returns:
{
"items": [...],
"total": 50
}
```
3. **Single Item Detail** — Use domain model directly wrapped in a named field.
Example: GET /api/jails/{name}, GET /api/dashboard/status
```python
class MyDetailResponse(BaseModel):
jail: Jail # or: status: ServerStatus, settings: ServerSettings
# Optional extra fields (ignore_list, warnings, etc.)
# Returns:
{
"jail": {...},
"ignore_list": [...]
}
```
4. **Command/Action Result** — Use `CommandResponse` for success/acknowledgement.
Example: POST /api/jails/{name}/start, POST /api/bans
```python
class MyCommandResponse(CommandResponse):
jail: str # Optional: target identifier
# Returns:
{
"message": "Jail 'sshd' started.",
"success": true,
"jail": "sshd"
}
```
5. **Aggregated Data** — Use domain-specific aggregation models with metadata.
Example: GET /api/dashboard/bans/by-jail
```python
class MyAggregationResponse(BaseModel):
jails: list[JailBanCount] # or: countries, buckets, etc.
total: int
# Optional: filters, time_range metadata
# Returns:
{
"jails": [...],
"total": 1234
}
```
Note on field naming:
- Paginated/collection responses always use "items" for the data array.
- Detail responses use domain-specific field names (jail, status, settings).
- Aggregation responses use domain-specific field names (jails, countries, buckets).
- All responses with multiple items include a "total" field.
"""
from typing import Generic, TypeVar
from pydantic import BaseModel, ConfigDict, Field
T = TypeVar("T")
class PaginatedListResponse(BaseModel, Generic[T]):
"""Standardized paginated list response.
Use this as a base for all endpoints that return paginated collections.
Automatically includes pagination metadata to support frontend paging UIs.
Fields:
items: The data items for the current page.
total: Total number of items matching the query (across all pages).
page: Current page number (1-based).
page_size: Number of items per page.
Example:
```python
class UserListResponse(PaginatedListResponse[User]):
pass
# Returns:
{
"items": [...],
"total": 150,
"page": 2,
"page_size": 50
}
```
"""
model_config = ConfigDict(strict=True)
items: list[T] = Field(default_factory=list, description="Data items for the current page.")
total: int = Field(..., ge=0, description="Total number of items matching the query.")
page: int = Field(..., ge=1, description="Current page number (1-based).")
page_size: int = Field(..., ge=1, description="Number of items per page.")
class CollectionResponse(BaseModel, Generic[T]):
"""Standardized non-paginated collection response.
Use this for endpoints that return a collection without pagination support.
Simpler than PaginatedListResponse, but still provides consistent wrapping.
Fields:
items: The data items in the collection.
total: Total number of items.
Example:
```python
class ActiveBansResponse(CollectionResponse[ActiveBan]):
pass
# Returns:
{
"items": [...],
"total": 42
}
```
"""
model_config = ConfigDict(strict=True)
items: list[T] = Field(default_factory=list, description="Collection items.")
total: int = Field(..., ge=0, description="Total number of items.")
class CommandResponse(BaseModel):
"""Standardized command/action result response.
Use this for endpoints that execute commands (start, stop, reload, ban, unban, etc.).
Always includes a success indicator and human-readable message.
Fields:
message: Human-readable result message or error description.
success: Whether the command succeeded (default True).
Example:
```python
class StartJailResponse(CommandResponse):
jail: str # Optional: target identifier
# Returns:
{
"message": "Jail 'sshd' started.",
"success": true,
"jail": "sshd"
}
```
"""
model_config = ConfigDict(strict=True)
message: str = Field(..., description="Human-readable result or error message.")
success: bool = Field(
default=True,
description="Whether the command succeeded (false for errors in non-exception handlers).",
)

View File

@@ -204,7 +204,7 @@ async def list_jails(socket_path: str, state: JailServiceState) -> JailListRespo
log.info("jail_list_fetched", count=len(jail_names)) log.info("jail_list_fetched", count=len(jail_names))
if not jail_names: if not jail_names:
return JailListResponse(jails=[], total=0) return JailListResponse(items=[], total=0)
# 2. Fetch summary data for every jail in parallel. # 2. Fetch summary data for every jail in parallel.
summaries: list[JailSummary] = await asyncio.gather( summaries: list[JailSummary] = await asyncio.gather(
@@ -212,7 +212,7 @@ async def list_jails(socket_path: str, state: JailServiceState) -> JailListRespo
return_exceptions=False, return_exceptions=False,
) )
return JailListResponse(jails=list(summaries), total=len(summaries)) return JailListResponse(items=list(summaries), total=len(summaries))
async def _fetch_jail_summary( async def _fetch_jail_summary(

View File

@@ -76,9 +76,9 @@ class TestActiveBanListMapper:
result = map_domain_active_ban_list_to_response(domain_list) result = map_domain_active_ban_list_to_response(domain_list)
assert result.total == 2 assert result.total == 2
assert len(result.bans) == 2 assert len(result.items) == 2
assert result.bans[0].ip == "1.1.1.1" assert result.items[0].ip == "1.1.1.1"
assert result.bans[1].ip == "2.2.2.2" assert result.items[1].ip == "2.2.2.2"
def test_handles_empty_list(self) -> None: def test_handles_empty_list(self) -> None:
"""Empty list is handled correctly.""" """Empty list is handled correctly."""
@@ -87,7 +87,7 @@ class TestActiveBanListMapper:
result = map_domain_active_ban_list_to_response(domain_list) result = map_domain_active_ban_list_to_response(domain_list)
assert result.total == 0 assert result.total == 0
assert len(result.bans) == 0 assert len(result.items) == 0
class TestDashboardBanItemMapper: class TestDashboardBanItemMapper:

View File

@@ -30,7 +30,7 @@ export interface UseActiveBansResult {
export function useActiveBans(): UseActiveBansResult { export function useActiveBans(): UseActiveBansResult {
const fetcher = useCallback((signal: AbortSignal) => fetchActiveBans(signal), []); const fetcher = useCallback((signal: AbortSignal) => fetchActiveBans(signal), []);
const selector = useCallback((response: ActiveBanListResponse) => response.bans, []); const selector = useCallback((response: ActiveBanListResponse) => response.items, []);
const { items: bans, loading, error, refresh } = useListData<ActiveBanListResponse, ActiveBan>({ const { items: bans, loading, error, refresh } = useListData<ActiveBanListResponse, ActiveBan>({
fetcher, fetcher,

View File

@@ -72,8 +72,8 @@ export function useConfigActiveStatus(): UseConfigActiveStatusResult {
.then(([jailsResp, configsResp]) => { .then(([jailsResp, configsResp]) => {
if (ctrl.signal.aborted) return; if (ctrl.signal.aborted) return;
const summaries: JailSummary[] = jailsResp.jails; const summaries: JailSummary[] = jailsResp.items;
const configs: JailConfig[] = configsResp.jails; const configs: JailConfig[] = configsResp.items;
// Active jails: enabled in the runtime summary list. // Active jails: enabled in the runtime summary list.
const jailSet = new Set<string>( const jailSet = new Set<string>(

View File

@@ -52,7 +52,7 @@ export function useJailAdmin(): UseJailAdminResult {
fetchInactiveJails(ctrl.signal) fetchInactiveJails(ctrl.signal)
.then((resp) => { .then((resp) => {
if (!ctrl.signal.aborted) { if (!ctrl.signal.aborted) {
setInactiveJails(resp.jails); setInactiveJails(resp.items);
} }
}) })
.catch((err: unknown) => { .catch((err: unknown) => {

View File

@@ -29,7 +29,7 @@ export function useJailConfigs(): UseJailConfigsResult {
[], [],
); );
const selector = useCallback((response: JailConfigListResponse) => response.jails, []); const selector = useCallback((response: JailConfigListResponse) => response.items, []);
const onSuccess = useCallback((response: JailConfigListResponse) => { const onSuccess = useCallback((response: JailConfigListResponse) => {
setTotal(response.total); setTotal(response.total);

View File

@@ -39,7 +39,7 @@ export function useJails(): UseJailsResult {
[], [],
); );
const selector = useCallback((response: JailListResponse) => response.jails, []); const selector = useCallback((response: JailListResponse) => response.items, []);
const onSuccess = useCallback((response: JailListResponse) => { const onSuccess = useCallback((response: JailListResponse) => {
setTotal(response.total); setTotal(response.total);

View File

@@ -67,7 +67,7 @@ export interface JailConfigResponse {
} }
export interface JailConfigListResponse { export interface JailConfigListResponse {
jails: JailConfig[]; items: JailConfig[];
total: number; total: number;
} }
@@ -536,7 +536,7 @@ export interface InactiveJail {
} }
export interface InactiveJailListResponse { export interface InactiveJailListResponse {
jails: InactiveJail[]; items: InactiveJail[];
total: number; total: number;
} }

View File

@@ -66,7 +66,7 @@ export interface JailSummary {
*/ */
export interface JailListResponse { export interface JailListResponse {
/** All known jails. */ /** All known jails. */
jails: JailSummary[]; items: JailSummary[];
/** Total number of jails. */ /** Total number of jails. */
total: number; total: number;
} }
@@ -174,7 +174,7 @@ export interface ActiveBan {
*/ */
export interface ActiveBanListResponse { export interface ActiveBanListResponse {
/** List of all currently active bans. */ /** List of all currently active bans. */
bans: ActiveBan[]; items: ActiveBan[];
/** Total number of active bans. */ /** Total number of active bans. */
total: number; total: number;
} }