mirror of
https://github.com/coollabsio/coolify.git
synced 2026-09-28 02:06:37 -04:00
feat(v5): show server stale status and coold logs
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user