Extends the existing Engine-API-based docker integration adapter rather than
porting Termix's SSH+CLI approach, since ArchNest's docker integrations only
ever configure a baseUrl. Adds backend/src/docker/{client,exec}.ts and
backend/src/routes/docker.ts (REST + websocket exec-terminal via raw socket
hijack), and a new Containers page wired into the sidebar/router.
Verified end-to-end against a real dockerd instance and a real container in
this sandbox, which caught and fixed a genuine bug: calling /exec/{id}/resize
before starting the exec hangs the daemon indefinitely; fixed by setting the
initial size via ConsoleSize at exec-create time instead.
243 lines
9.3 KiB
TypeScript
243 lines
9.3 KiB
TypeScript
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' }),
|
|
|
|
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 }),
|
|
}),
|
|
}
|
|
|
|
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>
|
|
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
|
|
}
|