feat(frontend): extract console data layer into api/domain/hooks modules

This commit is contained in:
2026-08-03 14:22:45 +01:00
parent 836153ccb5
commit 0a1c219941
13 changed files with 499 additions and 148 deletions
+39
View File
@@ -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<T>(res: Response): Promise<T> {
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<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, init)
if (!res.ok) {
throw new ApiError(`Request failed (${res.status})`, res.status)
}
return readJson<T>(res)
}
export function get<T>(path: string): Promise<T> {
return request<T>(path)
}
export function post<T = void>(path: string, body?: unknown): Promise<T> {
return request<T>(path, {
method: 'POST',
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
})
}
+33
View File
@@ -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<void> {
return post<void>('/api/containers/install', registryPayload(service))
}
export function uninstallContainer(service: Service): Promise<void> {
return post<void>('/api/containers/uninstall', registryPayload(service))
}
export function startContainer(service: Service): Promise<void> {
return post<void>('/api/containers/start', registryPayload(service))
}
export function stopContainer(service: Service): Promise<void> {
return post<void>('/api/containers/stop', registryPayload(service))
}
+6
View File
@@ -0,0 +1,6 @@
import { get } from './client'
import type { RegistryItem } from '../types'
export function fetchMasterRegistry(): Promise<RegistryItem[]> {
return get<RegistryItem[]>('/api/registry')
}
+39
View File
@@ -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 }
}
+113
View File
@@ -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<string, number> = {
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<string, Service>,
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<string, Service>()
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())
}
+55
View File
@@ -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
}
@@ -0,0 +1,26 @@
import { useState } from 'preact/hooks'
interface AsyncAction {
pending: boolean
run: () => Promise<void>
}
/**
* 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<void>): AsyncAction {
const [pending, setPending] = useState(false)
const run = async () => {
if (pending) return
setPending(true)
try {
await action()
} finally {
setPending(false)
}
}
return { pending, run }
}
@@ -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<ContainerStoreState>(() => store.getState())
useEffect(() => {
connectWebSocket()
const unsubscribe = store.subscribe(() => setState(store.getState()))
return unsubscribe
}, [])
return state
}
@@ -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<RegistryLoadState>({ 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 }
}
+50 -55
View File
@@ -1,75 +1,70 @@
export type ContainerOrigin = 'local' | 'remote' | 'both'
export interface PortMapping { export interface PortMapping {
host: number; host: number
guest: number; guest: number
} }
export interface NetworkConfig { export interface NetworkConfig {
name: string; name: string
mode: "bridge" | "host" | "none" | "overlay" | "ipvlan" | "macvlan" | string; mode: string
ports: PortMapping[]; ports: PortMapping[]
} }
/** A live container as broadcast over the WebSocket. */
export interface Container {
id: string
name: string
service_name?: string
docker_name?: string
image?: string
version?: string
status: string
network?: NetworkConfig
}
/** A remote catalog entry, as served by GET /api/registry. */
export interface RegistryItem { export interface RegistryItem {
name: string name: string
version: string version: string
image?: string image: string
source?: string digest?: string
url?: string
title?: string title?: string
vendor?: string vendor?: string
author?: string author?: string
description?: string description?: string
source?: string
revision?: string
licenses?: string
url?: string
documentation?: string
created_at?: string created_at?: string
// Runtime properties
id?: string;
status?: string;
origin?: ContainerOrigin;
remoteSource?: string;
latestVersion?: string;
hasUpdate?: boolean;
} }
export const STATUS_PRIORITY: Record<string, number> = { /** Merged view model: one card per service, local state and remote catalog fused. */
running: 3, export interface Service {
restarting: 2, id: string
starting: 2, name: string
stopped: 1, status: string
exited: 1, origin: ContainerOrigin
created: 1, title?: string
installed: 1, 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
} }
// --- SINGLE UNIFIED CONTAINER TYPE --- export type ServiceFilter = 'all' | 'running' | 'updates' | 'available'
export type ContainerOrigin = 'local' | 'remote' | 'both'; export interface FilterOption {
id: ServiceFilter
export interface Container { label: string
id: string; hint: string
name: string;
service_name?: string;
// Status can be from Docker ("running", "stopped") or our UI ("uninstalled")
status: string;
// Origin tracking (populated by our App logic during merge)
origin?: ContainerOrigin;
// 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 }>;
};
// Fields populated by remote Git Registry
source?: string;
url?: string;
version?: string;
// Optional properties for update state
remoteSource?: string;
latestVersion?: string;
hasUpdate?: boolean;
} }
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+34 -32
View File
@@ -1,48 +1,50 @@
import { registry } from "./registry"; import { store } from './registry'
import { type Container } from "../types"; import type { Container } from '../types'
const HOST = "127.0.0.1"; const URL = 'ws://127.0.0.1:8080/api/ws'
const PORT = "8080"; const RECONNECT_DELAY = 3000
const PATH = "api/ws";
const URL = `ws://${HOST}:${PORT}/${PATH}`;
let socket: WebSocket | null = null; let socket: WebSocket | null = null
let reconnectTimer: number | undefined
export function connectWebSocket(): void { export function connectWebSocket(): void {
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) { if (
return; socket &&
(socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)
) {
return
} }
socket = new WebSocket(URL); const ws = new WebSocket(URL)
registry.setStatus("connecting"); socket = ws
store.setStatus('connecting')
socket.onopen = () => { ws.onopen = () => store.setStatus('open')
console.log("[WS] Connected");
registry.setStatus("open");
};
socket.onmessage = (event: MessageEvent) => { ws.onmessage = (event) => {
try { try {
// Parses into the unified type (missing fields will safely be undefined) const payload = JSON.parse(event.data as string) as Container[]
const containers: Array<Container> = JSON.parse(event.data) if (Array.isArray(payload)) store.setContainers(payload)
registry.setContainers(containers); } catch {
} catch (err) { console.error('[ws] Failed to parse message payload')
console.error("[WS] Failed to parse message payload:", err); }
} }
};
socket.onclose = () => { ws.onclose = () => {
console.warn("[WS] Closed"); store.setStatus('closed')
registry.setStatus("closed"); scheduleReconnect()
setTimeout(connectWebSocket, 3000); }
};
socket.onerror = (err) => { ws.onerror = () => {
console.error("[WS] Error", err); ws.close()
socket?.close(); }
}; }
function scheduleReconnect(): void {
window.clearTimeout(reconnectTimer)
reconnectTimer = window.setTimeout(connectWebSocket, RECONNECT_DELAY)
} }
export function isConnected(): boolean { export function isConnected(): boolean {
return socket !== null && socket.readyState === WebSocket.OPEN; return socket !== null && socket.readyState === WebSocket.OPEN
} }
+26 -36
View File
@@ -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; export interface ContainerStoreState {
containers: Container[]
status: ConnectionStatus
}
class ContainerRegistry { type Listener = () => void
private containers: Container[] = [];
private status: ConnectionStatus = "closed";
private listeners: Set<Listener> = new Set();
private state = { class ContainerStore {
containers: this.containers, private containers: Container[] = []
status: this.status, private status: ConnectionStatus = 'closed'
}; private listeners = new Set<Listener>()
public getState = () => { getState(): ContainerStoreState {
return this.state; return { containers: this.containers, status: this.status }
};
public setContainers(nextContainers: Container[]) {
this.containers = nextContainers;
this.updateSnapshot();
} }
public setStatus(nextStatus: ConnectionStatus) { setContainers(next: Container[]): void {
this.status = nextStatus; this.containers = next
this.updateSnapshot(); this.notify()
} }
private updateSnapshot() { setStatus(next: ConnectionStatus): void {
this.state = { this.status = next
containers: this.containers, this.notify()
status: this.status,
};
this.notify();
} }
public subscribe = (listener: Listener): () => void => { subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener); this.listeners.add(listener)
return () => this.listeners.delete(listener); return () => this.listeners.delete(listener)
};
private notify() {
for (const listener of this.listeners) {
listener();
} }
private notify(): void {
for (const listener of this.listeners) listener()
} }
} }
export const registry = new ContainerRegistry(); export const store = new ContainerStore()