Add restart/stop controls to every ServiceCard on Services tab

Running services show Restart and Stop buttons. Stopped/unknown show
Start button. Stop requires double-click confirmation (3s timeout).
Card restructured from <a> wrapper to <div> with nested <a> for the
link area, so buttons can coexist without nesting issues.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-02-03 23:58:17 +01:00
parent 93578904f4
commit 14fc608754

View File

@@ -1,5 +1,6 @@
'use client'; 'use client';
import { useState, useCallback } from 'react';
import { Service, DiscoveredService } from '@/lib/services'; import { Service, DiscoveredService } from '@/lib/services';
import { HealthStatus } from '@/lib/PortalContext'; import { HealthStatus } from '@/lib/PortalContext';
import { Icon } from './Icons'; import { Icon } from './Icons';
@@ -32,7 +33,6 @@ function getFqdnLabel(service: Service): string | null {
try { try {
const url = new URL(service.fqdn); const url = new URL(service.fqdn);
const hostname = url.hostname; const hostname = url.hostname;
// Show just the subdomain part if it's a .nuc.lan address
if (hostname.endsWith('.nuc.lan')) { if (hostname.endsWith('.nuc.lan')) {
return hostname.replace('.nuc.lan', ''); return hostname.replace('.nuc.lan', '');
} }
@@ -52,14 +52,41 @@ function getResourceBadge(service: Service): string | null {
export function ServiceCard({ service, status }: ServiceCardProps) { export function ServiceCard({ service, status }: ServiceCardProps) {
const fqdnLabel = getFqdnLabel(service); const fqdnLabel = getFqdnLabel(service);
const resourceBadge = getResourceBadge(service); const resourceBadge = getResourceBadge(service);
const discovered = isDiscovered(service);
const [loading, setLoading] = useState(false);
const [confirmStop, setConfirmStop] = useState(false);
const controlService = useCallback(async (action: 'start' | 'stop' | 'restart') => {
if (!discovered) return;
setLoading(true);
setConfirmStop(false);
try {
await fetch('/api/control', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
uuid: service.uuid,
resourceType: service.resourceType,
action,
}),
});
// Coolify takes a moment to process — page will refresh via polling
} catch { /* ignore */ } finally {
setTimeout(() => setLoading(false), 3000);
}
}, [discovered, service]);
const handleStop = useCallback(() => {
if (confirmStop) {
controlService('stop');
} else {
setConfirmStop(true);
setTimeout(() => setConfirmStop(false), 3000);
}
}, [confirmStop, controlService]);
return ( return (
<a <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">
href={service.url}
target="_blank"
rel="noopener noreferrer"
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 */} {/* Status indicator */}
<div className="absolute top-3 right-3 flex items-center gap-1.5"> <div className="absolute top-3 right-3 flex items-center gap-1.5">
{resourceBadge && ( {resourceBadge && (
@@ -73,38 +100,93 @@ export function ServiceCard({ service, status }: ServiceCardProps) {
/> />
</div> </div>
{/* Icon */} {/* Clickable area for opening service URL */}
<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"> <a
<Icon href={service.url}
name={service.icon} target="_blank"
size={20} rel="noopener noreferrer"
className="text-slate-600 dark:text-stone-400" className="block"
/> >
</div> {/* 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}
size={20}
className="text-slate-600 dark:text-stone-400"
/>
</div>
{/* Content */} {/* Content */}
<h3 className="font-medium text-slate-900 dark:text-stone-100 mb-1"> <h3 className="font-medium text-slate-900 dark:text-stone-100 mb-1">
{service.name} {service.name}
</h3> </h3>
{service.description && ( {service.description && (
<p className="text-sm text-slate-500 dark:text-stone-500 line-clamp-2"> <p className="text-sm text-slate-500 dark:text-stone-500 line-clamp-2">
{service.description} {service.description}
</p> </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">
{fqdnLabel}
</span>
)}
{service.port > 0 && (
<span className="text-xs text-slate-400 dark:text-stone-600 font-mono">
:{service.port}
</span>
)}
</div>
</a>
{/* Control buttons for discovered services */}
{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>
)} )}
</div>
{/* 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">
{fqdnLabel}
</span>
)}
{service.port > 0 && (
<span className="text-xs text-slate-400 dark:text-stone-600 font-mono">
:{service.port}
</span>
)}
</div>
</a>
); );
} }