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
+1 -19
View File
@@ -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',
];
}
@@ -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<int, array<string, mixed>>
*/
@@ -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(),
];
}
+10
View File
@@ -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<string, mixed> $command
* @return array<string, mixed>
+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;
};
+1
View File
@@ -24,6 +24,7 @@ Route::middleware('v5.authenticated')->group(function () {
Route::post('/clusters/{cluster}/servers', [DashboardController::class, 'storeServer'])->name('clusters.servers.store');
Route::patch('/clusters/{cluster}/servers/{server}', [DashboardController::class, 'updateServer'])->name('clusters.servers.update');
Route::post('/clusters/{cluster}/servers/{server}/check', [DashboardController::class, 'checkServer'])->name('clusters.servers.check');
Route::get('/clusters/{cluster}/servers/{server}/coold-logs', [DashboardController::class, 'serverCooldLogs'])->name('clusters.servers.coold-logs');
Route::post('/clusters/{cluster}/servers/{server}/bootstrap', [DashboardController::class, 'bootstrapServer'])->name('clusters.servers.bootstrap');
Route::delete('/clusters/{cluster}/servers/{server}', [DashboardController::class, 'destroyServer'])->name('clusters.servers.destroy');
});
+2 -8
View File
@@ -27,7 +27,7 @@ it('mints a host jwt signed by the configured flux private key', function () {
->and($claims->exp)->toBeGreaterThan(time());
});
it('mints a host jwt with primitive capabilities by default', function () {
it('mints a host jwt with the dev capability profile by default', function () {
[$privateKeyPath, $publicKeyPath] = createFluxJwtKeypair();
Config::set('flux.jwt_private_key_path', $privateKeyPath);
@@ -42,13 +42,7 @@ it('mints a host jwt with primitive capabilities by default', function () {
$token = trim(Artisan::output());
$claims = JWT::decode($token, new Key(file_get_contents($publicKeyPath), 'ES256'));
expect($claims->caps)->toContain('containers.list')
->and($claims->caps)->toContain('ingress.apply')
->and($claims->caps)->toContain('firewall.allow')
->and($claims->caps)->toContain('firewall.revoke')
->and($claims->caps)->toContain('firewall.list')
->and($claims->caps)->toContain('firewall.reconcile')
->and($claims->caps)->not->toContain('coold');
expect($claims->caps)->toBe(['host-agent:dev']);
});
it('writes the host jwt to an output path with owner-only permissions', function () {
+143 -1
View File
@@ -64,6 +64,7 @@ it('registers the v5 dashboard route', function () {
->and(Route::has('v5.clusters.servers.store'))->toBeTrue()
->and(Route::has('v5.clusters.servers.update'))->toBeTrue()
->and(Route::has('v5.clusters.servers.check'))->toBeTrue()
->and(Route::has('v5.clusters.servers.coold-logs'))->toBeTrue()
->and(Route::has('v5.clusters.servers.bootstrap'))->toBeTrue()
->and(Route::has('v5.clusters.servers.destroy'))->toBeTrue()
->and(Route::has('v5.applications.nginx'))->toBeTrue()
@@ -804,6 +805,87 @@ it('serves v5 dashboard applications as canvas nodes', function () {
->assertDontSee('other-nginx-test', false);
});
it('marks v5 application status as stale when its server is unreachable', function () {
app()->detectEnvironment(fn () => 'local');
$this->withoutVite();
fakeFluxHealth();
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production');
$server = V5Server::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'edge-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'unreachable',
'last_status_output' => 'coold heartbeat timed out.',
'capabilities' => [],
]);
V5Application::query()->create([
'team_id' => $team->id,
'project_id' => $project->id,
'environment_id' => $environment->id,
'server_id' => $server->id,
'created_by_user_id' => $user->id,
'name' => 'nginx-test',
'image' => 'docker.io/library/nginx:alpine',
'container_name' => 'coolify-v5-nginx-1',
'status' => 'running',
'status_message' => 'Container started.',
]);
$this
->actingAs($user)
->withSession([
'currentTeam' => $team,
'v5.selectedProjectUuid' => $project->uuid,
'v5.selectedEnvironmentUuid' => $environment->uuid,
])
->get('/v5')
->assertSuccessful()
->assertSee('"status":"running"', false)
->assertSee('"effectiveStatus":"unreachable"', false)
->assertSee('"effectiveStatusMessage":"coold heartbeat timed out."', false)
->assertSee('"serverStatus":"unreachable"', false)
->assertSee('"isServerReachable":false', false);
});
it('shows v5 caddy ingress as unreachable when its server is unreachable', function () {
app()->detectEnvironment(fn () => 'local');
$this->withoutVite();
fakeFluxHealth();
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
V5Server::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'edge-ingress-01',
'host' => '203.0.113.20',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'unreachable',
'ingress_status' => 'running',
'last_status_output' => 'coold heartbeat timed out.',
'capabilities' => ['ingress'],
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->get('/v5')
->assertSuccessful()
->assertSee('"caddyIngresses":[', false)
->assertSee('"status":"unreachable"', false)
->assertSee('"statusMessage":"coold heartbeat timed out."', false);
});
it('persists generic v5 resource connections and direction-specific ports', function () {
app()->detectEnvironment(fn () => 'local');
@@ -2078,6 +2160,66 @@ it('broadcasts v5 cluster updates when bootstrap state changes', function () {
expect(Event::dispatched(V5ClusterUpdated::class)->count())->toBeGreaterThanOrEqual(2);
});
it('fetches v5 server coold logs through flux', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'description' => null,
]);
$server = V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'name' => 'prod-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'builder_enabled' => false,
'builder_capacity' => 0,
'wireguard_management_ip' => '100.64.0.10',
'node_address' => '203.0.113.10',
]);
$this->mock(FluxClient::class, function (MockInterface $mock): void {
$mock->shouldReceive('cooldLogs')
->once()
->with('100.64.0.10', 200)
->andReturn('Jun 22 coold[123]: started');
});
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->getJson("/v5/clusters/{$cluster->id}/servers/{$server->id}/coold-logs?tail=200")
->assertSuccessful()
->assertJsonPath('output', 'Jun 22 coold[123]: started')
->assertJsonStructure(['output', 'fetchedAt']);
});
it('renders a coold logs action in the v5 server menu', function () {
$clustersPage = file_get_contents(resource_path('js/v5/Pages/Clusters.tsx'));
expect($clustersPage)
->toContain('Coold logs')
->toContain('/coold-logs?tail=200')
->toContain('Latest journalctl entries');
});
it('renders v5 server status on cluster server cards', function () {
$clustersPage = file_get_contents(resource_path('js/v5/Pages/Clusters.tsx'));
expect($clustersPage)
->toContain('server.status')
->toContain('server.lastStatusOutput')
->toContain('statusLabel(server.status)')
->toContain('statusBadgeClass(server.status)');
});
it('returns fresh v5 cluster bootstrap state for realtime fallback refreshes', function () {
createSharedUserAndTeamTables();
@@ -4583,7 +4725,7 @@ it('defines the v5 cluster management page and create cluster form', function ()
->toContain('privateKeyName: string | null;')
->toContain('lastBootstrappedAt: string | null;')
->toContain('lastBootstrapStatus: string | null;')
->not->toContain('lastStatusOutput: string | null;');
->toContain('lastStatusOutput: string | null;');
});
it('uses the standard button size for the v5 delete cluster action', function () {