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>
193 lines
6.8 KiB
TypeScript
193 lines
6.8 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useCallback } from 'react';
|
|
import { Service, DiscoveredService } from '@/lib/services';
|
|
import { HealthStatus } from '@/lib/PortalContext';
|
|
import { Icon } from './Icons';
|
|
|
|
interface ServiceCardProps {
|
|
service: Service;
|
|
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 statusLabels: Record<HealthStatus, string> = {
|
|
running: 'Running',
|
|
stopped: 'Stopped',
|
|
unknown: 'Unknown',
|
|
loading: 'Checking...',
|
|
};
|
|
|
|
function isDiscovered(service: Service): service is DiscoveredService {
|
|
return 'source' in service && (service as DiscoveredService).source === 'discovered';
|
|
}
|
|
|
|
function getFqdnLabel(service: Service): string | null {
|
|
if (!isDiscovered(service) || !service.fqdn) return null;
|
|
try {
|
|
const url = new URL(service.fqdn);
|
|
const hostname = url.hostname;
|
|
if (hostname.endsWith('.nuc.lan')) {
|
|
return hostname.replace('.nuc.lan', '');
|
|
}
|
|
return hostname;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function getResourceBadge(service: Service): string | null {
|
|
if (!isDiscovered(service)) return null;
|
|
if (service.resourceType === 'database') return 'DB';
|
|
if (service.resourceType === 'application') return 'App';
|
|
return null;
|
|
}
|
|
|
|
export function ServiceCard({ service, status }: ServiceCardProps) {
|
|
const fqdnLabel = getFqdnLabel(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 (
|
|
<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="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 */}
|
|
<a
|
|
href={service.url}
|
|
target="_blank"
|
|
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}
|
|
size={20}
|
|
className="text-slate-600 dark:text-stone-400"
|
|
/>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<h3 className="font-medium text-slate-900 dark:text-stone-100 mb-1">
|
|
{service.name}
|
|
</h3>
|
|
{service.description && (
|
|
<p className="text-sm text-slate-500 dark:text-stone-500 line-clamp-2">
|
|
{service.description}
|
|
</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>
|
|
);
|
|
}
|