dev_arc_aws/src/lib/api.ts

310 lines
13 KiB
TypeScript
Raw Normal View History

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> = {
...(options.body ? { 'Content-Type': 'application/json' } : {}),
...(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 }) }),
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) }),
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'),
createBookmarkCategory: (data: { name: string; icon?: string; sortOrder?: number }) =>
apiFetch<{ id: number }>('/bookmarks/categories', { method: 'POST', body: JSON.stringify(data) }),
createBookmark: (data: { categoryId?: number | null; title: string; url: string; icon?: string; favorite?: boolean }) =>
apiFetch<{ id: number }>('/bookmarks', { method: 'POST', body: JSON.stringify(data) }),
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) }),
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' }),
listEvents: (limit = 20) => apiFetch<{ events: Event[] }>(`/events?limit=${limit}`),
listResources: () => apiFetch<{ resources: Resource[] }>('/integrations/resources'),
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' }),
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 }>
},
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 }),
}),
getHostMetrics: (integrationId: number) => apiFetch<HostMetrics>(`/integrations/${integrationId}/metrics`),
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' }),
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 }>
}
export interface AuthUser {
id: number
username: string
display_name: string | null
email: string | null
avatar_data_url: string | null
}
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[]
lastCheckedAt: string | null
createdAt: string
}
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
}
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
}
export interface Event {
id: number
type: string
title: string
source: string | null
created_at: string
}
export interface FileEntry {
name: string
isDirectory: boolean
isSymlink: boolean
size: number
mode: number
mtime: number
}
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
}
export interface Resource {
name: string
status: 'healthy' | 'warning' | 'critical' | 'unknown'
detail?: string
integration: string
}
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
}
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
}
}