From 93578904f41e8294743ee2b3db285d2e93ed7549 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Tue, 3 Feb 2026 23:16:45 +0100 Subject: [PATCH] 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 --- src/app/api/control/route.ts | 68 +++++++++++++++++++++ src/components/Icons.tsx | 3 + src/components/OverviewTab.tsx | 104 ++++++++++++++++++++++++++------- 3 files changed, 154 insertions(+), 21 deletions(-) create mode 100644 src/app/api/control/route.ts diff --git a/src/app/api/control/route.ts b/src/app/api/control/route.ts new file mode 100644 index 0000000..43e4f61 --- /dev/null +++ b/src/app/api/control/route.ts @@ -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 } + ); + } +} diff --git a/src/components/Icons.tsx b/src/components/Icons.tsx index d67a9ae..6906791 100644 --- a/src/components/Icons.tsx +++ b/src/components/Icons.tsx @@ -109,6 +109,9 @@ export const icons: Record> = { 'maximize-2': createIcon(''), 'settings': createIcon(''), 'loader': createIcon(''), + 'play': createIcon(''), + 'power': createIcon(''), + 'stop-circle': createIcon(''), // Navigation 'chevron-right': createIcon(''), diff --git a/src/components/OverviewTab.tsx b/src/components/OverviewTab.tsx index 4338eea..1e050f4 100644 --- a/src/components/OverviewTab.tsx +++ b/src/components/OverviewTab.tsx @@ -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>({}); + + 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() { of {totalCount} running - {/* Stopped services as named chips */} + {/* Stopped services as named chips with start button */} {stoppedServices.length > 0 && (

Stopped

- {stoppedServices.map(s => ( - - - {s.name} - - ))} + {stoppedServices.map(s => { + const uuid = isDiscoveredService(s) ? s.uuid : null; + const loading = uuid ? controlling[uuid] : false; + return ( + + + {s.name} + {uuid && ( + + )} + + ); + })}
)} - {/* Unknown services as named chips */} + {/* Unknown services as named chips with start button */} {unknownServices.length > 0 && (

Unknown

- {unknownServices.map(s => ( - - - {s.name} - - ))} + {unknownServices.map(s => { + const uuid = isDiscoveredService(s) ? s.uuid : null; + const loading = uuid ? controlling[uuid] : false; + return ( + + + {s.name} + {uuid && ( + + )} + + ); + })}
)}