Files
Containr/app/frontend/src/lib/use-build-updates.ts
T
Tomas DvorakandDevin 9f3b196032 Overhaul: real metrics, Git integration UI, open-source hygiene, root cleanup
Backend:
- GET /services/:id/metrics with real Docker container stats
- DELETE /git/providers/:id and GET repo branches endpoint

Frontend:
- Service detail shows real telemetry or honest empty states
- Live status distinguishes idle/polling/reconnecting
- Git providers section in Settings; repo+branch pickers in Add Service
- Real logo, compact Projects header, canvas fitView fix
- index.css deduplicated (1725 -> 1384 lines)
- Fixed smoke tests, lint errors, ToastProvider/context unification

Repo hygiene:
- Added LICENSE (MIT), CONTRIBUTING.md, SECURITY.md; rewrote README
- Untracked .env.prod and data/letsencrypt secrets; fixed .gitignore
- Removed legacy root src/internal/cmd and stale configs/docs/artifacts
- Moved guides and design references under docs/

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-15 18:59:40 +02:00

137 lines
3.3 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'react';
import { getApiBaseUrl } from '@/lib/api-client';
type BuildUpdatePayload = {
channel: string;
data: unknown;
};
type BuildUpdateMessage = {
type?: string;
channel?: string;
data?: unknown;
};
function toWebSocketUrl(apiBase: string): string {
const wsBase = apiBase.startsWith('https://') ? apiBase.replace('https://', 'wss://') : apiBase.replace('http://', 'ws://');
return `${wsBase}/ws`;
}
export type LiveSyncStatus = 'idle' | 'offline' | 'live';
export function useBuildUpdates(
buildIds: string[],
onBuildUpdate: (payload: BuildUpdatePayload) => void,
): LiveSyncStatus {
const [connected, setConnected] = useState(false);
const callbackRef = useRef(onBuildUpdate);
useEffect(() => {
callbackRef.current = onBuildUpdate;
}, [onBuildUpdate]);
const normalizedBuildIds = useMemo(
() => Array.from(new Set(buildIds.filter(Boolean))).sort(),
[buildIds],
);
const buildIdsKey = normalizedBuildIds.join('|');
const subscriptionIds = useMemo(
() => (buildIdsKey ? buildIdsKey.split('|') : []),
[buildIdsKey],
);
useEffect(() => {
if (subscriptionIds.length === 0) {
return;
}
const wsUrl = toWebSocketUrl(getApiBaseUrl());
let socket: WebSocket | null = null;
let reconnectTimer: number | null = null;
let closedByEffect = false;
const subscribeAll = () => {
if (!socket || socket.readyState !== WebSocket.OPEN) {
return;
}
for (const buildId of subscriptionIds) {
socket.send(
JSON.stringify({
action: 'subscribe',
channel: `build:${buildId}`,
}),
);
}
};
const connect = () => {
socket = new WebSocket(wsUrl);
socket.onopen = () => {
setConnected(true);
subscribeAll();
};
socket.onmessage = (event) => {
const lines = String(event.data ?? '')
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
for (const line of lines) {
try {
const parsed = JSON.parse(line) as BuildUpdateMessage;
if (parsed.type !== 'build_update' || !parsed.channel) {
continue;
}
callbackRef.current({
channel: parsed.channel,
data: parsed.data,
});
} catch {
// Ignore malformed messages.
}
}
};
socket.onclose = () => {
setConnected(false);
if (closedByEffect) {
return;
}
reconnectTimer = window.setTimeout(connect, 2000);
};
socket.onerror = () => {
setConnected(false);
};
};
connect();
return () => {
closedByEffect = true;
setConnected(false);
if (reconnectTimer !== null) {
window.clearTimeout(reconnectTimer);
}
if (socket && socket.readyState === WebSocket.OPEN) {
for (const buildId of subscriptionIds) {
socket.send(
JSON.stringify({
action: 'unsubscribe',
channel: `build:${buildId}`,
}),
);
}
}
socket?.close();
};
}, [buildIdsKey, subscriptionIds]);
if (subscriptionIds.length === 0) {
return 'idle';
}
return connected ? 'live' : 'offline';
}