2026-06-18 19:13:27 +00:00
|
|
|
const TOKEN_KEY = 'archnest_token'
|
|
|
|
|
|
|
|
|
|
export function getToken(): string | null {
|
|
|
|
|
return localStorage.getItem(TOKEN_KEY)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function setToken(token: string | null) {
|
|
|
|
|
if (token) localStorage.setItem(TOKEN_KEY, token)
|
|
|
|
|
else localStorage.removeItem(TOKEN_KEY)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export class ApiError extends Error {
|
|
|
|
|
status: number
|
|
|
|
|
constructor(status: number, message: string) {
|
|
|
|
|
super(message)
|
|
|
|
|
this.status = status
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
|
|
|
|
const token = getToken()
|
|
|
|
|
const headers: Record<string, string> = {
|
2026-06-18 19:26:48 +00:00
|
|
|
...(options.body ? { 'Content-Type': 'application/json' } : {}),
|
2026-06-18 19:13:27 +00:00
|
|
|
...(options.headers as Record<string, string> | undefined),
|
|
|
|
|
}
|
|
|
|
|
if (token) headers.Authorization = `Bearer ${token}`
|
|
|
|
|
|
|
|
|
|
const res = await fetch(`/api${path}`, { ...options, headers })
|
|
|
|
|
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
let message = res.statusText
|
|
|
|
|
try {
|
|
|
|
|
const body = await res.json()
|
|
|
|
|
message = body.error ?? message
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore non-JSON error bodies
|
|
|
|
|
}
|
|
|
|
|
throw new ApiError(res.status, message)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (res.status === 204) return undefined as T
|
|
|
|
|
return res.json() as Promise<T>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const api = {
|
|
|
|
|
getSetupStatus: () => apiFetch<{ needsSetup: boolean }>('/system/setup-status'),
|
|
|
|
|
setup: (username: string, password: string) =>
|
|
|
|
|
apiFetch<{ token: string }>('/setup', { method: 'POST', body: JSON.stringify({ username, password }) }),
|
|
|
|
|
login: (username: string, password: string) =>
|
|
|
|
|
apiFetch<{ token: string }>('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) }),
|
2026-06-18 20:08:30 +00:00
|
|
|
me: () => apiFetch<{ user: AuthUser }>('/auth/me'),
|
|
|
|
|
updateMe: (data: Partial<{ displayName: string | null; email: string | null; avatarDataUrl: string | null }>) =>
|
|
|
|
|
apiFetch<{ user: AuthUser }>('/auth/me', { method: 'PUT', body: JSON.stringify(data) }),
|
Add auth Phase 2: password change, sessions, and login audit log (#27)
Builds out the Settings → Security tab (previously a "coming soon"
placeholder) and the backend behind it. Still single-user; multi-user
and SSO remain Phases 3-4.
Backend:
- New `sessions` table (id, user_id, user_agent, ip, created_at,
last_seen_at) and `login_events` table (user_id, username, ip,
user_agent, success, created_at).
- Login and setup now mint a session row and embed its id as a `sid`
claim in the JWT. The `authenticate` hook validates that the session
still exists (and bumps last_seen_at), so revoking a session genuinely
invalidates its token instead of relying on the JWT signature alone.
Tokens minted before sessions existed have no `sid` and stay valid
until expiry, for backward compatibility.
- Every login attempt (success and failure) is recorded in login_events
for the audit trail.
- New endpoints: PUT /api/auth/password (verifies current via bcrypt,
hashes new at cost 12, revokes all *other* sessions on success),
GET /api/auth/sessions, DELETE /api/auth/sessions/:id (can't revoke
the current one), POST /api/auth/logout (revokes current session),
GET /api/auth/login-events?limit.
- AuthContext.logout() now calls POST /api/auth/logout best-effort so
signing out revokes the server session, not just the local token.
Frontend:
- SecuritySection: change-password form (current/new/confirm with
show/hide and client-side validation), active-sessions list (device
description from user-agent, IP, last-seen relative time, per-session
"Sign out" for non-current sessions), and a recent login-activity feed
(success/failure dot, user, IP, relative time).
- api.ts: changePassword/listSessions/revokeSession/logout/
listLoginEvents + AuthSession/LoginEvent types.
Verified end-to-end against a throwaway backend instance: session
creation, second-device session, failed-login logging, cross-session
revocation invalidating the revoked token, password change keeping the
current session alive while revoking others, and logout invalidating the
current session. Frontend + backend both type-check clean.
Co-authored-by: Samuel James <ssamjame@amazon.com>
Co-authored-by: Kiro <noreply@kiro.dev>
2026-06-20 11:50:56 -04:00
|
|
|
changePassword: (currentPassword: string, newPassword: string) =>
|
|
|
|
|
apiFetch<{ ok: boolean }>('/auth/password', { method: 'PUT', body: JSON.stringify({ currentPassword, newPassword }) }),
|
|
|
|
|
listSessions: () => apiFetch<{ sessions: AuthSession[] }>('/auth/sessions'),
|
|
|
|
|
revokeSession: (id: string) => apiFetch<{ ok: boolean }>(`/auth/sessions/${id}`, { method: 'DELETE' }),
|
|
|
|
|
logout: () => apiFetch<{ ok: boolean }>('/auth/logout', { method: 'POST' }),
|
|
|
|
|
listLoginEvents: (limit = 20) => apiFetch<{ events: LoginEvent[] }>(`/auth/login-events?limit=${limit}`),
|
2026-06-18 19:13:27 +00:00
|
|
|
|
Add auth Phase 3: multi-user accounts with admin/member roles (#28)
Implements Phase 3 of the auth roadmap: multiple user accounts (cap 10),
an admin/member role model, and admin-only gating of config-mutating
routes. Dashboard data stays shared across all users (per the product
decision in HANDOFF.md — this is a household/self-hosted dashboard, not
a multi-tenant app), so there is no per-user data isolation.
Schema (backend/src/db/index.ts):
- Idempotent migration adds `role` (default 'admin') and `active`
(default 1) columns to `users` when missing. The 'admin' default means
the pre-existing single user is backfilled to admin on deploy and keeps
full access; newly created users are inserted explicitly as 'member'.
Verified against a production-like old schema (columns added, existing
user backfilled to admin/active).
Auth + access control:
- `/api/setup` creates the first user as admin. Login enforces `active`
(deactivated accounts get 403) and embeds the live role in the session.
- `app.authenticate` now reads role+active fresh from the DB on every
request (not from the possibly-stale JWT claim), rejects inactive
accounts, and stashes the role on req.user.
- New `requireAdmin` (auth + role check) and `adminOnly` (role check for
routes already behind the plugin-level authenticate hook) decorators.
User management (admin-only, in auth.ts):
- GET/POST/PUT/DELETE /api/users — list, create (admin sets a temp
password; no public signup), change role, activate/deactivate, delete.
- 10-user cap enforced server-side; guard rails prevent removing the last
active admin (demote/deactivate/delete) and deleting your own account;
deactivating or deleting a user drops their sessions immediately.
Admin-only route gating (members get 403):
- integrations create/update/delete/test, tunnels create/delete, data
export/import. Read routes and tunnel connect/disconnect stay open to
all authenticated users, as do all the SSH/Docker/RDP tools and
bookmarks (members are trusted to use the tooling, per product decision).
Frontend:
- api.ts: listUsers/createUser/updateUser/deleteUser + ManagedUser type;
role+active added to AuthUser.
- Settings: new admin-only "Users" section (create form, role toggle,
activate/deactivate, delete, 10-cap indicator). Nav filters the Users
tab by role and guards ?tab= deep-links. Data & Backup shows an
admin-only notice for members; Integrations shows a read-only banner
for members. (Backend remains the real enforcement boundary.)
Verified end-to-end against a throwaway backend: role assignment,
member 403s on every admin-only route + 200s on shared/read routes,
admin 200/201s, last-admin guards (409/400), deactivation killing an
active session and blocking re-login (then reactivation restoring it),
and the 10-user cap (409 on the 11th). Both frontend and backend
type-check clean.
Co-authored-by: Samuel James <ssamjame@amazon.com>
Co-authored-by: Kiro <noreply@kiro.dev>
2026-06-20 12:43:24 -04:00
|
|
|
listUsers: () => apiFetch<{ users: ManagedUser[] }>('/users'),
|
|
|
|
|
createUser: (data: { username: string; password: string; role: 'admin' | 'member'; displayName?: string | null; email?: string | null }) =>
|
|
|
|
|
apiFetch<{ user: ManagedUser }>('/users', { method: 'POST', body: JSON.stringify(data) }),
|
|
|
|
|
updateUser: (id: number, data: Partial<{ role: 'admin' | 'member'; active: boolean }>) =>
|
|
|
|
|
apiFetch<{ user: ManagedUser }>(`/users/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
|
|
|
|
deleteUser: (id: number) => apiFetch<{ ok: boolean }>(`/users/${id}`, { method: 'DELETE' }),
|
|
|
|
|
|
2026-06-18 19:13:27 +00:00
|
|
|
listIntegrations: () => apiFetch<{ integrations: Integration[] }>('/integrations'),
|
|
|
|
|
createIntegration: (data: { type: string; name: string; config?: Record<string, string>; secrets?: Record<string, string> }) =>
|
|
|
|
|
apiFetch<{ integration: Integration }>('/integrations', { method: 'POST', body: JSON.stringify(data) }),
|
|
|
|
|
updateIntegration: (id: number, data: Partial<{ name: string; config: Record<string, string>; secrets: Record<string, string> }>) =>
|
|
|
|
|
apiFetch<{ integration: Integration }>(`/integrations/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
|
|
|
|
deleteIntegration: (id: number) => apiFetch<void>(`/integrations/${id}`, { method: 'DELETE' }),
|
|
|
|
|
testIntegration: (id: number) => apiFetch<{ ok: boolean; message: string }>(`/integrations/${id}/test`, { method: 'POST' }),
|
|
|
|
|
|
|
|
|
|
listBookmarks: () => apiFetch<{ bookmarks: Bookmark[] }>('/bookmarks'),
|
|
|
|
|
listBookmarkCategories: () => apiFetch<{ categories: BookmarkCategory[] }>('/bookmarks/categories'),
|
2026-06-18 19:33:26 +00:00
|
|
|
createBookmarkCategory: (data: { name: string; icon?: string; sortOrder?: number }) =>
|
|
|
|
|
apiFetch<{ id: number }>('/bookmarks/categories', { method: 'POST', body: JSON.stringify(data) }),
|
2026-06-18 19:13:27 +00:00
|
|
|
createBookmark: (data: { categoryId?: number | null; title: string; url: string; icon?: string; favorite?: boolean }) =>
|
|
|
|
|
apiFetch<{ id: number }>('/bookmarks', { method: 'POST', body: JSON.stringify(data) }),
|
2026-06-18 19:33:26 +00:00
|
|
|
updateBookmark: (id: number, data: Partial<{ categoryId: number | null; title: string; url: string; icon: string; favorite: boolean }>) =>
|
|
|
|
|
apiFetch<{ ok: boolean }>(`/bookmarks/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
2026-06-18 19:13:27 +00:00
|
|
|
deleteBookmark: (id: number) => apiFetch<void>(`/bookmarks/${id}`, { method: 'DELETE' }),
|
Add bulk delete-all for bookmarks (#20)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
* Add JSON-converted bookmark import file for Archnest data import
Converts homarr-bookmarks.md into the format expected by /api/data/import.
* Auto-populate bookmark icons via favicon service in import JSON
Each bookmark now points to Google's favicon endpoint for its domain
instead of having no icon at all.
* Add bulk delete-all for bookmarks
Adds DELETE /api/bookmarks to clear every bookmark in one request, and a
"Delete All" button (with confirmation) on the BookNest page so re-imports
don't require deleting dozens of entries one at a time.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 09:09:44 -04:00
|
|
|
deleteAllBookmarks: () => apiFetch<void>('/bookmarks', { method: 'DELETE' }),
|
2026-06-18 19:56:10 +00:00
|
|
|
|
|
|
|
|
listEvents: (limit = 20) => apiFetch<{ events: Event[] }>(`/events?limit=${limit}`),
|
|
|
|
|
listResources: () => apiFetch<{ resources: Resource[] }>('/integrations/resources'),
|
2026-06-19 11:40:59 +00:00
|
|
|
|
|
|
|
|
listTunnels: () => apiFetch<{ tunnels: Tunnel[] }>('/tunnels'),
|
|
|
|
|
createTunnel: (data: {
|
|
|
|
|
name: string
|
|
|
|
|
integrationId: number
|
|
|
|
|
mode: 'local' | 'remote' | 'dynamic'
|
|
|
|
|
sourcePort: number
|
|
|
|
|
endpointHost?: string
|
|
|
|
|
endpointPort?: number
|
|
|
|
|
autoStart?: boolean
|
|
|
|
|
maxRetries?: number
|
|
|
|
|
retryIntervalMs?: number
|
|
|
|
|
}) => apiFetch<{ tunnel: Tunnel }>('/tunnels', { method: 'POST', body: JSON.stringify(data) }),
|
|
|
|
|
deleteTunnel: (id: number) => apiFetch<void>(`/tunnels/${id}`, { method: 'DELETE' }),
|
|
|
|
|
connectTunnel: (id: number) =>
|
|
|
|
|
apiFetch<{ ok: boolean; status: string; error: string | null; retryCount: number }>(`/tunnels/${id}/connect`, { method: 'POST' }),
|
|
|
|
|
disconnectTunnel: (id: number) =>
|
|
|
|
|
apiFetch<{ ok: boolean; status: string; error: string | null; retryCount: number }>(`/tunnels/${id}/disconnect`, { method: 'POST' }),
|
2026-06-19 11:56:04 +00:00
|
|
|
|
|
|
|
|
listFiles: (integrationId: number, path: string) =>
|
|
|
|
|
apiFetch<{ path: string; entries: FileEntry[] }>(`/files/${integrationId}/list?path=${encodeURIComponent(path)}`),
|
|
|
|
|
readFile: (integrationId: number, path: string) =>
|
|
|
|
|
apiFetch<{ content: string; encoding: 'utf8' | 'base64'; size: number; mode: number }>(
|
|
|
|
|
`/files/${integrationId}/content?path=${encodeURIComponent(path)}`,
|
|
|
|
|
),
|
|
|
|
|
writeFile: (integrationId: number, path: string, content: string, encoding: 'utf8' | 'base64' = 'utf8') =>
|
|
|
|
|
apiFetch<{ ok: boolean }>(`/files/${integrationId}/content`, { method: 'PUT', body: JSON.stringify({ path, content, encoding }) }),
|
|
|
|
|
mkdir: (integrationId: number, path: string) =>
|
|
|
|
|
apiFetch<{ ok: boolean }>(`/files/${integrationId}/mkdir`, { method: 'POST', body: JSON.stringify({ path }) }),
|
|
|
|
|
renameFile: (integrationId: number, from: string, to: string) =>
|
|
|
|
|
apiFetch<{ ok: boolean }>(`/files/${integrationId}/rename`, { method: 'POST', body: JSON.stringify({ from, to }) }),
|
|
|
|
|
deleteFile: (integrationId: number, path: string, isDirectory = false) =>
|
|
|
|
|
apiFetch<{ ok: boolean }>(`/files/${integrationId}/delete`, { method: 'POST', body: JSON.stringify({ path, isDirectory }) }),
|
|
|
|
|
chmodFile: (integrationId: number, path: string, mode: string) =>
|
|
|
|
|
apiFetch<{ ok: boolean }>(`/files/${integrationId}/chmod`, { method: 'POST', body: JSON.stringify({ path, mode }) }),
|
|
|
|
|
downloadFileUrl: (integrationId: number, path: string) =>
|
|
|
|
|
`/api/files/${integrationId}/download?path=${encodeURIComponent(path)}`,
|
|
|
|
|
uploadFile: async (integrationId: number, path: string, file: File) => {
|
|
|
|
|
const form = new FormData()
|
|
|
|
|
form.append('path', path)
|
|
|
|
|
form.append('file', file)
|
|
|
|
|
const token = getToken()
|
|
|
|
|
const res = await fetch(`/api/files/${integrationId}/upload`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
|
|
|
|
body: form,
|
|
|
|
|
})
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
let message = res.statusText
|
|
|
|
|
try {
|
|
|
|
|
message = (await res.json()).error ?? message
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore non-JSON error bodies
|
|
|
|
|
}
|
|
|
|
|
throw new ApiError(res.status, message)
|
|
|
|
|
}
|
|
|
|
|
return res.json() as Promise<{ ok: boolean; path: string }>
|
|
|
|
|
},
|
2026-06-19 12:28:30 +00:00
|
|
|
|
|
|
|
|
listContainers: (integrationId: number) =>
|
|
|
|
|
apiFetch<{ containers: Container[] }>(`/docker/${integrationId}/containers`),
|
|
|
|
|
containerStats: (integrationId: number, id: string) =>
|
|
|
|
|
apiFetch<ContainerStats>(`/docker/${integrationId}/containers/${encodeURIComponent(id)}/stats`),
|
|
|
|
|
containerLogs: (integrationId: number, id: string, tail = 200) =>
|
|
|
|
|
apiFetch<{ logs: string }>(`/docker/${integrationId}/containers/${encodeURIComponent(id)}/logs?tail=${tail}`),
|
|
|
|
|
containerAction: (integrationId: number, id: string, action: 'start' | 'stop' | 'restart' | 'pause' | 'unpause') =>
|
|
|
|
|
apiFetch<{ ok: boolean }>(`/docker/${integrationId}/containers/${encodeURIComponent(id)}/${action}`, { method: 'POST' }),
|
|
|
|
|
removeContainer: (integrationId: number, id: string, force = false) =>
|
|
|
|
|
apiFetch<{ ok: boolean }>(`/docker/${integrationId}/containers/${encodeURIComponent(id)}/remove`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: JSON.stringify({ force }),
|
|
|
|
|
}),
|
2026-06-19 15:38:30 +00:00
|
|
|
|
Add Docker-over-SSH management and push-agent monitoring (#31)
Expands the Containers feature with two new ways to see and manage Docker
containers without exposing the Docker Engine TCP socket, plus the docs and
roadmap entries that frame them.
Docker over SSH (management):
- Runs the `docker` CLI on a remote SSH host instead of talking to the Engine
TCP API, reusing the existing SSH transport (jump-host chaining, host-key
verification, key/password auth) via connectTarget + execCommand. No dockerd
socket has to be exposed — the mesh + SSH auth are the gate.
- backend/src/ssh/docker.ts: list/logs/start/stop/restart/pause/unpause/remove
and an interactive `docker exec` shell builder. Container refs are validated
against a strict allowlist and single-quoted to prevent command injection;
action verbs are whitelisted.
- backend/src/routes/dockerSsh.ts: REST routes mirroring the TCP Docker API
shape (mutating actions gated by adminOnly) + a /api/docker-ssh/exec
WebSocket modeled on the terminal PTY plumbing.
- Note: the SSH path uses the ssh2 key/password auth; it does not implement the
OpenSSH-certificate (OPKSSH) fallback that the terminal route has.
Docker push-agent monitoring (self-hosted, read-only):
- A small bash agent (agent/archnest-docker-agent.sh) runs on each Docker VM,
collects a rich snapshot (docker ps + inspect + a stats snapshot), masks
secret-looking env values locally, and POSTs it to ArchNest. VMs need
outbound-only mesh access — no exposed port, no SSH for monitoring.
- backend/src/routes/agents.ts: token-gated ingest
(POST /api/agents/docker/report, ARCHNEST_AGENT_TOKEN, constant-time compare;
503 when unset, so it is disabled by default) plus user-auth read endpoints
(hosts list with staleness flag, per-host containers, single-container
detail). New docker_agent_reports table (latest report per host).
- Ingest stores data only; it never executes anything from the agent.
Containers page:
- Host selector now spans Docker API, SSH, and Agent sources.
- Intra-page tabs: a Containers list plus dynamic, closeable per-container
detail tabs opened by clicking a container name. Agent detail shows
overview/state/stats/ports/networks/mounts/env(masked)/labels; docker/ssh
degrade gracefully. Agent rows are read-only; docker/ssh keep management.
Docs/roadmap:
- docs/docker-agent-monitoring.md (design doc, written before implementation).
- ROADMAP.md: LXC management (paid), Docker monitoring agent tiering
(push self-hosted now / pull-agent paid), terminal grid tiering.
Deferred (documented, not built here): the mesh-prerequisite setup gate, the
paid pull-agent (Option 2), per-host tokens, time-series metrics.
Requires ARCHNEST_AGENT_TOKEN in the backend env to enable agent ingest.
Verified: backend `tsc --noEmit` and frontend `tsc -b && vite build` both pass;
agent jq filters, byte conversion, and `bash -n` checked locally.
Co-authored-by: Samuel James <ssamjame@amazon.com>
Co-authored-by: Kiro <noreply@kiro.dev>
2026-06-20 16:24:57 -04:00
|
|
|
// Docker over SSH: runs the `docker` CLI on a remote SSH host instead of the
|
|
|
|
|
// Docker Engine TCP API. `integrationId` here is an SSH integration.
|
|
|
|
|
listSshContainers: (integrationId: number) =>
|
|
|
|
|
apiFetch<{ containers: SshContainer[] }>(`/docker-ssh/${integrationId}/containers`),
|
|
|
|
|
sshContainerLogs: (integrationId: number, id: string, tail = 200) =>
|
|
|
|
|
apiFetch<{ logs: string }>(`/docker-ssh/${integrationId}/containers/${encodeURIComponent(id)}/logs?tail=${tail}`),
|
|
|
|
|
sshContainerAction: (integrationId: number, id: string, action: 'start' | 'stop' | 'restart' | 'pause' | 'unpause') =>
|
|
|
|
|
apiFetch<{ ok: boolean }>(`/docker-ssh/${integrationId}/containers/${encodeURIComponent(id)}/${action}`, { method: 'POST' }),
|
|
|
|
|
removeSshContainer: (integrationId: number, id: string, force = false) =>
|
|
|
|
|
apiFetch<{ ok: boolean }>(`/docker-ssh/${integrationId}/containers/${encodeURIComponent(id)}/remove`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: JSON.stringify({ force }),
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
// Docker monitoring agents (push model). Read-only; agents POST reports to a
|
|
|
|
|
// token-gated ingest endpoint that the UI never calls.
|
|
|
|
|
listAgentHosts: () => apiFetch<{ hosts: AgentHost[] }>('/agents/docker/hosts'),
|
|
|
|
|
listAgentContainers: (hostId: string) =>
|
|
|
|
|
apiFetch<AgentHostContainers>(`/agents/docker/hosts/${encodeURIComponent(hostId)}/containers`),
|
|
|
|
|
getAgentContainer: (hostId: string, containerId: string) =>
|
|
|
|
|
apiFetch<{ container: AgentContainer }>(
|
|
|
|
|
`/agents/docker/hosts/${encodeURIComponent(hostId)}/containers/${encodeURIComponent(containerId)}`,
|
|
|
|
|
),
|
|
|
|
|
|
2026-06-19 15:38:30 +00:00
|
|
|
getHostMetrics: (integrationId: number) => apiFetch<HostMetrics>(`/integrations/${integrationId}/metrics`),
|
Add host-to-host file transfer (Phase 7)
Ports the core of Termix's host-transfer feature: stream files/directories
between two SSH hosts through the backend via SFTP (read source -> write dest),
with up-front scan for progress totals, recursive directory support, optional
move, and cooperative cancellation. Leaves behind Termix's parallel-segment
workers, tar heuristics, watchdogs and retry orchestration as unjustified at
this scale.
Exposed via REST (start/list/status/cancel) with an in-memory transfer registry,
and surfaced in the Files page as a per-entry "send to another host" action plus
a live transfers progress panel. Verified end-to-end against two real SSH
endpoints: recursive copy (binary md5 match), move (source deleted), error
handling, and mid-stream cancel.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF
2026-06-19 15:52:13 +00:00
|
|
|
|
|
|
|
|
startTransfer: (data: { sourceIntegrationId: number; destIntegrationId: number; sourcePaths: string[]; destPath: string; move?: boolean }) =>
|
|
|
|
|
apiFetch<{ transferId: string }>('/transfers', { method: 'POST', body: JSON.stringify(data) }),
|
|
|
|
|
listTransfers: () => apiFetch<{ transfers: TransferProgress[] }>('/transfers'),
|
|
|
|
|
getTransfer: (id: string) => apiFetch<TransferProgress>(`/transfers/${id}`),
|
|
|
|
|
cancelTransfer: (id: string) => apiFetch<{ ok: boolean }>(`/transfers/${id}/cancel`, { method: 'POST' }),
|
2026-06-19 16:13:29 +00:00
|
|
|
|
|
|
|
|
exportData: () => apiFetch<DataExport>('/data/export'),
|
|
|
|
|
importData: (data: DataExport) =>
|
|
|
|
|
apiFetch<{ ok: boolean; imported: { integrations: number; bookmarkCategories: number; bookmarks: number; tunnels: number } }>('/data/import', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: JSON.stringify(data),
|
|
|
|
|
}),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface DataExport {
|
|
|
|
|
version: number
|
|
|
|
|
exportedAt?: string
|
|
|
|
|
integrations: Array<{ id: number; type: string; name: string; enabled: boolean; config: Record<string, string>; secrets: Record<string, string> }>
|
|
|
|
|
bookmarkCategories: Array<{ id: number; name: string; icon: string | null; sortOrder: number }>
|
|
|
|
|
bookmarks: Array<{ categoryId: number | null; title: string; url: string; icon: string | null; favorite: boolean }>
|
|
|
|
|
tunnels: Array<{ name: string; integrationId: number; mode: string; sourcePort: number; endpointHost: string; endpointPort: number; autoStart: boolean; maxRetries: number; retryIntervalMs: number }>
|
2026-06-18 19:13:27 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-18 20:08:30 +00:00
|
|
|
export interface AuthUser {
|
|
|
|
|
id: number
|
|
|
|
|
username: string
|
|
|
|
|
display_name: string | null
|
|
|
|
|
email: string | null
|
|
|
|
|
avatar_data_url: string | null
|
Add auth Phase 3: multi-user accounts with admin/member roles (#28)
Implements Phase 3 of the auth roadmap: multiple user accounts (cap 10),
an admin/member role model, and admin-only gating of config-mutating
routes. Dashboard data stays shared across all users (per the product
decision in HANDOFF.md — this is a household/self-hosted dashboard, not
a multi-tenant app), so there is no per-user data isolation.
Schema (backend/src/db/index.ts):
- Idempotent migration adds `role` (default 'admin') and `active`
(default 1) columns to `users` when missing. The 'admin' default means
the pre-existing single user is backfilled to admin on deploy and keeps
full access; newly created users are inserted explicitly as 'member'.
Verified against a production-like old schema (columns added, existing
user backfilled to admin/active).
Auth + access control:
- `/api/setup` creates the first user as admin. Login enforces `active`
(deactivated accounts get 403) and embeds the live role in the session.
- `app.authenticate` now reads role+active fresh from the DB on every
request (not from the possibly-stale JWT claim), rejects inactive
accounts, and stashes the role on req.user.
- New `requireAdmin` (auth + role check) and `adminOnly` (role check for
routes already behind the plugin-level authenticate hook) decorators.
User management (admin-only, in auth.ts):
- GET/POST/PUT/DELETE /api/users — list, create (admin sets a temp
password; no public signup), change role, activate/deactivate, delete.
- 10-user cap enforced server-side; guard rails prevent removing the last
active admin (demote/deactivate/delete) and deleting your own account;
deactivating or deleting a user drops their sessions immediately.
Admin-only route gating (members get 403):
- integrations create/update/delete/test, tunnels create/delete, data
export/import. Read routes and tunnel connect/disconnect stay open to
all authenticated users, as do all the SSH/Docker/RDP tools and
bookmarks (members are trusted to use the tooling, per product decision).
Frontend:
- api.ts: listUsers/createUser/updateUser/deleteUser + ManagedUser type;
role+active added to AuthUser.
- Settings: new admin-only "Users" section (create form, role toggle,
activate/deactivate, delete, 10-cap indicator). Nav filters the Users
tab by role and guards ?tab= deep-links. Data & Backup shows an
admin-only notice for members; Integrations shows a read-only banner
for members. (Backend remains the real enforcement boundary.)
Verified end-to-end against a throwaway backend: role assignment,
member 403s on every admin-only route + 200s on shared/read routes,
admin 200/201s, last-admin guards (409/400), deactivation killing an
active session and blocking re-login (then reactivation restoring it),
and the 10-user cap (409 on the 11th). Both frontend and backend
type-check clean.
Co-authored-by: Samuel James <ssamjame@amazon.com>
Co-authored-by: Kiro <noreply@kiro.dev>
2026-06-20 12:43:24 -04:00
|
|
|
role?: 'admin' | 'member'
|
|
|
|
|
active?: boolean
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface ManagedUser {
|
|
|
|
|
id: number
|
|
|
|
|
username: string
|
|
|
|
|
displayName: string | null
|
|
|
|
|
email: string | null
|
|
|
|
|
role: 'admin' | 'member'
|
|
|
|
|
active: boolean
|
|
|
|
|
createdAt: string
|
2026-06-18 20:08:30 +00:00
|
|
|
}
|
|
|
|
|
|
Add auth Phase 2: password change, sessions, and login audit log (#27)
Builds out the Settings → Security tab (previously a "coming soon"
placeholder) and the backend behind it. Still single-user; multi-user
and SSO remain Phases 3-4.
Backend:
- New `sessions` table (id, user_id, user_agent, ip, created_at,
last_seen_at) and `login_events` table (user_id, username, ip,
user_agent, success, created_at).
- Login and setup now mint a session row and embed its id as a `sid`
claim in the JWT. The `authenticate` hook validates that the session
still exists (and bumps last_seen_at), so revoking a session genuinely
invalidates its token instead of relying on the JWT signature alone.
Tokens minted before sessions existed have no `sid` and stay valid
until expiry, for backward compatibility.
- Every login attempt (success and failure) is recorded in login_events
for the audit trail.
- New endpoints: PUT /api/auth/password (verifies current via bcrypt,
hashes new at cost 12, revokes all *other* sessions on success),
GET /api/auth/sessions, DELETE /api/auth/sessions/:id (can't revoke
the current one), POST /api/auth/logout (revokes current session),
GET /api/auth/login-events?limit.
- AuthContext.logout() now calls POST /api/auth/logout best-effort so
signing out revokes the server session, not just the local token.
Frontend:
- SecuritySection: change-password form (current/new/confirm with
show/hide and client-side validation), active-sessions list (device
description from user-agent, IP, last-seen relative time, per-session
"Sign out" for non-current sessions), and a recent login-activity feed
(success/failure dot, user, IP, relative time).
- api.ts: changePassword/listSessions/revokeSession/logout/
listLoginEvents + AuthSession/LoginEvent types.
Verified end-to-end against a throwaway backend instance: session
creation, second-device session, failed-login logging, cross-session
revocation invalidating the revoked token, password change keeping the
current session alive while revoking others, and logout invalidating the
current session. Frontend + backend both type-check clean.
Co-authored-by: Samuel James <ssamjame@amazon.com>
Co-authored-by: Kiro <noreply@kiro.dev>
2026-06-20 11:50:56 -04:00
|
|
|
export interface AuthSession {
|
|
|
|
|
id: string
|
|
|
|
|
userAgent: string | null
|
|
|
|
|
ip: string | null
|
|
|
|
|
createdAt: string
|
|
|
|
|
lastSeenAt: string
|
|
|
|
|
current: boolean
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface LoginEvent {
|
|
|
|
|
id: number
|
|
|
|
|
username: string | null
|
|
|
|
|
ip: string | null
|
|
|
|
|
userAgent: string | null
|
|
|
|
|
success: boolean
|
|
|
|
|
createdAt: string
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 19:13:27 +00:00
|
|
|
export interface Integration {
|
|
|
|
|
id: number
|
|
|
|
|
type: string
|
|
|
|
|
name: string
|
|
|
|
|
enabled: boolean
|
|
|
|
|
status: string
|
|
|
|
|
config: Record<string, string>
|
Show saved indicator for secret fields instead of appearing deleted (#18)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
* Show saved indicator for secret fields instead of appearing blank/deleted
GET /api/integrations never returns decrypted secret values (by design),
so after navigating away and back, secret/key fields rendered empty -
looking exactly like the saved key had been deleted, even though it was
still intact and encrypted in the database. Expose which secret keys
exist (names only, never values) via secretKeys, and use it to label
fields as "saved" with an appropriate placeholder instead of blank.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:53:56 -04:00
|
|
|
secretKeys: string[]
|
2026-06-18 19:13:27 +00:00
|
|
|
lastCheckedAt: string | null
|
|
|
|
|
createdAt: string
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-19 11:40:59 +00:00
|
|
|
export interface Tunnel {
|
|
|
|
|
id: number
|
|
|
|
|
name: string
|
|
|
|
|
integrationId: number
|
|
|
|
|
mode: 'local' | 'remote' | 'dynamic'
|
|
|
|
|
sourcePort: number
|
|
|
|
|
endpointHost: string
|
|
|
|
|
endpointPort: number
|
|
|
|
|
autoStart: boolean
|
|
|
|
|
maxRetries: number
|
|
|
|
|
retryIntervalMs: number
|
|
|
|
|
createdAt: string
|
|
|
|
|
status: 'stopped' | 'connecting' | 'connected' | 'retrying' | 'error'
|
|
|
|
|
error: string | null
|
|
|
|
|
retryCount: number
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 19:13:27 +00:00
|
|
|
export interface Bookmark {
|
|
|
|
|
id: number
|
|
|
|
|
category_id: number | null
|
|
|
|
|
title: string
|
|
|
|
|
url: string
|
|
|
|
|
icon: string | null
|
|
|
|
|
favorite: number
|
|
|
|
|
status: string
|
|
|
|
|
last_checked_at: string | null
|
|
|
|
|
created_at: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface BookmarkCategory {
|
|
|
|
|
id: number
|
|
|
|
|
name: string
|
|
|
|
|
icon: string | null
|
|
|
|
|
sort_order: number
|
|
|
|
|
}
|
2026-06-18 19:56:10 +00:00
|
|
|
|
|
|
|
|
export interface Event {
|
|
|
|
|
id: number
|
|
|
|
|
type: string
|
|
|
|
|
title: string
|
|
|
|
|
source: string | null
|
|
|
|
|
created_at: string
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-19 11:56:04 +00:00
|
|
|
export interface FileEntry {
|
|
|
|
|
name: string
|
|
|
|
|
isDirectory: boolean
|
|
|
|
|
isSymlink: boolean
|
|
|
|
|
size: number
|
|
|
|
|
mode: number
|
|
|
|
|
mtime: number
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-19 12:28:30 +00:00
|
|
|
export interface Container {
|
|
|
|
|
id: string
|
|
|
|
|
name: string
|
|
|
|
|
image: string
|
|
|
|
|
state: string
|
|
|
|
|
status: string
|
|
|
|
|
ports: { privatePort: number; publicPort?: number; type: string }[]
|
|
|
|
|
}
|
|
|
|
|
|
Add Docker-over-SSH management and push-agent monitoring (#31)
Expands the Containers feature with two new ways to see and manage Docker
containers without exposing the Docker Engine TCP socket, plus the docs and
roadmap entries that frame them.
Docker over SSH (management):
- Runs the `docker` CLI on a remote SSH host instead of talking to the Engine
TCP API, reusing the existing SSH transport (jump-host chaining, host-key
verification, key/password auth) via connectTarget + execCommand. No dockerd
socket has to be exposed — the mesh + SSH auth are the gate.
- backend/src/ssh/docker.ts: list/logs/start/stop/restart/pause/unpause/remove
and an interactive `docker exec` shell builder. Container refs are validated
against a strict allowlist and single-quoted to prevent command injection;
action verbs are whitelisted.
- backend/src/routes/dockerSsh.ts: REST routes mirroring the TCP Docker API
shape (mutating actions gated by adminOnly) + a /api/docker-ssh/exec
WebSocket modeled on the terminal PTY plumbing.
- Note: the SSH path uses the ssh2 key/password auth; it does not implement the
OpenSSH-certificate (OPKSSH) fallback that the terminal route has.
Docker push-agent monitoring (self-hosted, read-only):
- A small bash agent (agent/archnest-docker-agent.sh) runs on each Docker VM,
collects a rich snapshot (docker ps + inspect + a stats snapshot), masks
secret-looking env values locally, and POSTs it to ArchNest. VMs need
outbound-only mesh access — no exposed port, no SSH for monitoring.
- backend/src/routes/agents.ts: token-gated ingest
(POST /api/agents/docker/report, ARCHNEST_AGENT_TOKEN, constant-time compare;
503 when unset, so it is disabled by default) plus user-auth read endpoints
(hosts list with staleness flag, per-host containers, single-container
detail). New docker_agent_reports table (latest report per host).
- Ingest stores data only; it never executes anything from the agent.
Containers page:
- Host selector now spans Docker API, SSH, and Agent sources.
- Intra-page tabs: a Containers list plus dynamic, closeable per-container
detail tabs opened by clicking a container name. Agent detail shows
overview/state/stats/ports/networks/mounts/env(masked)/labels; docker/ssh
degrade gracefully. Agent rows are read-only; docker/ssh keep management.
Docs/roadmap:
- docs/docker-agent-monitoring.md (design doc, written before implementation).
- ROADMAP.md: LXC management (paid), Docker monitoring agent tiering
(push self-hosted now / pull-agent paid), terminal grid tiering.
Deferred (documented, not built here): the mesh-prerequisite setup gate, the
paid pull-agent (Option 2), per-host tokens, time-series metrics.
Requires ARCHNEST_AGENT_TOKEN in the backend env to enable agent ingest.
Verified: backend `tsc --noEmit` and frontend `tsc -b && vite build` both pass;
agent jq filters, byte conversion, and `bash -n` checked locally.
Co-authored-by: Samuel James <ssamjame@amazon.com>
Co-authored-by: Kiro <noreply@kiro.dev>
2026-06-20 16:24:57 -04:00
|
|
|
export interface SshContainer {
|
|
|
|
|
id: string
|
|
|
|
|
name: string
|
|
|
|
|
image: string
|
|
|
|
|
state: string
|
|
|
|
|
status: string
|
|
|
|
|
/** Raw `docker ps` ports string (e.g. "0.0.0.0:8080->80/tcp"). */
|
|
|
|
|
ports: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface AgentHost {
|
|
|
|
|
hostId: string
|
|
|
|
|
hostname: string | null
|
|
|
|
|
reportedAt: string | null
|
|
|
|
|
receivedAt: string
|
|
|
|
|
containerCount: number
|
|
|
|
|
stale: boolean
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface AgentContainerPort {
|
|
|
|
|
hostIp?: string
|
|
|
|
|
hostPort?: number | null
|
|
|
|
|
containerPort: number
|
|
|
|
|
proto: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface AgentContainerStats {
|
|
|
|
|
cpuPercent?: number
|
|
|
|
|
memUsage?: number
|
|
|
|
|
memLimit?: number
|
|
|
|
|
netRxBytes?: number
|
|
|
|
|
netTxBytes?: number
|
|
|
|
|
blockReadBytes?: number
|
|
|
|
|
blockWriteBytes?: number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface AgentContainer {
|
|
|
|
|
id: string
|
|
|
|
|
name: string
|
|
|
|
|
image: string
|
|
|
|
|
imageId?: string
|
|
|
|
|
state: string
|
|
|
|
|
status: string
|
|
|
|
|
createdAt?: string
|
|
|
|
|
startedAt?: string
|
|
|
|
|
restartCount?: number
|
|
|
|
|
restartPolicy?: string
|
|
|
|
|
health?: string
|
|
|
|
|
ports: AgentContainerPort[]
|
|
|
|
|
networks: { name: string; ip?: string }[]
|
|
|
|
|
mounts: { type?: string; source?: string; destination?: string; rw?: boolean }[]
|
|
|
|
|
env: { key: string; value: string }[]
|
|
|
|
|
command?: string
|
|
|
|
|
labels?: Record<string, string>
|
|
|
|
|
stats?: AgentContainerStats
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface AgentHostContainers {
|
|
|
|
|
hostId: string
|
|
|
|
|
hostname: string | null
|
|
|
|
|
reportedAt: string | null
|
|
|
|
|
receivedAt: string
|
|
|
|
|
stale: boolean
|
|
|
|
|
containers: AgentContainer[]
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-19 12:28:30 +00:00
|
|
|
export interface ContainerStats {
|
|
|
|
|
cpuPercent: number
|
|
|
|
|
memUsage: number
|
|
|
|
|
memLimit: number
|
|
|
|
|
netRx: number
|
|
|
|
|
netTx: number
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 19:56:10 +00:00
|
|
|
export interface Resource {
|
|
|
|
|
name: string
|
|
|
|
|
status: 'healthy' | 'warning' | 'critical' | 'unknown'
|
|
|
|
|
detail?: string
|
|
|
|
|
integration: string
|
|
|
|
|
}
|
2026-06-19 15:38:30 +00:00
|
|
|
|
Add host-to-host file transfer (Phase 7)
Ports the core of Termix's host-transfer feature: stream files/directories
between two SSH hosts through the backend via SFTP (read source -> write dest),
with up-front scan for progress totals, recursive directory support, optional
move, and cooperative cancellation. Leaves behind Termix's parallel-segment
workers, tar heuristics, watchdogs and retry orchestration as unjustified at
this scale.
Exposed via REST (start/list/status/cancel) with an in-memory transfer registry,
and surfaced in the Files page as a per-entry "send to another host" action plus
a live transfers progress panel. Verified end-to-end against two real SSH
endpoints: recursive copy (binary md5 match), move (source deleted), error
handling, and mid-stream cancel.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF
2026-06-19 15:52:13 +00:00
|
|
|
export interface TransferProgress {
|
|
|
|
|
transferId: string
|
|
|
|
|
status: 'running' | 'completed' | 'failed' | 'cancelled'
|
|
|
|
|
sourceIntegrationId: number
|
|
|
|
|
destIntegrationId: number
|
|
|
|
|
sourcePaths: string[]
|
|
|
|
|
destPath: string
|
|
|
|
|
move: boolean
|
|
|
|
|
totalFiles: number
|
|
|
|
|
totalBytes: number
|
|
|
|
|
filesTransferred: number
|
|
|
|
|
bytesTransferred: number
|
|
|
|
|
currentFile: string | null
|
|
|
|
|
error: string | null
|
|
|
|
|
startedAt: number
|
|
|
|
|
finishedAt: number | null
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-19 15:38:30 +00:00
|
|
|
export interface HostMetrics {
|
|
|
|
|
cpu: { percent: number | null; cores: number | null; load: [number, number, number] | null }
|
|
|
|
|
memory: { percent: number | null; usedGiB: number | null; totalGiB: number | null }
|
|
|
|
|
disk: { percent: number | null; usedHuman: string | null; totalHuman: string | null; availableHuman: string | null }
|
|
|
|
|
uptime: { seconds: number | null; formatted: string | null }
|
|
|
|
|
network: { interfaces: Array<{ name: string; ip: string; state: string }> }
|
|
|
|
|
system: { hostname: string | null; kernel: string | null; os: string | null }
|
|
|
|
|
processes: {
|
|
|
|
|
total: number | null
|
|
|
|
|
running: number | null
|
|
|
|
|
top: Array<{ pid: string; user: string; cpu: string; mem: string; command: string }>
|
|
|
|
|
}
|
|
|
|
|
ports: { source: 'ss' | 'netstat' | 'none'; ports: Array<{ protocol: string; localAddress: string; localPort: number; state?: string; process?: string }> }
|
|
|
|
|
firewall: { type: 'iptables' | 'none'; status: 'active' | 'inactive' | 'unknown'; chains: Array<{ name: string; policy: string; rules: unknown[] }> }
|
|
|
|
|
loginStats: {
|
|
|
|
|
recentLogins: Array<{ user: string; ip: string; time: string; status: string }>
|
|
|
|
|
failedLogins: Array<{ user: string; ip: string; time: string; status: string }>
|
|
|
|
|
totalLogins: number
|
|
|
|
|
uniqueIPs: number
|
|
|
|
|
}
|
|
|
|
|
}
|