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) }),
|
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' }),
|
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
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
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 }[]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|