Files
nuc-portal/src/components/ServiceCard.tsx
Alejandro Gutiérrez e4e5cc97d3 Deep-link Dozzle logs button to specific container
The discover API now fetches container names from Docker via the Python
API on port 9876 and matches them to services by UUID. The Dozzle logs
button builds a proper deep link using the Dozzle host ID and container
name, opening directly to that container's log stream.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 01:17:46 +01:00

244 lines
8.8 KiB
TypeScript

'use client';
import { useState, useCallback } from 'react';
import { Service, DiscoveredService, getCoolifyUrl, getDozzleUrl } from '@/lib/services';
import { HealthStatus } from '@/lib/PortalContext';
import { Icon } from './Icons';
interface ServiceCardProps {
service: Service;
status: HealthStatus;
}
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> = {
running: 'Running',
stopped: 'Stopped',
unknown: 'Unknown',
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';
}
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 isStopped = status === 'stopped' || status === 'unknown';
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,
}),
});
} 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 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: badge */}
<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>
)}
</div>
{/* Service info (non-clickable) */}
<div className="w-10 h-10 flex items-center justify-center rounded-lg bg-slate-100 dark:bg-stone-800 mb-3">
<Icon
name={service.icon}
size={20}
className="text-slate-600 dark:text-stone-400"
/>
</div>
<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>
)}
<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>
{/* Footer: status + links + controls */}
<div className="mt-3 pt-3 border-t border-slate-100 dark:border-stone-800 flex items-center justify-between">
{/* Left: status pill */}
{discovered ? (
<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>
) : (
<span />
)}
{/* Right: links + action buttons */}
<div className="flex items-center gap-1">
{/* Open website */}
<a
href={service.url}
target="_blank"
rel="noopener noreferrer"
title="Open website"
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="external-link" size={14} />
</a>
{/* View logs in Dozzle */}
{discovered && (
<a
href={getDozzleUrl(service as DiscoveredService)}
target="_blank"
rel="noopener noreferrer"
title="View logs"
className="p-1.5 rounded-md text-slate-400 dark:text-stone-500 hover:text-amber-500 dark:hover:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-900/20 transition-colors"
>
<Icon name="scroll-text" size={14} />
</a>
)}
{/* Manage in Coolify */}
{discovered && (
<a
href={getCoolifyUrl(service as DiscoveredService)}
target="_blank"
rel="noopener noreferrer"
title="Manage in Coolify"
className="p-1.5 rounded-md text-slate-400 dark:text-stone-500 hover:text-blue-500 dark:hover:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors"
>
<Icon name="settings" size={14} />
</a>
)}
{/* Divider between links and controls */}
{discovered && !loading && (status === 'running' || isStopped) && (
<span className="w-px h-4 bg-slate-200 dark:bg-stone-700 mx-0.5" />
)}
{/* Control buttons */}
{discovered && !loading && (
<>
{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>
);
}