diff --git a/src/app/api/control/route.ts b/src/app/api/control/route.ts index 43e4f61..06bef33 100644 --- a/src/app/api/control/route.ts +++ b/src/app/api/control/route.ts @@ -1,9 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; +import { controlResource, triggerDeploy } from '@/lib/coolify'; -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_ACTIONS = ['start', 'stop', 'restart', 'deploy'] as const; const VALID_RESOURCE_TYPES = ['application', 'service', 'database'] as const; export async function POST(request: NextRequest) { @@ -15,54 +13,41 @@ export async function POST(request: NextRequest) { action: string; }; - if (!uuid || !resourceType || !action) { - return NextResponse.json( - { error: 'Missing uuid, resourceType, or action' }, - { status: 400 } - ); + if (!uuid || !action) { + return NextResponse.json({ error: 'Missing uuid or action' }, { status: 400 }); } if (!VALID_ACTIONS.includes(action as typeof VALID_ACTIONS[number])) { - return NextResponse.json( - { error: `Invalid action: ${action}` }, - { status: 400 } - ); + 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 } - ); + // Deploy action + if (action === 'deploy') { + const result = await triggerDeploy(uuid); + if (!result.ok) { + return NextResponse.json({ error: result.message }, { status: 500 }); + } + return NextResponse.json({ ok: true, action, uuid }); } - // Coolify API: POST /api/v1/{resourceType}s/{uuid}/{action} - const endpoint = `${COOLIFY_API}/${resourceType}s/${uuid}/${action}`; + // Start/stop/restart + if (!resourceType || !VALID_RESOURCE_TYPES.includes(resourceType as typeof VALID_RESOURCE_TYPES[number])) { + return NextResponse.json({ error: `Invalid resourceType: ${resourceType}` }, { status: 400 }); + } - const res = await fetch(endpoint, { - method: 'POST', - headers: { - Authorization: `Bearer ${COOLIFY_TOKEN}`, - Accept: 'application/json', - }, - signal: AbortSignal.timeout(15000), - }); + const result = await controlResource( + uuid, + resourceType as 'application' | 'service' | 'database', + action as 'start' | 'stop' | 'restart' + ); - 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 } - ); + if (!result.ok) { + return NextResponse.json({ error: `Coolify returned ${result.status}` }, { status: result.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 } - ); + return NextResponse.json({ error: 'Failed to control service' }, { status: 500 }); } } diff --git a/src/components/ServiceCard.tsx b/src/components/ServiceCard.tsx index a5ae3d1..2975a0b 100644 --- a/src/components/ServiceCard.tsx +++ b/src/components/ServiceCard.tsx @@ -10,11 +10,18 @@ interface ServiceCardProps { status: HealthStatus; } -const statusColors: Record = { - running: 'bg-emerald-500', - stopped: 'bg-red-500', - unknown: 'bg-slate-400 dark:bg-stone-500', - loading: 'bg-amber-500 animate-pulse', +const borderColors: Record = { + 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 = { + 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 = { @@ -24,6 +31,13 @@ const statusLabels: Record = { loading: 'Checking...', }; +const statusIcons: Record = { + running: 'circle', + stopped: 'power', + unknown: 'circle', + loading: 'loader', +}; + function isDiscovered(service: Service): service is DiscoveredService { return 'source' in service && (service as DiscoveredService).source === 'discovered'; } @@ -55,6 +69,7 @@ export function ServiceCard({ service, status }: ServiceCardProps) { 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; @@ -70,7 +85,6 @@ export function ServiceCard({ service, status }: ServiceCardProps) { action, }), }); - // Coolify takes a moment to process — page will refresh via polling } catch { /* ignore */ } finally { setTimeout(() => setLoading(false), 3000); } @@ -86,18 +100,14 @@ export function ServiceCard({ service, status }: ServiceCardProps) { }, [confirmStop, controlService]); return ( -
- {/* Status indicator */} +
+ {/* Top row: badges */}
{resourceBadge && ( {resourceBadge} )} -
{/* Clickable area for opening service URL */} @@ -107,7 +117,6 @@ export function ServiceCard({ service, status }: ServiceCardProps) { rel="noopener noreferrer" className="block" > - {/* Icon */}
- {/* Content */}

{service.name}

@@ -126,7 +134,6 @@ export function ServiceCard({ service, status }: ServiceCardProps) {

)} - {/* Badge: FQDN subdomain or port */}
{fqdnLabel && ( @@ -141,50 +148,54 @@ export function ServiceCard({ service, status }: ServiceCardProps) {
- {/* Control buttons for discovered services */} + {/* Controls footer */} {discovered && ( -
- {status === 'stopped' || status === 'unknown' ? ( - - ) : status === 'running' ? ( - <> - - - - ) : null} +
+ {/* Status pill */} + + + {loading ? 'Processing...' : statusLabels[status]} + + + {/* Action buttons */} + {!loading && ( +
+ {isStopped ? ( + + ) : status === 'running' ? ( + <> + + + + ) : null} +
+ )}
)}
diff --git a/src/lib/config.ts b/src/lib/config.ts new file mode 100644 index 0000000..6fddfba --- /dev/null +++ b/src/lib/config.ts @@ -0,0 +1,19 @@ +// Server-side configuration (only available in API routes / server components) +export const serverConfig = { + coolifyToken: process.env.COOLIFY_API_TOKEN || '', + coolifyApiUrl: process.env.COOLIFY_API_URL || 'http://192.168.1.3:8000/api/v1', + coolifyServerUuid: process.env.COOLIFY_SERVER_UUID || 'qk84w0goo4w48g4ggsoo0oss', + coolifyDbUrl: process.env.COOLIFY_DB_URL || '', + prometheusUrl: process.env.PROMETHEUS_URL || 'http://192.168.1.3:9091', + nodeExporterInstance: process.env.NODE_EXPORTER_INSTANCE || '192.168.1.3:9100', + nicDevice: process.env.NIC_DEVICE || 'eno1', + nucHost: process.env.NUC_HOST || '192.168.1.3', +}; + +// Client-side configuration (available everywhere via NEXT_PUBLIC_ prefix) +export const clientConfig = { + nucHost: process.env.NEXT_PUBLIC_NUC_HOST || '192.168.1.3', + coolifyUrl: process.env.NEXT_PUBLIC_COOLIFY_URL || 'http://192.168.1.3:8000', + coolifyProjectUuid: process.env.NEXT_PUBLIC_COOLIFY_PROJECT_UUID || 'a8484ggc88c40w4g4k004ow0', + grafanaUrl: process.env.NEXT_PUBLIC_GRAFANA_URL || 'http://192.168.1.3:3333', +}; diff --git a/src/lib/coolify.ts b/src/lib/coolify.ts new file mode 100644 index 0000000..6a82194 --- /dev/null +++ b/src/lib/coolify.ts @@ -0,0 +1,97 @@ +import { serverConfig } from './config'; + +const { coolifyApiUrl, coolifyToken, coolifyServerUuid } = serverConfig; + +async function fetchJson(url: string): Promise { + try { + const res = await fetch(url, { + headers: { Authorization: `Bearer ${coolifyToken}`, Accept: 'application/json' }, + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) return null; + return await res.json() as T; + } catch { + return null; + } +} + +interface CoolifyResource { + id: number; + uuid: string; + name: string; + type: string; + status: string; + created_at: string; + updated_at: string; +} + +export async function fetchResources(): Promise { + return fetchJson( + `${coolifyApiUrl}/servers/${coolifyServerUuid}/resources` + ); +} + +export async function fetchAppDetail(uuid: string) { + return fetchJson<{ + uuid: string; + name: string; + fqdn: string | null; + ports_exposes: string | null; + ports_mappings: string | null; + status: string; + description: string | null; + }>(`${coolifyApiUrl}/applications/${uuid}`); +} + +export async function fetchServiceDetail(uuid: string) { + return fetchJson<{ + uuid: string; + name: string; + applications?: Array<{ + name: string; + human_name: string | null; + fqdn: string | null; + ports: string | null; + status: string; + image: string | null; + }>; + databases?: Array<{ + name: string; + status: string; + }>; + }>(`${coolifyApiUrl}/services/${uuid}`); +} + +export async function controlResource( + uuid: string, + resourceType: 'application' | 'service' | 'database', + action: 'start' | 'stop' | 'restart' +): Promise<{ ok: boolean; status: number }> { + const endpoint = `${coolifyApiUrl}/${resourceType}s/${uuid}/${action}`; + try { + const res = await fetch(endpoint, { + method: 'POST', + headers: { Authorization: `Bearer ${coolifyToken}`, Accept: 'application/json' }, + signal: AbortSignal.timeout(15000), + }); + return { ok: res.ok, status: res.status }; + } catch { + return { ok: false, status: 500 }; + } +} + +export async function triggerDeploy(uuid: string): Promise<{ ok: boolean; message?: string }> { + try { + const res = await fetch(`${coolifyApiUrl}/deploy?uuid=${uuid}&force=false`, { + headers: { Authorization: `Bearer ${coolifyToken}`, Accept: 'application/json' }, + signal: AbortSignal.timeout(15000), + }); + if (!res.ok) { + const text = await res.text(); + return { ok: false, message: `${res.status}: ${text}` }; + } + return { ok: true }; + } catch (err) { + return { ok: false, message: String(err) }; + } +}