From 0a1c21994155fcbe76f832dbc4ed483799eb884a Mon Sep 17 00:00:00 2001 From: Admin <1+admin@noreply.git.dcntrlized.org> Date: Mon, 3 Aug 2026 14:22:45 +0100 Subject: [PATCH] feat(frontend): extract console data layer into api/domain/hooks modules --- src/frontend/src/lib/api/client.ts | 39 ++++++ src/frontend/src/lib/api/containers.ts | 33 +++++ src/frontend/src/lib/api/registry.ts | 6 + src/frontend/src/lib/domain/filter.ts | 39 ++++++ src/frontend/src/lib/domain/merge.ts | 113 ++++++++++++++++ src/frontend/src/lib/domain/version.ts | 55 ++++++++ src/frontend/src/lib/hooks/useAsyncAction.ts | 26 ++++ .../src/lib/hooks/useContainerStore.ts | 19 +++ .../src/lib/hooks/useMasterRegistry.ts | 28 ++++ src/frontend/src/lib/types.ts | 123 +++++++++--------- src/frontend/src/lib/utils.ts | 6 + src/frontend/src/lib/ws/client.ts | 76 +++++------ src/frontend/src/lib/ws/registry.ts | 84 ++++++------ 13 files changed, 499 insertions(+), 148 deletions(-) create mode 100644 src/frontend/src/lib/api/client.ts create mode 100644 src/frontend/src/lib/api/containers.ts create mode 100644 src/frontend/src/lib/api/registry.ts create mode 100644 src/frontend/src/lib/domain/filter.ts create mode 100644 src/frontend/src/lib/domain/merge.ts create mode 100644 src/frontend/src/lib/domain/version.ts create mode 100644 src/frontend/src/lib/hooks/useAsyncAction.ts create mode 100644 src/frontend/src/lib/hooks/useContainerStore.ts create mode 100644 src/frontend/src/lib/hooks/useMasterRegistry.ts create mode 100644 src/frontend/src/lib/utils.ts diff --git a/src/frontend/src/lib/api/client.ts b/src/frontend/src/lib/api/client.ts new file mode 100644 index 0000000..52396bc --- /dev/null +++ b/src/frontend/src/lib/api/client.ts @@ -0,0 +1,39 @@ +export class ApiError extends Error { + readonly status: number + + constructor(message: string, status: number) { + super(message) + this.name = 'ApiError' + this.status = status + } +} + +async function readJson(res: Response): Promise { + const text = await res.text() + if (!text) return undefined as T + try { + return JSON.parse(text) as T + } catch { + return text as unknown as T + } +} + +async function request(path: string, init?: RequestInit): Promise { + const res = await fetch(path, init) + if (!res.ok) { + throw new ApiError(`Request failed (${res.status})`, res.status) + } + return readJson(res) +} + +export function get(path: string): Promise { + return request(path) +} + +export function post(path: string, body?: unknown): Promise { + return request(path, { + method: 'POST', + headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined, + body: body !== undefined ? JSON.stringify(body) : undefined, + }) +} diff --git a/src/frontend/src/lib/api/containers.ts b/src/frontend/src/lib/api/containers.ts new file mode 100644 index 0000000..447693d --- /dev/null +++ b/src/frontend/src/lib/api/containers.ts @@ -0,0 +1,33 @@ +import { post } from './client' +import type { RegistryItem, Service } from '../types' + +/** The payload shape the backend expects for install/uninstall/start/stop. */ +function registryPayload(s: Service): RegistryItem { + return { + name: s.name, + version: s.latestVersion || s.version || '', + image: s.registryImage || s.image || '', + title: s.title, + vendor: s.vendor, + author: s.author, + description: s.description, + source: s.source, + url: s.registryUrl, + } +} + +export function installContainer(service: Service): Promise { + return post('/api/containers/install', registryPayload(service)) +} + +export function uninstallContainer(service: Service): Promise { + return post('/api/containers/uninstall', registryPayload(service)) +} + +export function startContainer(service: Service): Promise { + return post('/api/containers/start', registryPayload(service)) +} + +export function stopContainer(service: Service): Promise { + return post('/api/containers/stop', registryPayload(service)) +} diff --git a/src/frontend/src/lib/api/registry.ts b/src/frontend/src/lib/api/registry.ts new file mode 100644 index 0000000..390f97c --- /dev/null +++ b/src/frontend/src/lib/api/registry.ts @@ -0,0 +1,6 @@ +import { get } from './client' +import type { RegistryItem } from '../types' + +export function fetchMasterRegistry(): Promise { + return get('/api/registry') +} diff --git a/src/frontend/src/lib/domain/filter.ts b/src/frontend/src/lib/domain/filter.ts new file mode 100644 index 0000000..fb18c93 --- /dev/null +++ b/src/frontend/src/lib/domain/filter.ts @@ -0,0 +1,39 @@ +import type { FilterOption, Service, ServiceFilter } from '../types' + +export const FILTERS: FilterOption[] = [ + { id: 'all', label: 'All', hint: 'Every service on the node' }, + { id: 'running', label: 'Running', hint: 'Services currently running' }, + { id: 'updates', label: 'Updates', hint: 'A newer version is published' }, + { id: 'available', label: 'Available', hint: 'In the registry, not yet installed' }, +] + +export function filterServices( + services: Service[], + query: string, + filter: ServiceFilter +): Service[] { + const q = query.trim().toLowerCase() + + return services.filter((s) => { + const matchesSearch = + !q || + s.name.toLowerCase().includes(q) || + (s.image ?? '').toLowerCase().includes(q) || + (s.source ?? '').toLowerCase().includes(q) + + if (!matchesSearch) return false + if (filter === 'running') return s.status.toLowerCase() === 'running' + if (filter === 'updates') return s.hasUpdate + if (filter === 'available') return s.origin === 'remote' + return true + }) +} + +export function serviceCounts(services: Service[]) { + const running = services.filter((s) => s.status.toLowerCase() === 'running').length + const updates = services.filter((s) => s.hasUpdate).length + const available = services.filter((s) => s.origin === 'remote').length + const installed = services.length - available + + return { total: services.length, installed, running, updates, available } +} diff --git a/src/frontend/src/lib/domain/merge.ts b/src/frontend/src/lib/domain/merge.ts new file mode 100644 index 0000000..4a6e66a --- /dev/null +++ b/src/frontend/src/lib/domain/merge.ts @@ -0,0 +1,113 @@ +import type { Container, RegistryItem, Service } from '../types' +import { baseImageOf, compareVersions, normalizeVersion, versionFromImage } from './version' + +/** Statuses that should win the merge when a name maps to several containers. */ +const STATUS_PRIORITY: Record = { + running: 4, + restarting: 3, + starting: 3, + stopped: 2, + exited: 2, + created: 2, + installed: 1, +} + +function priority(status?: string): number { + return STATUS_PRIORITY[(status ?? '').toLowerCase()] ?? 0 +} + +function isMaestro(name?: string): boolean { + return (name ?? '').toUpperCase().includes('MAESTRO') +} + +function toService(c: Container): Service { + return { + id: c.id || c.name, + name: c.name, + status: c.status, + origin: 'local', + title: c.service_name || c.name, + image: c.image, + version: c.version || versionFromImage(c.image), + hasUpdate: false, + } +} + +function findMatch( + map: Map, + name: string, + registryImage: string +): Service | undefined { + const byName = map.get(name) + if (byName) return byName + + const base = baseImageOf(registryImage) + if (!base) return undefined + + for (const item of map.values()) { + if (baseImageOf(item.image) === base) return item + } + return undefined +} + +/** + * Merge live containers with the remote catalog into a single list of + * services, one entry per name. Local state wins for status; remote metadata + * (title, description, source, latest version) is layered on top. + */ +export function mergeServices(containers: Container[], registry: RegistryItem[]): Service[] { + const map = new Map() + + for (const c of containers) { + if (isMaestro(c.name)) continue + const key = c.name.toLowerCase() + const next = toService(c) + const existing = map.get(key) + if (!existing || priority(next.status) >= priority(existing.status)) { + map.set(key, next) + } + } + + for (const r of registry) { + if (isMaestro(r.name)) continue + const key = r.name.toLowerCase() + const existing = findMatch(map, key, r.image) + + if (existing) { + existing.origin = 'both' + existing.registryImage = r.image + existing.registryUrl = r.url || r.source + existing.source = r.source || existing.source + existing.title = r.title || existing.title + existing.vendor = r.vendor || existing.vendor + existing.author = r.author || existing.author + existing.description = r.description || existing.description + existing.latestVersion = r.version + + const current = + normalizeVersion(existing.version) ?? normalizeVersion(versionFromImage(existing.image)) + if (current && compareVersions(r.version, current) > 0) { + existing.hasUpdate = true + } + } else { + map.set(key, { + id: `registry-${r.name}`, + name: r.name, + status: 'uninstalled', + origin: 'remote', + title: r.title || r.name, + image: r.image, + registryImage: r.image, + source: r.source, + registryUrl: r.url, + description: r.description, + vendor: r.vendor, + author: r.author, + latestVersion: r.version, + hasUpdate: false, + }) + } + } + + return Array.from(map.values()) +} diff --git a/src/frontend/src/lib/domain/version.ts b/src/frontend/src/lib/domain/version.ts new file mode 100644 index 0000000..0559746 --- /dev/null +++ b/src/frontend/src/lib/domain/version.ts @@ -0,0 +1,55 @@ +/** + * Version and image-reference helpers. + * + * Registry versions are stored in full X.Y.Z form by the backend; installed + * versions may come through as partial tags ("v3", "3.1", "4.0.0-hotfix"). + * Everything here normalizes to a comparable X.Y.Z[-pre] string. + */ + +/** Base image reference without registry host, tag, or digest. */ +export function baseImageOf(imageRef?: string): string { + if (!imageRef) return '' + return imageRef.split('@')[0].split(':')[0].toLowerCase() +} + +/** The tag of an image reference, "" when it is `latest` or absent. */ +export function versionFromImage(imageRef?: string): string { + if (!imageRef) return '' + const tag = imageRef.split('@')[0].split(':').pop() ?? '' + return tag === 'latest' ? '' : tag +} + +/** Normalize "v3", "3.1", "4.0.0-hotfix" → "3.0.0", "3.1.0", "4.0.0-hotfix". */ +export function normalizeVersion(value?: string): string | null { + if (!value) return null + const s = value.trim().replace(/^v/i, '') + if (!/^\d+/.test(s)) return null + + const match = /^(\d+(?:\.\d+){0,2})(?:[-+].*)?$/.exec(s) + if (!match) return null + + const parts = match[1].split('.').map(Number) + while (parts.length < 3) parts.push(0) + return `${parts[0]}.${parts[1]}.${parts[2]}` +} + +/** + * Compare two version strings. Returns a negative number when `a < b`. + * Pre-release versions sort below their release (4.0.0 > 4.0.0-beta). + */ +export function compareVersions(a: string, b: string): number { + const na = normalizeVersion(a) ?? a + const nb = normalizeVersion(b) ?? b + + const coreA = na.replace(/[-+].*$/, '').split('.').map(Number) + const coreB = nb.replace(/[-+].*$/, '').split('.').map(Number) + + for (let i = 0; i < 3; i++) { + const diff = (coreA[i] ?? 0) - (coreB[i] ?? 0) + if (diff !== 0) return diff + } + + const preA = na.includes('-') ? 1 : 0 + const preB = nb.includes('-') ? 1 : 0 + return preA - preB +} diff --git a/src/frontend/src/lib/hooks/useAsyncAction.ts b/src/frontend/src/lib/hooks/useAsyncAction.ts new file mode 100644 index 0000000..6202107 --- /dev/null +++ b/src/frontend/src/lib/hooks/useAsyncAction.ts @@ -0,0 +1,26 @@ +import { useState } from 'preact/hooks' + +interface AsyncAction { + pending: boolean + run: () => Promise +} + +/** + * Guards an async operation against double submission and exposes a pending + * flag for busy states. Errors are the caller's to surface (toast/banner). + */ +export function useAsyncAction(action: () => Promise): AsyncAction { + const [pending, setPending] = useState(false) + + const run = async () => { + if (pending) return + setPending(true) + try { + await action() + } finally { + setPending(false) + } + } + + return { pending, run } +} diff --git a/src/frontend/src/lib/hooks/useContainerStore.ts b/src/frontend/src/lib/hooks/useContainerStore.ts new file mode 100644 index 0000000..d5689e0 --- /dev/null +++ b/src/frontend/src/lib/hooks/useContainerStore.ts @@ -0,0 +1,19 @@ +import { useEffect, useState } from 'preact/hooks' +import { store, type ContainerStoreState } from '../ws/registry' +import { connectWebSocket } from '../ws/client' + +/** + * Subscribes the component to the live container store and owns the single + * WebSocket connection for the app. + */ +export function useContainerStore(): ContainerStoreState { + const [state, setState] = useState(() => store.getState()) + + useEffect(() => { + connectWebSocket() + const unsubscribe = store.subscribe(() => setState(store.getState())) + return unsubscribe + }, []) + + return state +} diff --git a/src/frontend/src/lib/hooks/useMasterRegistry.ts b/src/frontend/src/lib/hooks/useMasterRegistry.ts new file mode 100644 index 0000000..e47e45d --- /dev/null +++ b/src/frontend/src/lib/hooks/useMasterRegistry.ts @@ -0,0 +1,28 @@ +import { useCallback, useEffect, useState } from 'preact/hooks' +import { fetchMasterRegistry } from '../api/registry' +import type { RegistryItem } from '../types' + +export type RegistryLoadState = + | { kind: 'loading' } + | { kind: 'ok'; items: RegistryItem[] } + | { kind: 'error'; message: string } + +export function useMasterRegistry() { + const [state, setState] = useState({ kind: 'loading' }) + + const load = useCallback(async () => { + setState({ kind: 'loading' }) + try { + const items = await fetchMasterRegistry() + setState({ kind: 'ok', items }) + } catch { + setState({ kind: 'error', message: 'The dcntrlized registry is unreachable.' }) + } + }, []) + + useEffect(() => { + void load() + }, [load]) + + return { state, retry: load } +} diff --git a/src/frontend/src/lib/types.ts b/src/frontend/src/lib/types.ts index f3f1a82..2ee9ac1 100644 --- a/src/frontend/src/lib/types.ts +++ b/src/frontend/src/lib/types.ts @@ -1,75 +1,70 @@ +export type ContainerOrigin = 'local' | 'remote' | 'both' + export interface PortMapping { - host: number; - guest: number; + host: number + guest: number } export interface NetworkConfig { - name: string; - mode: "bridge" | "host" | "none" | "overlay" | "ipvlan" | "macvlan" | string; - ports: PortMapping[]; + name: string + mode: string + ports: PortMapping[] } -export interface RegistryItem { - name: string - version: string - image?: string - source?: string - url?: string - title?: string - vendor?: string - author?: string - description?: string - created_at?: string - - // Runtime properties - id?: string; - status?: string; - origin?: ContainerOrigin; - remoteSource?: string; - latestVersion?: string; - hasUpdate?: boolean; -} - -export const STATUS_PRIORITY: Record = { - running: 3, - restarting: 2, - starting: 2, - stopped: 1, - exited: 1, - created: 1, - installed: 1, -} - -// --- SINGLE UNIFIED CONTAINER TYPE --- - -export type ContainerOrigin = 'local' | 'remote' | 'both'; - +/** A live container as broadcast over the WebSocket. */ export interface Container { - id: string; - name: string; - service_name?: string; - // Status can be from Docker ("running", "stopped") or our UI ("uninstalled") - status: string; + id: string + name: string + service_name?: string + docker_name?: string + image?: string + version?: string + status: string + network?: NetworkConfig +} - // Origin tracking (populated by our App logic during merge) - origin?: ContainerOrigin; +/** A remote catalog entry, as served by GET /api/registry. */ +export interface RegistryItem { + name: string + version: string + image: string + digest?: string + title?: string + vendor?: string + author?: string + description?: string + source?: string + revision?: string + licenses?: string + url?: string + documentation?: string + created_at?: string +} - // Fields populated by local Docker Daemon - docker_name?: string; - image?: string; - network?: { - name: string; - mode: "bridge" | "host" | "none" | "overlay" | "ipvlan" | "macvlan" | string; - ports: Array<{ host: number, guest: number }>; - }; +/** Merged view model: one card per service, local state and remote catalog fused. */ +export interface Service { + id: string + name: string + status: string + origin: ContainerOrigin + title?: string + vendor?: string + author?: string + description?: string + image?: string + /** The image reference published in the registry (for install/update). */ + registryImage?: string + source?: string + registryUrl?: string + version?: string + latestVersion?: string + hasUpdate: boolean +} - // Fields populated by remote Git Registry - source?: string; - url?: string; - version?: string; +export type ServiceFilter = 'all' | 'running' | 'updates' | 'available' - // Optional properties for update state - remoteSource?: string; - latestVersion?: string; - hasUpdate?: boolean; -} \ No newline at end of file +export interface FilterOption { + id: ServiceFilter + label: string + hint: string +} diff --git a/src/frontend/src/lib/utils.ts b/src/frontend/src/lib/utils.ts new file mode 100644 index 0000000..fed2fe9 --- /dev/null +++ b/src/frontend/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from 'clsx' +import { twMerge } from 'tailwind-merge' + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/src/frontend/src/lib/ws/client.ts b/src/frontend/src/lib/ws/client.ts index 356b079..757a079 100644 --- a/src/frontend/src/lib/ws/client.ts +++ b/src/frontend/src/lib/ws/client.ts @@ -1,48 +1,50 @@ -import { registry } from "./registry"; -import { type Container } from "../types"; +import { store } from './registry' +import type { Container } from '../types' -const HOST = "127.0.0.1"; -const PORT = "8080"; -const PATH = "api/ws"; -const URL = `ws://${HOST}:${PORT}/${PATH}`; +const URL = 'ws://127.0.0.1:8080/api/ws' +const RECONNECT_DELAY = 3000 -let socket: WebSocket | null = null; +let socket: WebSocket | null = null +let reconnectTimer: number | undefined export function connectWebSocket(): void { - if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) { - return; + if ( + socket && + (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) + ) { + return + } + + const ws = new WebSocket(URL) + socket = ws + store.setStatus('connecting') + + ws.onopen = () => store.setStatus('open') + + ws.onmessage = (event) => { + try { + const payload = JSON.parse(event.data as string) as Container[] + if (Array.isArray(payload)) store.setContainers(payload) + } catch { + console.error('[ws] Failed to parse message payload') } + } - socket = new WebSocket(URL); - registry.setStatus("connecting"); + ws.onclose = () => { + store.setStatus('closed') + scheduleReconnect() + } - socket.onopen = () => { - console.log("[WS] Connected"); - registry.setStatus("open"); - }; + ws.onerror = () => { + ws.close() + } +} - socket.onmessage = (event: MessageEvent) => { - try { - // Parses into the unified type (missing fields will safely be undefined) - const containers: Array = JSON.parse(event.data) - registry.setContainers(containers); - } catch (err) { - console.error("[WS] Failed to parse message payload:", err); - } - }; - - socket.onclose = () => { - console.warn("[WS] Closed"); - registry.setStatus("closed"); - setTimeout(connectWebSocket, 3000); - }; - - socket.onerror = (err) => { - console.error("[WS] Error", err); - socket?.close(); - }; +function scheduleReconnect(): void { + window.clearTimeout(reconnectTimer) + reconnectTimer = window.setTimeout(connectWebSocket, RECONNECT_DELAY) } export function isConnected(): boolean { - return socket !== null && socket.readyState === WebSocket.OPEN; -} \ No newline at end of file + return socket !== null && socket.readyState === WebSocket.OPEN +} diff --git a/src/frontend/src/lib/ws/registry.ts b/src/frontend/src/lib/ws/registry.ts index 4da74a2..c67bbcc 100644 --- a/src/frontend/src/lib/ws/registry.ts +++ b/src/frontend/src/lib/ws/registry.ts @@ -1,51 +1,41 @@ -import { type Container } from "../types"; +import type { Container } from '../types' -export type ConnectionStatus = "connecting" | "open" | "closing" | "closed"; +export type ConnectionStatus = 'connecting' | 'open' | 'closing' | 'closed' -type Listener = () => void; - -class ContainerRegistry { - private containers: Container[] = []; - private status: ConnectionStatus = "closed"; - private listeners: Set = new Set(); - - private state = { - containers: this.containers, - status: this.status, - }; - - public getState = () => { - return this.state; - }; - - public setContainers(nextContainers: Container[]) { - this.containers = nextContainers; - this.updateSnapshot(); - } - - public setStatus(nextStatus: ConnectionStatus) { - this.status = nextStatus; - this.updateSnapshot(); - } - - private updateSnapshot() { - this.state = { - containers: this.containers, - status: this.status, - }; - this.notify(); - } - - public subscribe = (listener: Listener): () => void => { - this.listeners.add(listener); - return () => this.listeners.delete(listener); - }; - - private notify() { - for (const listener of this.listeners) { - listener(); - } - } +export interface ContainerStoreState { + containers: Container[] + status: ConnectionStatus } -export const registry = new ContainerRegistry(); \ No newline at end of file +type Listener = () => void + +class ContainerStore { + private containers: Container[] = [] + private status: ConnectionStatus = 'closed' + private listeners = new Set() + + getState(): ContainerStoreState { + return { containers: this.containers, status: this.status } + } + + setContainers(next: Container[]): void { + this.containers = next + this.notify() + } + + setStatus(next: ConnectionStatus): void { + this.status = next + this.notify() + } + + subscribe = (listener: Listener): (() => void) => { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + private notify(): void { + for (const listener of this.listeners) listener() + } +} + +export const store = new ContainerStore()