mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
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>
137 lines
3.3 KiB
TypeScript
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';
|
|
}
|