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>
244 lines
8.8 KiB
TypeScript
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>
|
|
);
|
|
}
|