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:
68
src/app/api/control/route.ts
Normal file
68
src/app/api/control/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"/>'),
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user