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 }
}
+58 -63
View File
@@ -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<string, number> = {
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;
export interface FilterOption {
id: ServiceFilter
label: string
hint: string
}
+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))
}
+38 -36
View File
@@ -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<Container> = 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;
return socket !== null && socket.readyState === WebSocket.OPEN
}
+37 -47
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;
class ContainerRegistry {
private containers: Container[] = [];
private status: ConnectionStatus = "closed";
private listeners: Set<Listener> = 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();
type Listener = () => void
class ContainerStore {
private containers: Container[] = []
private status: ConnectionStatus = 'closed'
private listeners = new Set<Listener>()
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()