mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-24 10:26:42 +00:00
feat(v5): show server stale status and coold logs
This commit is contained in:
@@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
Reference in New Issue
Block a user