Redesign ServiceCard: state-driven controls with visual indicators

- Color-coded left border: green=running, red=stopped, grey=unknown
- Status pill with icon: "Running" (green), "Stopped" (red), etc.
- Stopped cards appear muted (75% opacity), recover on hover
- Running: restart (↻) + stop (⏻) icon buttons, stop needs confirm
- Stopped: prominent green play (▶) button
- Loading state: spinner replaces controls with "Processing..."
- Added coolify.ts lib and config.ts for shared Coolify API access
- No pause support (Coolify API doesn't expose docker pause)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-02-04 00:37:05 +01:00
parent 14fc608754
commit eda6d956b2
4 changed files with 209 additions and 97 deletions

View File

@@ -1,9 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { controlResource, triggerDeploy } from '@/lib/coolify';
const COOLIFY_API = 'http://192.168.1.3:8000/api/v1';
const COOLIFY_TOKEN = process.env.COOLIFY_API_TOKEN || '';
const VALID_ACTIONS = ['start', 'stop', 'restart'] as const;
const VALID_ACTIONS = ['start', 'stop', 'restart', 'deploy'] as const;
const VALID_RESOURCE_TYPES = ['application', 'service', 'database'] as const;
export async function POST(request: NextRequest) {
@@ -15,54 +13,41 @@ export async function POST(request: NextRequest) {
action: string;
};
if (!uuid || !resourceType || !action) {
return NextResponse.json(
{ error: 'Missing uuid, resourceType, or action' },
{ status: 400 }
);
if (!uuid || !action) {
return NextResponse.json({ error: 'Missing uuid or action' }, { status: 400 });
}
if (!VALID_ACTIONS.includes(action as typeof VALID_ACTIONS[number])) {
return NextResponse.json(
{ error: `Invalid action: ${action}` },
{ status: 400 }
);
return NextResponse.json({ error: `Invalid action: ${action}` }, { status: 400 });
}
if (!VALID_RESOURCE_TYPES.includes(resourceType as typeof VALID_RESOURCE_TYPES[number])) {
return NextResponse.json(
{ error: `Invalid resourceType: ${resourceType}` },
{ status: 400 }
);
// Deploy action
if (action === 'deploy') {
const result = await triggerDeploy(uuid);
if (!result.ok) {
return NextResponse.json({ error: result.message }, { status: 500 });
}
return NextResponse.json({ ok: true, action, uuid });
}
// Coolify API: POST /api/v1/{resourceType}s/{uuid}/{action}
const endpoint = `${COOLIFY_API}/${resourceType}s/${uuid}/${action}`;
// Start/stop/restart
if (!resourceType || !VALID_RESOURCE_TYPES.includes(resourceType as typeof VALID_RESOURCE_TYPES[number])) {
return NextResponse.json({ error: `Invalid resourceType: ${resourceType}` }, { status: 400 });
}
const res = await fetch(endpoint, {
method: 'POST',
headers: {
Authorization: `Bearer ${COOLIFY_TOKEN}`,
Accept: 'application/json',
},
signal: AbortSignal.timeout(15000),
});
const result = await controlResource(
uuid,
resourceType as 'application' | 'service' | 'database',
action as 'start' | 'stop' | 'restart'
);
if (!res.ok) {
const text = await res.text();
console.error(`Coolify control error: ${res.status} ${text}`);
return NextResponse.json(
{ error: `Coolify returned ${res.status}` },
{ status: res.status }
);
if (!result.ok) {
return NextResponse.json({ error: `Coolify returned ${result.status}` }, { status: result.status });
}
return NextResponse.json({ ok: true, action, uuid });
} catch (error) {
console.error('Control error:', error);
return NextResponse.json(
{ error: 'Failed to control service' },
{ status: 500 }
);
return NextResponse.json({ error: 'Failed to control service' }, { status: 500 });
}
}

View File

@@ -10,11 +10,18 @@ interface ServiceCardProps {
status: HealthStatus;
}
const statusColors: Record<HealthStatus, string> = {
running: 'bg-emerald-500',
stopped: 'bg-red-500',
unknown: 'bg-slate-400 dark:bg-stone-500',
loading: 'bg-amber-500 animate-pulse',
const borderColors: Record<HealthStatus, string> = {
running: 'border-l-emerald-500',
stopped: 'border-l-red-500',
unknown: 'border-l-slate-400 dark:border-l-stone-600',
loading: 'border-l-amber-500',
};
const statusPillStyles: Record<HealthStatus, string> = {
running: 'bg-emerald-50 dark:bg-emerald-900/20 text-emerald-600 dark:text-emerald-400',
stopped: 'bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400',
unknown: 'bg-slate-100 dark:bg-stone-800 text-slate-500 dark:text-stone-500',
loading: 'bg-amber-50 dark:bg-amber-900/20 text-amber-600 dark:text-amber-400',
};
const statusLabels: Record<HealthStatus, string> = {
@@ -24,6 +31,13 @@ const statusLabels: Record<HealthStatus, string> = {
loading: 'Checking...',
};
const statusIcons: Record<HealthStatus, string> = {
running: 'circle',
stopped: 'power',
unknown: 'circle',
loading: 'loader',
};
function isDiscovered(service: Service): service is DiscoveredService {
return 'source' in service && (service as DiscoveredService).source === 'discovered';
}
@@ -55,6 +69,7 @@ export function ServiceCard({ service, status }: ServiceCardProps) {
const discovered = isDiscovered(service);
const [loading, setLoading] = useState(false);
const [confirmStop, setConfirmStop] = useState(false);
const isStopped = status === 'stopped' || status === 'unknown';
const controlService = useCallback(async (action: 'start' | 'stop' | 'restart') => {
if (!discovered) return;
@@ -70,7 +85,6 @@ export function ServiceCard({ service, status }: ServiceCardProps) {
action,
}),
});
// Coolify takes a moment to process — page will refresh via polling
} catch { /* ignore */ } finally {
setTimeout(() => setLoading(false), 3000);
}
@@ -86,18 +100,14 @@ export function ServiceCard({ service, status }: ServiceCardProps) {
}, [confirmStop, controlService]);
return (
<div className="group relative block p-4 bg-white dark:bg-stone-900 rounded-xl border border-slate-100 dark:border-stone-700/50 shadow-sm hover:border-slate-200 dark:hover:border-stone-600/50 hover:shadow-md transition-all duration-200">
{/* Status indicator */}
<div className={`group relative p-4 bg-white dark:bg-stone-900 rounded-xl border border-slate-100 dark:border-stone-700/50 border-l-[3px] ${borderColors[status]} shadow-sm hover:shadow-md transition-all duration-200 ${isStopped ? 'opacity-75 hover:opacity-100' : ''}`}>
{/* Top row: badges */}
<div className="absolute top-3 right-3 flex items-center gap-1.5">
{resourceBadge && (
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-slate-100 dark:bg-stone-800 text-slate-500 dark:text-stone-400">
{resourceBadge}
</span>
)}
<span
className={`w-2 h-2 rounded-full ${statusColors[status]}`}
title={statusLabels[status]}
/>
</div>
{/* Clickable area for opening service URL */}
@@ -107,7 +117,6 @@ export function ServiceCard({ service, status }: ServiceCardProps) {
rel="noopener noreferrer"
className="block"
>
{/* Icon */}
<div className="w-10 h-10 flex items-center justify-center rounded-lg bg-slate-100 dark:bg-stone-800 mb-3 group-hover:bg-slate-200 dark:group-hover:bg-stone-700 transition-colors">
<Icon
name={service.icon}
@@ -116,7 +125,6 @@ export function ServiceCard({ service, status }: ServiceCardProps) {
/>
</div>
{/* Content */}
<h3 className="font-medium text-slate-900 dark:text-stone-100 mb-1">
{service.name}
</h3>
@@ -126,7 +134,6 @@ export function ServiceCard({ service, status }: ServiceCardProps) {
</p>
)}
{/* Badge: FQDN subdomain or port */}
<div className="mt-3 flex items-center gap-2">
{fqdnLabel && (
<span className="text-xs px-1.5 py-0.5 rounded bg-cyan-50 dark:bg-cyan-900/20 text-cyan-600 dark:text-cyan-400 font-mono">
@@ -141,50 +148,54 @@ export function ServiceCard({ service, status }: ServiceCardProps) {
</div>
</a>
{/* Control buttons for discovered services */}
{/* Controls footer */}
{discovered && (
<div className="mt-3 pt-3 border-t border-slate-100 dark:border-stone-800 flex items-center gap-1.5">
{status === 'stopped' || status === 'unknown' ? (
<button
onClick={() => controlService('start')}
disabled={loading}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium bg-emerald-50 dark:bg-emerald-900/20 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-100 dark:hover:bg-emerald-900/40 transition-colors disabled:opacity-50"
>
{loading ? (
<Icon name="loader" size={12} className="animate-spin" />
) : (
<Icon name="play" size={12} />
)}
Start
</button>
) : status === 'running' ? (
<>
<button
onClick={() => controlService('restart')}
disabled={loading}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium bg-slate-50 dark:bg-stone-800 text-slate-500 dark:text-stone-400 hover:bg-slate-100 dark:hover:bg-stone-700 transition-colors disabled:opacity-50"
>
{loading ? (
<Icon name="loader" size={12} className="animate-spin" />
) : (
<Icon name="refresh-cw" size={12} />
)}
Restart
</button>
<button
onClick={handleStop}
disabled={loading}
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium transition-colors disabled:opacity-50 ${
confirmStop
? 'bg-red-100 dark:bg-red-900/40 text-red-600 dark:text-red-400'
: 'bg-slate-50 dark:bg-stone-800 text-slate-500 dark:text-stone-400 hover:bg-red-50 dark:hover:bg-red-900/20 hover:text-red-500 dark:hover:text-red-400'
}`}
>
<Icon name="power" size={12} />
{confirmStop ? 'Confirm' : 'Stop'}
</button>
</>
) : null}
<div className="mt-3 pt-3 border-t border-slate-100 dark:border-stone-800 flex items-center justify-between">
{/* Status pill */}
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[11px] font-medium ${statusPillStyles[status]}`}>
<Icon
name={loading ? 'loader' : statusIcons[status]}
size={10}
className={loading || status === 'loading' ? 'animate-spin' : ''}
/>
{loading ? 'Processing...' : statusLabels[status]}
</span>
{/* Action buttons */}
{!loading && (
<div className="flex items-center gap-1">
{isStopped ? (
<button
onClick={() => controlService('start')}
title="Start"
className="p-1.5 rounded-md bg-emerald-50 dark:bg-emerald-900/20 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-100 dark:hover:bg-emerald-900/40 transition-colors"
>
<Icon name="play" size={14} />
</button>
) : status === 'running' ? (
<>
<button
onClick={() => controlService('restart')}
title="Restart"
className="p-1.5 rounded-md text-slate-400 dark:text-stone-500 hover:bg-slate-100 dark:hover:bg-stone-800 hover:text-slate-600 dark:hover:text-stone-300 transition-colors"
>
<Icon name="refresh-cw" size={14} />
</button>
<button
onClick={handleStop}
title={confirmStop ? 'Click again to confirm' : 'Stop'}
className={`p-1.5 rounded-md transition-colors ${
confirmStop
? 'bg-red-100 dark:bg-red-900/40 text-red-500 dark:text-red-400 animate-pulse'
: 'text-slate-400 dark:text-stone-500 hover:bg-red-50 dark:hover:bg-red-900/20 hover:text-red-500 dark:hover:text-red-400'
}`}
>
<Icon name="power" size={14} />
</button>
</>
) : null}
</div>
)}
</div>
)}
</div>

19
src/lib/config.ts Normal file
View File

@@ -0,0 +1,19 @@
// Server-side configuration (only available in API routes / server components)
export const serverConfig = {
coolifyToken: process.env.COOLIFY_API_TOKEN || '',
coolifyApiUrl: process.env.COOLIFY_API_URL || 'http://192.168.1.3:8000/api/v1',
coolifyServerUuid: process.env.COOLIFY_SERVER_UUID || 'qk84w0goo4w48g4ggsoo0oss',
coolifyDbUrl: process.env.COOLIFY_DB_URL || '',
prometheusUrl: process.env.PROMETHEUS_URL || 'http://192.168.1.3:9091',
nodeExporterInstance: process.env.NODE_EXPORTER_INSTANCE || '192.168.1.3:9100',
nicDevice: process.env.NIC_DEVICE || 'eno1',
nucHost: process.env.NUC_HOST || '192.168.1.3',
};
// Client-side configuration (available everywhere via NEXT_PUBLIC_ prefix)
export const clientConfig = {
nucHost: process.env.NEXT_PUBLIC_NUC_HOST || '192.168.1.3',
coolifyUrl: process.env.NEXT_PUBLIC_COOLIFY_URL || 'http://192.168.1.3:8000',
coolifyProjectUuid: process.env.NEXT_PUBLIC_COOLIFY_PROJECT_UUID || 'a8484ggc88c40w4g4k004ow0',
grafanaUrl: process.env.NEXT_PUBLIC_GRAFANA_URL || 'http://192.168.1.3:3333',
};

97
src/lib/coolify.ts Normal file
View File

@@ -0,0 +1,97 @@
import { serverConfig } from './config';
const { coolifyApiUrl, coolifyToken, coolifyServerUuid } = serverConfig;
async function fetchJson<T>(url: string): Promise<T | null> {
try {
const res = await fetch(url, {
headers: { Authorization: `Bearer ${coolifyToken}`, Accept: 'application/json' },
signal: AbortSignal.timeout(5000),
});
if (!res.ok) return null;
return await res.json() as T;
} catch {
return null;
}
}
interface CoolifyResource {
id: number;
uuid: string;
name: string;
type: string;
status: string;
created_at: string;
updated_at: string;
}
export async function fetchResources(): Promise<CoolifyResource[] | null> {
return fetchJson<CoolifyResource[]>(
`${coolifyApiUrl}/servers/${coolifyServerUuid}/resources`
);
}
export async function fetchAppDetail(uuid: string) {
return fetchJson<{
uuid: string;
name: string;
fqdn: string | null;
ports_exposes: string | null;
ports_mappings: string | null;
status: string;
description: string | null;
}>(`${coolifyApiUrl}/applications/${uuid}`);
}
export async function fetchServiceDetail(uuid: string) {
return fetchJson<{
uuid: string;
name: string;
applications?: Array<{
name: string;
human_name: string | null;
fqdn: string | null;
ports: string | null;
status: string;
image: string | null;
}>;
databases?: Array<{
name: string;
status: string;
}>;
}>(`${coolifyApiUrl}/services/${uuid}`);
}
export async function controlResource(
uuid: string,
resourceType: 'application' | 'service' | 'database',
action: 'start' | 'stop' | 'restart'
): Promise<{ ok: boolean; status: number }> {
const endpoint = `${coolifyApiUrl}/${resourceType}s/${uuid}/${action}`;
try {
const res = await fetch(endpoint, {
method: 'POST',
headers: { Authorization: `Bearer ${coolifyToken}`, Accept: 'application/json' },
signal: AbortSignal.timeout(15000),
});
return { ok: res.ok, status: res.status };
} catch {
return { ok: false, status: 500 };
}
}
export async function triggerDeploy(uuid: string): Promise<{ ok: boolean; message?: string }> {
try {
const res = await fetch(`${coolifyApiUrl}/deploy?uuid=${uuid}&force=false`, {
headers: { Authorization: `Bearer ${coolifyToken}`, Accept: 'application/json' },
signal: AbortSignal.timeout(15000),
});
if (!res.ok) {
const text = await res.text();
return { ok: false, message: `${res.status}: ${text}` };
}
return { ok: true };
} catch (err) {
return { ok: false, message: String(err) };
}
}