From 75cbcad240844b3703b3fdca205e6542a0c90139 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 22 Jun 2026 11:14:13 +0200 Subject: [PATCH] feat(v5): show server stale status and coold logs --- app/Console/Commands/FluxDev.php | 20 +-- .../Controllers/V5/DashboardController.php | 70 ++++++++- app/Services/Flux/FluxClient.php | 10 ++ resources/js/v5/Pages/Clusters.tsx | 134 ++++++++++++++++ resources/js/v5/Pages/Dashboard.tsx | 64 ++++++-- resources/js/v5/types.ts | 8 + routes/v5.php | 1 + tests/Feature/FluxDevCommandTest.php | 10 +- tests/Feature/V5/DashboardTest.php | 144 +++++++++++++++++- 9 files changed, 416 insertions(+), 45 deletions(-) diff --git a/app/Console/Commands/FluxDev.php b/app/Console/Commands/FluxDev.php index 2451fac77..ff2e12aeb 100644 --- a/app/Console/Commands/FluxDev.php +++ b/app/Console/Commands/FluxDev.php @@ -24,25 +24,7 @@ class FluxDev extends Command private function defaultCapabilities(): array { return [ - 'images.pull', - 'images.list', - 'images.delete', - 'containers.create', - 'containers.start', - 'containers.stop', - 'containers.restart', - 'containers.delete', - 'containers.inspect', - 'containers.list', - 'containers.logs', - 'containers.exec', - 'containers.healthcheck.run', - 'ingress.apply', - 'ingress.stop', - 'firewall.allow', - 'firewall.revoke', - 'firewall.list', - 'firewall.reconcile', + 'host-agent:dev', ]; } diff --git a/app/Http/Controllers/V5/DashboardController.php b/app/Http/Controllers/V5/DashboardController.php index 28de0accf..e1d38e3fa 100644 --- a/app/Http/Controllers/V5/DashboardController.php +++ b/app/Http/Controllers/V5/DashboardController.php @@ -970,6 +970,46 @@ class DashboardController extends Controller ]); } + public function serverCooldLogs(Request $request, V5Cluster $cluster, V5Server $server, FluxClient $fluxClient): JsonResponse + { + $currentTeam = $request->attributes->get('v5.currentTeam'); + + if ( + ! $currentTeam instanceof Team + || $cluster->team_id !== $currentTeam->id + || $server->team_id !== $currentTeam->id + || $server->cluster_id !== $cluster->id + ) { + abort(404); + } + + $validated = $request->validate([ + 'tail' => ['sometimes', 'integer', 'min:1', 'max:1000'], + ]); + + $hostId = $server->wireguard_management_ip ?: $server->node_address; + + if (! is_string($hostId) || $hostId === '') { + return response()->json([ + 'message' => 'This server is missing its Flux host id.', + ], 422); + } + + try { + $output = $fluxClient->cooldLogs($hostId, (int) ($validated['tail'] ?? 200)); + } catch (\Throwable $e) { + return response()->json([ + 'message' => $e->getMessage(), + ], 502); + } + + return response()->json([ + 'output' => $output, + 'source' => 'flux', + 'fetchedAt' => now()->toJSON(), + ]); + } + public function destroyServer(Request $request, V5Cluster $cluster, V5Server $server): \Illuminate\Http\Response|JsonResponse { $currentTeam = $request->attributes->get('v5.currentTeam'); @@ -1313,12 +1353,15 @@ class DashboardController extends Controller */ private function serializeCaddyIngress(V5Server $server, int $index = 0): array { + $isServerReachable = $this->isServerReachable($server); + return [ 'id' => (string) $server->id, 'name' => $server->name, 'host' => $server->host, 'type' => $server->ingressType(), - 'status' => $server->ingressStatus(), + 'status' => $isServerReachable ? $server->ingressStatus() : 'unreachable', + 'statusMessage' => $isServerReachable ? null : $this->serverStatusMessage($server), 'canvasX' => $server->canvas_x ?? -self::CANVAS_CARD_WIDTH - self::CANVAS_CARD_GAP, 'canvasY' => $server->canvas_y ?? $index * (self::CANVAS_CARD_HEIGHT + self::CANVAS_CARD_GAP), ]; @@ -1539,6 +1582,8 @@ class DashboardController extends Controller private function serializeApplication(V5Application $application): array { $application->loadMissing(['server', 'domains']); + $server = $application->server; + $isServerReachable = ! $server instanceof V5Server || $this->isServerReachable($server); return [ 'id' => (string) $application->id, @@ -1547,9 +1592,16 @@ class DashboardController extends Controller 'containerName' => $application->container_name, 'status' => $application->status, 'statusMessage' => $application->status_message, + 'effectiveStatus' => $isServerReachable ? $application->status : 'unreachable', + 'effectiveStatusMessage' => $isServerReachable + ? $application->status_message + : $this->serverStatusMessage($server), 'runtimeContainerId' => $application->runtime_container_id, - 'serverName' => $application->server?->name, - 'serverIngressEnabled' => (bool) $application->server?->isIngress(), + 'serverName' => $server?->name, + 'serverStatus' => $server?->status, + 'serverStatusMessage' => $server instanceof V5Server ? $this->serverStatusMessage($server) : null, + 'isServerReachable' => $isServerReachable, + 'serverIngressEnabled' => (bool) $server?->isIngress(), 'meshNamespace' => $application->mesh_namespace, 'ingressEnabled' => $application->ingress_enabled, 'internalPort' => $application->internal_port, @@ -1560,6 +1612,16 @@ class DashboardController extends Controller ]; } + private function isServerReachable(V5Server $server): bool + { + return $server->status !== 'unreachable'; + } + + private function serverStatusMessage(V5Server $server): ?string + { + return $server->last_status_output ?: null; + } + /** * @return array> */ @@ -1716,6 +1778,8 @@ class DashboardController extends Controller 'lastBootstrapStatus' => $server->last_bootstrap_status, 'lastBootstrapOutput' => $server->last_bootstrap_output, 'lastBootstrapRanAt' => $server->last_bootstrap_ran_at?->toJSON(), + 'lastStatusOutput' => $server->last_status_output, + 'lastStatusCheckedAt' => $server->last_status_checked_at?->toJSON(), ])->all(), ]; } diff --git a/app/Services/Flux/FluxClient.php b/app/Services/Flux/FluxClient.php index bc5453ee9..e2ef80ced 100644 --- a/app/Services/Flux/FluxClient.php +++ b/app/Services/Flux/FluxClient.php @@ -126,6 +126,16 @@ class FluxClient return $this->output($payload, 'Firewall rule removed.'); } + public function cooldLogs(string $hostId, int $tail = 200): string + { + $payload = $this->dispatch($hostId, [ + 'type' => 'coold.logs', + 'tail' => max(1, min($tail, 1000)), + ]); + + return $this->output($payload, 'No coold logs returned.'); + } + /** * @param array $command * @return array diff --git a/resources/js/v5/Pages/Clusters.tsx b/resources/js/v5/Pages/Clusters.tsx index 57150f9ca..68a5af593 100644 --- a/resources/js/v5/Pages/Clusters.tsx +++ b/resources/js/v5/Pages/Clusters.tsx @@ -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(null); + const [cooldLogsOutput, setCooldLogsOutput] = useState(''); + const [cooldLogsFetchedAt, setCooldLogsFetchedAt] = useState(null); + const [cooldLogsError, setCooldLogsError] = useState(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 { + 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({

{server.name}

{server.host}

+ {server.status === 'unreachable' && server.lastStatusOutput ? ( +

{server.lastStatusOutput}

+ ) : null}
+ + {statusLabel(server.status)} + {!isServerInitialized ? (
@@ -836,6 +914,9 @@ export default function Clusters({ : 'Show install logs'} ) : null} + void loadCooldLogs(server)}> + Coold logs + openEditServerDialog(server)}> Edit server @@ -1756,6 +1837,59 @@ export default function Clusters({ + + { + setIsCooldLogsDialogOpen(open); + + if (!open) { + setCooldLogsServer(null); + setCooldLogsOutput(''); + setCooldLogsFetchedAt(null); + setCooldLogsError(null); + } + }} + > + + + coold logs + + Latest journalctl entries for {cooldLogsServer?.name ?? 'this server'}. + + + +
+
+

+ {cooldLogsFetchedAt ? `Fetched ${formatDate(cooldLogsFetchedAt)}` : 'Last 200 lines'} +

+ {cooldLogsServer ? ( + + ) : null} +
+ + {cooldLogsError ? ( +
+ {cooldLogsError} +
+ ) : null} + +
+                                        {isLoadingCooldLogs ? 'Loading coold logs...' : cooldLogsOutput || 'No coold logs returned.'}
+                                    
+
+
+
+ { diff --git a/resources/js/v5/Pages/Dashboard.tsx b/resources/js/v5/Pages/Dashboard.tsx index 1971a51a4..baee9f8b1 100644 --- a/resources/js/v5/Pages/Dashboard.tsx +++ b/resources/js/v5/Pages/Dashboard.tsx @@ -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 { 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 {statusCounts.failed} failed )} + {statusCounts.unreachable > 0 && ( + <> + + {statusCounts.unreachable} unreachable + + )}
@@ -1364,11 +1387,9 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection {ingress.status} @@ -1645,13 +1666,11 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection - {application.status} + {application.effectiveStatus}