Add service start/stop toggle buttons via Coolify API

New /api/control route proxies start/stop/restart actions to Coolify.
Stopped and unknown service chips now show a play button to start them.
After toggling, auto-refreshes service discovery after 3s.
Added play, power, stop-circle icons.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-02-03 23:16:45 +01:00
parent 4024005319
commit 93578904f4
3 changed files with 154 additions and 21 deletions

View File

@@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from 'next/server';
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_RESOURCE_TYPES = ['application', 'service', 'database'] as const;
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { uuid, resourceType, action } = body as {
uuid: string;
resourceType: string;
action: string;
};
if (!uuid || !resourceType || !action) {
return NextResponse.json(
{ error: 'Missing uuid, resourceType, or action' },
{ status: 400 }
);
}
if (!VALID_ACTIONS.includes(action as typeof VALID_ACTIONS[number])) {
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 }
);
}
// Coolify API: POST /api/v1/{resourceType}s/{uuid}/{action}
const endpoint = `${COOLIFY_API}/${resourceType}s/${uuid}/${action}`;
const res = await fetch(endpoint, {
method: 'POST',
headers: {
Authorization: `Bearer ${COOLIFY_TOKEN}`,
Accept: 'application/json',
},
signal: AbortSignal.timeout(15000),
});
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 }
);
}
return NextResponse.json({ ok: true, action, uuid });
} catch (error) {
console.error('Control error:', error);
return NextResponse.json(
{ error: 'Failed to control service' },
{ status: 500 }
);
}
}

View File

@@ -109,6 +109,9 @@ export const icons: Record<string, React.ComponentType<IconProps>> = {
'maximize-2': createIcon('<polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" x2="14" y1="3" y2="10"/><line x1="3" x2="10" y1="21" y2="14"/>'),
'settings': createIcon('<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>'),
'loader': createIcon('<path d="M12 2v4"/><path d="m16.2 7.8 2.9-2.9"/><path d="M18 12h4"/><path d="m16.2 16.2 2.9 2.9"/><path d="M12 18v4"/><path d="m4.9 19.1 2.9-2.9"/><path d="M2 12h4"/><path d="m4.9 4.9 2.9 2.9"/>'),
'play': createIcon('<polygon points="6 3 20 12 6 21 6 3"/>'),
'power': createIcon('<path d="M12 2v10"/><path d="M18.4 6.6a9 9 0 1 1-12.77.04"/>'),
'stop-circle': createIcon('<circle cx="12" cy="12" r="10"/><rect x="9" y="9" width="6" height="6"/>'),
// Navigation
'chevron-right': createIcon('<path d="m9 18 6-6-6-6"/>'),

View File

@@ -1,5 +1,6 @@
'use client';
import { useState, useCallback } from 'react';
import { usePortal } from '@/lib/PortalContext';
import { Icon } from './Icons';
import { SystemTrends } from './SystemTrends';
@@ -7,7 +8,11 @@ import { formatUptime } from '@/lib/stats';
import { STATUS_COLORS, STATUS_LABELS, formatRelativeTime, formatDuration } from '@/lib/deployments';
import type { DeploymentStatus } from '@/lib/deployments';
import type { HealthStatus } from '@/lib/PortalContext';
import type { Service } from '@/lib/services';
import type { Service, DiscoveredService } from '@/lib/services';
function isDiscoveredService(s: Service): s is DiscoveredService {
return 'uuid' in s && 'resourceType' in s;
}
const quickLinks = [
{ name: 'Coolify', url: 'http://192.168.1.3:8000', icon: 'coolify', desc: 'Service manager' },
@@ -115,8 +120,29 @@ export function OverviewTab() {
deploymentsLoading,
discoveredServices,
setActiveTab,
refreshDiscover,
} = usePortal();
const [controlling, setControlling] = useState<Record<string, boolean>>({});
const controlService = useCallback(async (s: Service, action: 'start' | 'stop' | 'restart') => {
if (!isDiscoveredService(s)) return;
setControlling(prev => ({ ...prev, [s.uuid]: true }));
try {
const res = await fetch('/api/control', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uuid: s.uuid, resourceType: s.resourceType, action }),
});
if (res.ok) {
// Wait briefly for Coolify to process, then refresh
setTimeout(() => refreshDiscover(), 3000);
}
} catch { /* ignore */ } finally {
setControlling(prev => ({ ...prev, [s.uuid]: false }));
}
}, [refreshDiscover]);
const runningServices = services.filter(s => healthStatus[s.name] === 'running');
const stoppedServices = services.filter(s => healthStatus[s.name] === 'stopped');
const unknownServices = services.filter(s => {
@@ -158,38 +184,74 @@ export function OverviewTab() {
<span className="text-sm text-slate-500 dark:text-stone-500">of {totalCount} running</span>
</div>
{/* Stopped services as named chips */}
{/* Stopped services as named chips with start button */}
{stoppedServices.length > 0 && (
<div className="mb-3">
<p className="text-[11px] font-medium text-red-500/70 dark:text-red-400/70 uppercase tracking-wider mb-1.5">Stopped</p>
<div className="flex flex-wrap gap-1.5">
{stoppedServices.map(s => (
<span
key={s.name}
className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 border border-red-100 dark:border-red-800/30"
>
<span className="w-1.5 h-1.5 rounded-full bg-red-500 flex-shrink-0" />
{s.name}
</span>
))}
{stoppedServices.map(s => {
const uuid = isDiscoveredService(s) ? s.uuid : null;
const loading = uuid ? controlling[uuid] : false;
return (
<span
key={s.name}
className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 border border-red-100 dark:border-red-800/30"
>
<span className="w-1.5 h-1.5 rounded-full bg-red-500 flex-shrink-0" />
{s.name}
{uuid && (
<button
onClick={() => controlService(s, 'start')}
disabled={loading}
title={`Start ${s.name}`}
className="ml-0.5 p-0.5 rounded hover:bg-red-100 dark:hover:bg-red-800/30 transition-colors disabled:opacity-50"
>
{loading ? (
<Icon name="loader" size={11} className="animate-spin" />
) : (
<Icon name="play" size={11} />
)}
</button>
)}
</span>
);
})}
</div>
</div>
)}
{/* Unknown services as named chips */}
{/* Unknown services as named chips with start button */}
{unknownServices.length > 0 && (
<div className="mb-3">
<p className="text-[11px] font-medium text-slate-400/70 dark:text-stone-500/70 uppercase tracking-wider mb-1.5">Unknown</p>
<div className="flex flex-wrap gap-1.5">
{unknownServices.map(s => (
<span
key={s.name}
className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium bg-slate-50 dark:bg-stone-800/50 text-slate-500 dark:text-stone-500 border border-slate-200 dark:border-stone-700/50"
>
<span className="w-1.5 h-1.5 rounded-full bg-slate-400 dark:bg-stone-600 flex-shrink-0" />
{s.name}
</span>
))}
{unknownServices.map(s => {
const uuid = isDiscoveredService(s) ? s.uuid : null;
const loading = uuid ? controlling[uuid] : false;
return (
<span
key={s.name}
className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium bg-slate-50 dark:bg-stone-800/50 text-slate-500 dark:text-stone-500 border border-slate-200 dark:border-stone-700/50"
>
<span className="w-1.5 h-1.5 rounded-full bg-slate-400 dark:bg-stone-600 flex-shrink-0" />
{s.name}
{uuid && (
<button
onClick={() => controlService(s, 'start')}
disabled={loading}
title={`Start ${s.name}`}
className="ml-0.5 p-0.5 rounded hover:bg-slate-200 dark:hover:bg-stone-700 transition-colors disabled:opacity-50"
>
{loading ? (
<Icon name="loader" size={11} className="animate-spin" />
) : (
<Icon name="play" size={11} />
)}
</button>
)}
</span>
);
})}
</div>
</div>
)}