feat(v5): show server stale status and coold logs

This commit is contained in:
Andras Bacsai
2026-06-22 11:14:13 +02:00
parent 0914052b56
commit 75cbcad240
9 changed files with 416 additions and 45 deletions
+134
View File
@@ -87,6 +87,10 @@ type DeleteServerResponse = {
cluster: V5Cluster;
};
type CooldLogsResponse = {
output: string;
fetchedAt: string;
};
type BootstrapServerResponse = {
cluster?: V5Cluster;
@@ -103,6 +107,30 @@ type V5ClusterUpdatedEvent = {
cluster: V5Cluster | null;
};
function statusLabel(status: string): string {
return status
.split(/[-_\s]+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
function statusBadgeClass(status: string): string {
if (status === 'installed' || status === 'running') {
return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400';
}
if (['queued', 'starting', 'bootstrapping'].includes(status)) {
return 'border-warning/30 bg-warning/10 text-warning';
}
if (['unreachable', 'failed', 'error'].includes(status)) {
return 'border-destructive/30 bg-destructive/10 text-destructive';
}
return 'border-border bg-muted/40 text-muted-foreground';
}
type EchoChannel = {
listen: (event: string, callback: (payload: unknown) => void) => EchoChannel;
subscribed?: (callback: () => void) => EchoChannel;
@@ -226,6 +254,12 @@ export default function Clusters({
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [isAddServerDialogOpen, setIsAddServerDialogOpen] = useState(false);
const [isEditServerDialogOpen, setIsEditServerDialogOpen] = useState(false);
const [isCooldLogsDialogOpen, setIsCooldLogsDialogOpen] = useState(false);
const [cooldLogsServer, setCooldLogsServer] = useState<V5Server | null>(null);
const [cooldLogsOutput, setCooldLogsOutput] = useState('');
const [cooldLogsFetchedAt, setCooldLogsFetchedAt] = useState<string | null>(null);
const [cooldLogsError, setCooldLogsError] = useState<string | null>(null);
const [isLoadingCooldLogs, setIsLoadingCooldLogs] = useState(false);
const [showAdvancedConfiguration, setShowAdvancedConfiguration] = useState(false);
const [showAdvancedServerConfiguration, setShowAdvancedServerConfiguration] = useState(false);
@@ -588,6 +622,41 @@ export default function Clusters({
bootstrappingServers.finish(server.id);
}
async function loadCooldLogs(server: V5Server): Promise<void> {
if (!selectedCluster) {
return;
}
setCooldLogsServer(server);
setIsCooldLogsDialogOpen(true);
setIsLoadingCooldLogs(true);
setCooldLogsError(null);
setCooldLogsOutput('');
setCooldLogsFetchedAt(null);
const response = await fetch(`/v5/clusters/${selectedCluster.id}/servers/${server.id}/coold-logs?tail=200`, {
method: 'GET',
credentials: 'same-origin',
headers: {
Accept: 'application/json',
},
});
const payload = (await response.json().catch(() => null)) as CooldLogsResponse & { message?: string } | null;
if (!response.ok) {
setCooldLogsError(payload?.message ?? 'Unable to load coold logs.');
setIsLoadingCooldLogs(false);
return;
}
setCooldLogsOutput(payload?.output ?? '');
setCooldLogsFetchedAt(payload?.fetchedAt ?? null);
setIsLoadingCooldLogs(false);
}
function openDeleteClusterDialog(): void {
if (!selectedCluster || selectedCluster.serversCount !== 0) {
setDeleteClusterError('Only empty clusters can be deleted.');
@@ -775,8 +844,17 @@ export default function Clusters({
<div className="min-w-0 flex-1">
<h4 className="break-words text-sm font-semibold text-foreground">{server.name}</h4>
<p className="mt-1 break-all text-xs text-muted-foreground">{server.host}</p>
{server.status === 'unreachable' && server.lastStatusOutput ? (
<p className="mt-2 break-words text-xs text-destructive">{server.lastStatusOutput}</p>
) : null}
</div>
<div className="flex shrink-0 items-center justify-end gap-2 sm:flex-wrap">
<span
className={`inline-flex h-7 items-center rounded-md border px-2 text-xs font-medium ${statusBadgeClass(server.status)}`}
title={server.lastStatusOutput ?? undefined}
>
{statusLabel(server.status)}
</span>
{!isServerInitialized ? (
<div role="group" aria-label="Server initialization" className="inline-flex">
<span className="inline-flex h-7 items-center rounded-l-md border border-r-0 border-destructive/30 bg-destructive/10 px-2 text-xs font-medium text-destructive">
@@ -836,6 +914,9 @@ export default function Clusters({
: 'Show install logs'}
</DropdownMenuItem>
) : null}
<DropdownMenuItem onClick={() => void loadCooldLogs(server)}>
Coold logs
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openEditServerDialog(server)}>
Edit server
</DropdownMenuItem>
@@ -1756,6 +1837,59 @@ export default function Clusters({
</DialogContent>
</Dialog>
<Dialog
open={isCooldLogsDialogOpen}
onOpenChange={(open) => {
setIsCooldLogsDialogOpen(open);
if (!open) {
setCooldLogsServer(null);
setCooldLogsOutput('');
setCooldLogsFetchedAt(null);
setCooldLogsError(null);
}
}}
>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>coold logs</DialogTitle>
<DialogDescription>
Latest journalctl entries for {cooldLogsServer?.name ?? 'this server'}.
</DialogDescription>
</DialogHeader>
<div className="mt-5 flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">
{cooldLogsFetchedAt ? `Fetched ${formatDate(cooldLogsFetchedAt)}` : 'Last 200 lines'}
</p>
{cooldLogsServer ? (
<Button
type="button"
variant="outline"
size="sm"
disabled={isLoadingCooldLogs}
onClick={() => void loadCooldLogs(cooldLogsServer)}
>
{isLoadingCooldLogs ? 'Loading...' : 'Refresh'}
</Button>
) : null}
</div>
{cooldLogsError ? (
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
{cooldLogsError}
</div>
) : null}
<pre className="max-h-[32rem] overflow-auto rounded-lg border border-border bg-black p-4 font-mono text-xs leading-relaxed text-white">
{isLoadingCooldLogs ? 'Loading coold logs...' : cooldLogsOutput || 'No coold logs returned.'}
</pre>
</div>
</DialogContent>
</Dialog>
<Dialog
open={isEditServerDialogOpen}
onOpenChange={(open) => {
+50 -14
View File
@@ -108,6 +108,22 @@ const CANVAS_ZOOM_STEP = 0.1;
const PINCH_CANVAS_ZOOM_STEP = 0.03;
const DEFAULT_NGINX_IMAGE = 'docker.io/library/nginx:alpine';
function statusBadgeClass(status: string): string | false {
if (status === 'running') {
return 'bg-emerald-500/15 text-emerald-400';
}
if (status === 'creating') {
return 'bg-warning/15 text-warning';
}
if (['failed', 'exited', 'unreachable'].includes(status)) {
return 'bg-destructive/15 text-destructive';
}
return false;
}
async function persistApplicationPosition(application: V5Application): Promise<void> {
await fetch(`/v5/applications/${application.id}/position`, {
method: 'PATCH',
@@ -174,8 +190,9 @@ export default function Dashboard({
const statusCounts = useMemo(
() => ({
running: applications.filter((application) => application.status === 'running').length,
failed: applications.filter((application) => application.status === 'failed').length,
running: applications.filter((application) => application.effectiveStatus === 'running').length,
failed: applications.filter((application) => application.effectiveStatus === 'failed').length,
unreachable: applications.filter((application) => application.effectiveStatus === 'unreachable').length,
}),
[applications],
);
@@ -1312,6 +1329,12 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection
<span className="text-destructive">{statusCounts.failed} failed</span>
</>
)}
{statusCounts.unreachable > 0 && (
<>
<span>•</span>
<span className="text-destructive">{statusCounts.unreachable} unreachable</span>
</>
)}
</div>
</div>
@@ -1364,11 +1387,9 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection
<span
className={cn(
'shrink-0 rounded-full px-2 py-1 text-[0.625rem] font-semibold uppercase tracking-wide',
ingress.status === 'running' && 'bg-emerald-500/15 text-emerald-400',
ingress.status === 'creating' && 'bg-warning/15 text-warning',
ingress.status === 'failed' && 'bg-destructive/15 text-destructive',
ingress.status === 'exited' && 'bg-destructive/15 text-destructive',
statusBadgeClass(ingress.status),
)}
title={ingress.statusMessage ?? undefined}
>
{ingress.status}
</span>
@@ -1645,13 +1666,11 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection
<span
className={cn(
'rounded-full px-2 py-1 text-[0.625rem] font-semibold uppercase tracking-wide',
application.status === 'running' && 'bg-emerald-500/15 text-emerald-400',
application.status === 'creating' && 'bg-warning/15 text-warning',
application.status === 'failed' && 'bg-destructive/15 text-destructive',
application.status === 'exited' && 'bg-destructive/15 text-destructive',
statusBadgeClass(application.effectiveStatus),
)}
title={application.effectiveStatusMessage ?? undefined}
>
{application.status}
{application.effectiveStatus}
</span>
<button
type="button"
@@ -1680,6 +1699,9 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection
<dt className="shrink-0 text-muted-foreground">Server</dt>
<dd className="truncate text-right font-medium text-foreground">
{application.serverName ?? 'Unknown'}
{!application.isServerReachable && (
<span className="ml-2 text-destructive">(unreachable)</span>
)}
</dd>
</div>
<div className="grid grid-cols-[auto_minmax(0,1fr)] gap-3">
@@ -1743,9 +1765,16 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection
<Field>
<FieldLabel>Status</FieldLabel>
<Input value={selectedInspectorApplication.status} readOnly />
<Input value={selectedInspectorApplication.effectiveStatus} readOnly />
</Field>
{selectedInspectorApplication.effectiveStatus !== selectedInspectorApplication.status && (
<Field>
<FieldLabel>Last known container status</FieldLabel>
<Input value={selectedInspectorApplication.status} readOnly />
</Field>
)}
<Field>
<FieldLabel>Image</FieldLabel>
<Input value={selectedInspectorApplication.image} readOnly />
@@ -1753,7 +1782,14 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection
<Field>
<FieldLabel>Server</FieldLabel>
<Input value={selectedInspectorApplication.serverName ?? 'Unknown'} readOnly />
<Input
value={
selectedInspectorApplication.isServerReachable
? (selectedInspectorApplication.serverName ?? 'Unknown')
: `${selectedInspectorApplication.serverName ?? 'Unknown'} (unreachable)`
}
readOnly
/>
</Field>
<Field>
@@ -1770,7 +1806,7 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection
<Field>
<FieldLabel>Status message</FieldLabel>
<Textarea
value={selectedInspectorApplication.statusMessage ?? 'No status message yet.'}
value={selectedInspectorApplication.effectiveStatusMessage ?? 'No status message yet.'}
readOnly
className="min-h-20"
/>
+8
View File
@@ -30,6 +30,8 @@ export type V5Server = {
lastBootstrapStatus: string | null;
lastBootstrapOutput: string | null;
lastBootstrapRanAt: string | null;
lastStatusOutput: string | null;
lastStatusCheckedAt: string | null;
};
export type V5Cluster = {
@@ -90,8 +92,13 @@ export type V5Application = {
containerName: string;
status: 'creating' | 'running' | 'failed' | string;
statusMessage: string | null;
effectiveStatus: 'creating' | 'running' | 'failed' | 'unreachable' | string;
effectiveStatusMessage: string | null;
runtimeContainerId: string | null;
serverName: string | null;
serverStatus: string | null;
serverStatusMessage: string | null;
isServerReachable: boolean;
serverIngressEnabled: boolean;
meshNamespace: string;
ingressEnabled: boolean;
@@ -107,6 +114,7 @@ export type V5CaddyIngress = {
name: string;
host: string;
status: string;
statusMessage: string | null;
canvasX: number;
canvasY: number;
};