From 70399a00ecab30433031c47a554134bda1dd8a29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Br=C3=BCckner?= Date: Fri, 5 Jun 2026 09:39:58 +0200 Subject: [PATCH] feat(semaphore): trigger Ansible tasks at booking start/end via Semaphore - Background scheduler checks every 30s for bookings that need setup or teardown - Per-lab Semaphore template IDs stored on the labs table - Booking flags track which jobs have been triggered and their Semaphore job IDs - Immediate teardown triggered when an active booking is cancelled - Settings UI section for Semaphore API URL, token, and project ID - Lab template form fields for setup/teardown template IDs - BookingDetailsModal shows live Ansible job status with manual trigger buttons --- server-db.ts | 10 ++ server.ts | 215 +++++++++++++++++++++++-- src/components/BookingDetailsModal.tsx | 92 ++++++++++- src/components/LabTemplates.tsx | 53 +++++- src/components/Settings.tsx | 136 +++++++++++++++- src/types.ts | 6 + 6 files changed, 488 insertions(+), 24 deletions(-) diff --git a/server-db.ts b/server-db.ts index 98cff45..aeffead 100644 --- a/server-db.ts +++ b/server-db.ts @@ -89,6 +89,12 @@ function ensureColumn(table: string, column: string, ddl: string) { ensureColumn('devices', 'checkMkUrl', "checkMkUrl TEXT NOT NULL DEFAULT ''"); ensureColumn('devices', 'cmkHostname', "cmkHostname TEXT NOT NULL DEFAULT ''"); +ensureColumn('labs', 'semaphoreSetupTemplateId', "semaphoreSetupTemplateId TEXT NOT NULL DEFAULT ''"); +ensureColumn('labs', 'semaphoreTeardownTemplateId', "semaphoreTeardownTemplateId TEXT NOT NULL DEFAULT ''"); +ensureColumn('bookings', 'ansibleSetupTriggered', 'ansibleSetupTriggered INTEGER NOT NULL DEFAULT 0'); +ensureColumn('bookings', 'ansibleTeardownTriggered', 'ansibleTeardownTriggered INTEGER NOT NULL DEFAULT 0'); +ensureColumn('bookings', 'ansibleSetupJobId', "ansibleSetupJobId TEXT NOT NULL DEFAULT ''"); +ensureColumn('bookings', 'ansibleTeardownJobId', "ansibleTeardownJobId TEXT NOT NULL DEFAULT ''"); // Seed default settings (INSERT OR IGNORE = only if key absent) const _insertDefault = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)'); @@ -104,6 +110,10 @@ const _defaultSettings: [string, string][] = [ ['checkmk_api_user', 'automation'], ['checkmk_api_secret', ''], ['checkmk_sync_interval_ms', '60000'], + ['semaphore_enabled', 'false'], + ['semaphore_api_url', ''], + ['semaphore_api_token', ''], + ['semaphore_project_id', ''], ]; for (const [k, v] of _defaultSettings) _insertDefault.run(k, v); diff --git a/server.ts b/server.ts index 8fb6ccb..d85ae4a 100644 --- a/server.ts +++ b/server.ts @@ -61,7 +61,7 @@ function getMsalClient(): ConfidentialClientApplication | null { }); } -const SECRET_KEYS = ['azure_client_secret', 'checkmk_api_secret']; +const SECRET_KEYS = ['azure_client_secret', 'checkmk_api_secret', 'semaphore_api_token']; function maskSettings(raw: Record): Record { const out = { ...raw }; @@ -247,7 +247,9 @@ async function startServer() { app.put('/api/settings', requireAuth, (req, res) => { try { const allowed = ['azure_enabled', 'azure_client_id', 'azure_tenant_id', 'azure_client_secret', - 'azure_redirect_uri', 'checkmk_enabled', 'checkmk_api_url', 'checkmk_api_user', 'checkmk_api_secret', 'checkmk_sync_interval_ms']; + 'azure_redirect_uri', 'azure_allowed_group', 'checkmk_enabled', 'checkmk_api_url', 'checkmk_api_user', + 'checkmk_api_secret', 'checkmk_sync_interval_ms', + 'semaphore_enabled', 'semaphore_api_url', 'semaphore_api_token', 'semaphore_project_id']; const updates = req.body as Record; for (const key of allowed) { if (key in updates && updates[key] !== '__SET__') { @@ -409,7 +411,9 @@ async function startServer() { const labs: LabTemplate[] = rows.map(r => ({ id: r.id, name: r.name, description: r.description, contactPerson: r.contactPerson, location: r.location, - deviceIds: JSON.parse(r.deviceIds), topology: JSON.parse(r.topology) + deviceIds: JSON.parse(r.deviceIds), topology: JSON.parse(r.topology), + semaphoreSetupTemplateId: r.semaphoreSetupTemplateId || '', + semaphoreTeardownTemplateId: r.semaphoreTeardownTemplateId || '', })); res.json(labs); } catch (err: any) { @@ -419,14 +423,14 @@ async function startServer() { app.post('/api/labs', requireAuth, (req, res) => { try { - const { name, description, contactPerson, location, deviceIds, topology } = req.body; + const { name, description, contactPerson, location, deviceIds, topology, semaphoreSetupTemplateId, semaphoreTeardownTemplateId } = req.body; if (!name || !deviceIds || !Array.isArray(deviceIds)) { return res.status(400).json({ error: 'Missing name or associated device configurations.' }); } const id = uid("lab"); - db.prepare(`INSERT INTO labs (id, name, description, contactPerson, location, deviceIds, topology) VALUES (?, ?, ?, ?, ?, ?, ?)`) - .run(id, name, description || '', contactPerson || '', location || '', JSON.stringify(deviceIds), JSON.stringify(topology || [])); + db.prepare(`INSERT INTO labs (id, name, description, contactPerson, location, deviceIds, topology, semaphoreSetupTemplateId, semaphoreTeardownTemplateId) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`) + .run(id, name, description || '', contactPerson || '', location || '', JSON.stringify(deviceIds), JSON.stringify(topology || []), semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId || ''); const logId = uid("log"); db.prepare(`INSERT INTO logs (id, timestamp, type, message, userId) VALUES (?, ?, ?, ?, ?)`) @@ -435,7 +439,7 @@ async function startServer() { req.user!.userId); const r = db.prepare('SELECT * FROM labs WHERE id = ?').get(id) as any; - res.status(201).json({ id: r.id, name: r.name, description: r.description, contactPerson: r.contactPerson, location: r.location, deviceIds: JSON.parse(r.deviceIds), topology: JSON.parse(r.topology) }); + res.status(201).json({ id: r.id, name: r.name, description: r.description, contactPerson: r.contactPerson, location: r.location, deviceIds: JSON.parse(r.deviceIds), topology: JSON.parse(r.topology), semaphoreSetupTemplateId: r.semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId: r.semaphoreTeardownTemplateId || '' }); } catch (err: any) { res.status(500).json({ error: err.message }); } @@ -444,10 +448,10 @@ async function startServer() { app.put('/api/labs/:id', requireAuth, (req, res) => { try { const id = req.params.id; - const { name, description, contactPerson, location, deviceIds, topology } = req.body; + const { name, description, contactPerson, location, deviceIds, topology, semaphoreSetupTemplateId, semaphoreTeardownTemplateId } = req.body; - db.prepare(`UPDATE labs SET name = ?, description = ?, contactPerson = ?, location = ?, deviceIds = ?, topology = ? WHERE id = ?`) - .run(name, description, contactPerson, location, JSON.stringify(deviceIds), JSON.stringify(topology), id); + db.prepare(`UPDATE labs SET name = ?, description = ?, contactPerson = ?, location = ?, deviceIds = ?, topology = ?, semaphoreSetupTemplateId = ?, semaphoreTeardownTemplateId = ? WHERE id = ?`) + .run(name, description, contactPerson, location, JSON.stringify(deviceIds), JSON.stringify(topology), semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId || '', id); const logId = uid("log"); db.prepare(`INSERT INTO logs (id, timestamp, type, message, userId) VALUES (?, ?, ?, ?, ?)`) @@ -455,7 +459,7 @@ async function startServer() { `Modified the active topology mapping schema for the "${name}" lab template.`, req.user!.userId); const r = db.prepare('SELECT * FROM labs WHERE id = ?').get(id) as any; - res.json({ id: r.id, name: r.name, description: r.description, contactPerson: r.contactPerson, location: r.location, deviceIds: JSON.parse(r.deviceIds), topology: JSON.parse(r.topology) }); + res.json({ id: r.id, name: r.name, description: r.description, contactPerson: r.contactPerson, location: r.location, deviceIds: JSON.parse(r.deviceIds), topology: JSON.parse(r.topology), semaphoreSetupTemplateId: r.semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId: r.semaphoreTeardownTemplateId || '' }); } catch (err: any) { res.status(500).json({ error: err.message }); } @@ -491,7 +495,11 @@ async function startServer() { id: r.id, labId: r.labId, userId: r.userId, startDateTime: r.startDateTime, endDateTime: r.endDateTime, notes: r.notes || '', status: r.status as any, - notified: r.notified === 1, emailSent: r.emailSent === 1 + notified: r.notified === 1, emailSent: r.emailSent === 1, + ansibleSetupTriggered: r.ansibleSetupTriggered === 1, + ansibleTeardownTriggered: r.ansibleTeardownTriggered === 1, + ansibleSetupJobId: r.ansibleSetupJobId || '', + ansibleTeardownJobId: r.ansibleTeardownJobId || '', })); res.json(bookings); } catch (err: any) { @@ -530,7 +538,7 @@ async function startServer() { } }); - app.put('/api/bookings/:id', requireAuth, (req, res) => { + app.put('/api/bookings/:id', requireAuth, async (req, res) => { try { const id = req.params.id; const { status, operatorName } = req.body; @@ -541,16 +549,38 @@ async function startServer() { db.prepare('UPDATE bookings SET status = ? WHERE id = ?').run(status, id); if (status === 'cancelled') { - const lab = db.prepare('SELECT name FROM labs WHERE id = ?').get(booking.labId) as { name: string } | undefined; + const lab = db.prepare('SELECT name, semaphoreTeardownTemplateId FROM labs WHERE id = ?').get(booking.labId) as { name: string; semaphoreTeardownTemplateId: string } | undefined; const logId = uid("log"); db.prepare(`INSERT INTO logs (id, timestamp, type, message, userId) VALUES (?, ?, ?, ?, ?)`) .run(logId, new Date().toISOString(), 'booking', `${operatorName || 'An operator'} canceled the reservation for lab template "${lab?.name || 'Unknown'}" and released associated nodes back into the pool.`, req.user!.userId); + + // Trigger teardown if booking had already started and teardown not yet triggered + const now = new Date(); + if (new Date(booking.startDateTime) <= now && !booking.ansibleTeardownTriggered) { + const templateId = lab?.semaphoreTeardownTemplateId; + if (templateId) { + const jobId = await triggerSemaphoreTask(Number(templateId), { + booking_id: booking.id, lab_name: lab?.name || '', user_id: booking.userId, + start_time: booking.startDateTime, end_time: booking.endDateTime, + }); + db.prepare('UPDATE bookings SET ansibleTeardownTriggered = 1, ansibleTeardownJobId = ? WHERE id = ?') + .run(jobId !== null ? String(jobId) : '', booking.id); + } + } } const r = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id) as any; - res.json({ id: r.id, labId: r.labId, userId: r.userId, startDateTime: r.startDateTime, endDateTime: r.endDateTime, notes: r.notes || '', status: r.status, notified: r.notified === 1, emailSent: r.emailSent === 1 }); + res.json({ + id: r.id, labId: r.labId, userId: r.userId, startDateTime: r.startDateTime, + endDateTime: r.endDateTime, notes: r.notes || '', status: r.status, + notified: r.notified === 1, emailSent: r.emailSent === 1, + ansibleSetupTriggered: r.ansibleSetupTriggered === 1, + ansibleTeardownTriggered: r.ansibleTeardownTriggered === 1, + ansibleSetupJobId: r.ansibleSetupJobId || '', + ansibleTeardownJobId: r.ansibleTeardownJobId || '', + }); } catch (err: any) { res.status(500).json({ error: err.message }); } @@ -806,6 +836,161 @@ async function startServer() { } }); + // ------------------------------------------------------------- + // ANSIBLE SEMAPHORE INTEGRATION + // Triggers Semaphore task templates at booking start (setup) and + // booking end (teardown). Uses the same self-rescheduling pattern + // as CheckMK. Template IDs are configured per lab template. + // ------------------------------------------------------------- + async function triggerSemaphoreTask(templateId: number, extraVars: Record): Promise { + const now = new Date().toISOString(); + const apiUrl = getSetting('semaphore_api_url'); + const token = getSetting('semaphore_api_token'); + const projectId = getSetting('semaphore_project_id'); + + if (!apiUrl || !token || !projectId) { + db.prepare('INSERT INTO logs (id, timestamp, type, message) VALUES (?, ?, ?, ?)') + .run(uid('log'), now, 'system', 'Semaphore trigger skipped — API URL, token, or project ID not configured. Check Settings.'); + return null; + } + + try { + const res = await fetch(`${apiUrl}/api/project/${projectId}/tasks`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ template_id: templateId, environment: JSON.stringify(extraVars) }), + }); + if (!res.ok) { + const hint = res.status === 401 ? 'HTTP 401 Unauthorized — check API token' + : res.status === 403 ? 'HTTP 403 Forbidden — token lacks permission' + : res.status === 404 ? 'HTTP 404 — wrong project ID or Semaphore URL' + : `HTTP ${res.status}`; + throw new Error(hint); + } + const data = await res.json() as { id?: number }; + const jobId = data?.id ?? null; + db.prepare('INSERT INTO logs (id, timestamp, type, message) VALUES (?, ?, ?, ?)') + .run(uid('log'), now, 'system', `Semaphore: triggered template #${templateId} → job #${jobId} (booking ${extraVars.booking_id}).`); + return jobId; + } catch (err: any) { + const msg = `Semaphore trigger failed for template #${templateId} — ${err?.message ?? err}`; + console.error('[Semaphore]', msg); + db.prepare('INSERT INTO logs (id, timestamp, type, message) VALUES (?, ?, ?, ?)') + .run(uid('log'), now, 'system', msg); + return null; + } + } + + async function checkAndTriggerAnsibleTasks() { + if (getSetting('semaphore_enabled') !== 'true') return; + + const now = new Date().toISOString(); + + // Bookings that have started but setup hasn't been triggered yet + const setupPending = db.prepare( + `SELECT b.*, l.semaphoreSetupTemplateId, l.name AS labName + FROM bookings b LEFT JOIN labs l ON b.labId = l.id + WHERE b.startDateTime <= ? AND b.ansibleSetupTriggered = 0 AND b.status != 'cancelled'` + ).all(now) as any[]; + + for (const row of setupPending) { + const templateId = row.semaphoreSetupTemplateId; + if (!templateId) { + db.prepare('UPDATE bookings SET ansibleSetupTriggered = 1 WHERE id = ?').run(row.id); + continue; + } + const jobId = await triggerSemaphoreTask(Number(templateId), { + booking_id: row.id, lab_name: row.labName || '', user_id: row.userId, + start_time: row.startDateTime, end_time: row.endDateTime, + }); + db.prepare('UPDATE bookings SET ansibleSetupTriggered = 1, ansibleSetupJobId = ? WHERE id = ?') + .run(jobId !== null ? String(jobId) : '', row.id); + } + + // Bookings that have ended but teardown hasn't been triggered yet + const teardownPending = db.prepare( + `SELECT b.*, l.semaphoreTeardownTemplateId, l.name AS labName + FROM bookings b LEFT JOIN labs l ON b.labId = l.id + WHERE b.endDateTime <= ? AND b.ansibleTeardownTriggered = 0 AND b.status != 'cancelled'` + ).all(now) as any[]; + + for (const row of teardownPending) { + const templateId = row.semaphoreTeardownTemplateId; + if (!templateId) { + db.prepare('UPDATE bookings SET ansibleTeardownTriggered = 1 WHERE id = ?').run(row.id); + continue; + } + const jobId = await triggerSemaphoreTask(Number(templateId), { + booking_id: row.id, lab_name: row.labName || '', user_id: row.userId, + start_time: row.startDateTime, end_time: row.endDateTime, + }); + db.prepare('UPDATE bookings SET ansibleTeardownTriggered = 1, ansibleTeardownJobId = ? WHERE id = ?') + .run(jobId !== null ? String(jobId) : '', row.id); + } + } + + async function scheduleSemaphoreCheck() { + await checkAndTriggerAnsibleTasks(); + setTimeout(scheduleSemaphoreCheck, 30_000); + } + scheduleSemaphoreCheck(); + + // Proxy Semaphore template list so the UI can populate dropdowns + app.get('/api/semaphore/templates', requireAuth, async (_req, res) => { + const apiUrl = getSetting('semaphore_api_url'); + const token = getSetting('semaphore_api_token'); + const projectId = getSetting('semaphore_project_id'); + if (!apiUrl || !token || !projectId) { + return res.status(400).json({ error: 'Semaphore not fully configured.' }); + } + try { + const r = await fetch(`${apiUrl}/api/project/${projectId}/templates`, { + headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, + }); + if (!r.ok) return res.status(r.status).json({ error: `Semaphore returned HTTP ${r.status}` }); + res.json(await r.json()); + } catch (err: any) { + res.status(500).json({ error: err.message }); + } + }); + + // Manual re-trigger for a specific booking (admin use / testing) + app.post('/api/semaphore/trigger/:bookingId', requireAuth, async (req, res) => { + try { + const { bookingId } = req.params; + const { type } = req.body as { type: 'setup' | 'teardown' }; + const row = db.prepare( + `SELECT b.*, l.semaphoreSetupTemplateId, l.semaphoreTeardownTemplateId, l.name AS labName + FROM bookings b LEFT JOIN labs l ON b.labId = l.id WHERE b.id = ?` + ).get(bookingId) as any; + if (!row) return res.status(404).json({ error: 'Booking not found.' }); + + const templateId = type === 'setup' ? row.semaphoreSetupTemplateId : row.semaphoreTeardownTemplateId; + if (!templateId) return res.status(400).json({ error: `No Semaphore ${type} template configured for this lab.` }); + + const jobId = await triggerSemaphoreTask(Number(templateId), { + booking_id: row.id, lab_name: row.labName || '', user_id: row.userId, + start_time: row.startDateTime, end_time: row.endDateTime, + }); + + if (type === 'setup') { + db.prepare('UPDATE bookings SET ansibleSetupTriggered = 1, ansibleSetupJobId = ? WHERE id = ?') + .run(jobId !== null ? String(jobId) : '', bookingId); + } else { + db.prepare('UPDATE bookings SET ansibleTeardownTriggered = 1, ansibleTeardownJobId = ? WHERE id = ?') + .run(jobId !== null ? String(jobId) : '', bookingId); + } + + res.json({ ok: true, jobId }); + } catch (err: any) { + res.status(500).json({ error: err.message }); + } + }); + app.listen(PORT, '0.0.0.0', () => { console.log(`[Server] Core Server running at http://0.0.0.0:${PORT}`); }); diff --git a/src/components/BookingDetailsModal.tsx b/src/components/BookingDetailsModal.tsx index c2799dd..efd1779 100644 --- a/src/components/BookingDetailsModal.tsx +++ b/src/components/BookingDetailsModal.tsx @@ -5,9 +5,10 @@ import React, { useState } from 'react'; import { Booking, LabTemplate, Device, User } from '../types'; -import { - X, Calendar, Clock, UserIcon, Database, Terminal, Cpu, Play, Check, - Trash2, Ban, Copy, CheckCircle, HelpCircle, HardDrive +import { authFetch } from '../lib/auth'; +import { + X, Calendar, Clock, UserIcon, Database, Terminal, Cpu, Play, Check, + Trash2, Ban, Copy, CheckCircle, HelpCircle, HardDrive, } from 'lucide-react'; interface BookingDetailsModalProps { @@ -39,6 +40,30 @@ export default function BookingDetailsModal({ // Find devices mapped to this booking const mappedDevices = devices.filter(d => lab?.deviceIds.includes(d.id)); + const [triggerStatus, setTriggerStatus] = useState(null); + const [triggering, setTriggering] = useState(false); + + async function manualTrigger(type: 'setup' | 'teardown') { + setTriggering(true); + setTriggerStatus(null); + try { + const res = await authFetch(`/api/semaphore/trigger/${booking.id}`, { + method: 'POST', + body: JSON.stringify({ type }), + }); + const data = await res.json(); + if (!res.ok) { + setTriggerStatus(`Error: ${data.error || `HTTP ${res.status}`}`); + } else { + setTriggerStatus(`Job #${data.jobId} triggered successfully.`); + } + } catch { + setTriggerStatus('Error: Network error.'); + } finally { + setTriggering(false); + } + } + // Developer panel tabs ('rest', 'ansible', 'terminal') const [activeTab, setActiveTab] = useState<'rest' | 'ansible' | 'terminal'>('ansible'); const [isCopied, setIsCopied] = useState(false); @@ -294,6 +319,67 @@ ${ipList.map(ip => ` - "${ip}"`).join('\n') || ' - "No registered targ + {/* Ansible Semaphore automation status */} + {(lab?.semaphoreSetupTemplateId || lab?.semaphoreTeardownTemplateId) && ( +
+
+ + Ansible Automation +
+
+ {lab.semaphoreSetupTemplateId && ( +
+

Setup

+ {booking.ansibleSetupTriggered ? ( +
+ + {booking.ansibleSetupJobId ? `Job #${booking.ansibleSetupJobId}` : 'Triggered'} +
+ ) : ( +
+ Pending + +
+ )} +
+ )} + {lab.semaphoreTeardownTemplateId && ( +
+

Teardown

+ {booking.ansibleTeardownTriggered ? ( +
+ + {booking.ansibleTeardownJobId ? `Job #${booking.ansibleTeardownJobId}` : 'Triggered'} +
+ ) : ( +
+ Pending + +
+ )} +
+ )} +
+ {triggerStatus && ( +

+ {triggerStatus} +

+ )} +
+ )} + {/* BELOW BLOCK: Restful API & Automation Integration developer panel */}
diff --git a/src/components/LabTemplates.tsx b/src/components/LabTemplates.tsx index 2132f95..d1e35fc 100644 --- a/src/components/LabTemplates.tsx +++ b/src/components/LabTemplates.tsx @@ -8,7 +8,7 @@ import { LabTemplate, Device, TopologyLink } from '../types'; import TopologyPanel from './TopologyPanel'; import { Server, Plus, Edit3, Trash, User, MapPin, - Layers, ChevronRight, Save, X, Check, Pencil + Layers, ChevronRight, Save, X, Check, Pencil, Terminal, } from 'lucide-react'; interface LabTemplatesProps { @@ -47,12 +47,16 @@ export default function LabTemplates({ contactPerson: string; location: string; deviceIds: string[]; + semaphoreSetupTemplateId: string; + semaphoreTeardownTemplateId: string; }>({ name: '', description: '', contactPerson: '', location: '', - deviceIds: [] + deviceIds: [], + semaphoreSetupTemplateId: '', + semaphoreTeardownTemplateId: '', }); // Calculate filtered devices associated with selected lab @@ -68,7 +72,9 @@ export default function LabTemplates({ description: '', contactPerson: '', location: '', - deviceIds: [] + deviceIds: [], + semaphoreSetupTemplateId: '', + semaphoreTeardownTemplateId: '', }); setIsEditing(true); }; @@ -82,7 +88,9 @@ export default function LabTemplates({ description: lab.description, contactPerson: lab.contactPerson, location: lab.location, - deviceIds: [...lab.deviceIds] + deviceIds: [...lab.deviceIds], + semaphoreSetupTemplateId: lab.semaphoreSetupTemplateId || '', + semaphoreTeardownTemplateId: lab.semaphoreTeardownTemplateId || '', }); setIsEditing(true); }; @@ -126,7 +134,9 @@ export default function LabTemplates({ contactPerson: formData.contactPerson, location: formData.location, deviceIds: formData.deviceIds, - topology: tempLinks + topology: tempLinks, + semaphoreSetupTemplateId: formData.semaphoreSetupTemplateId, + semaphoreTeardownTemplateId: formData.semaphoreTeardownTemplateId, }; if (formMode === 'add') { @@ -525,6 +535,39 @@ export default function LabTemplates({ )}
+ {/* Ansible Semaphore Automation */} +
+ +

Semaphore task template IDs to trigger at booking start and end. Leave empty to skip automation for this lab.

+
+
+ + setFormData({ ...formData, semaphoreSetupTemplateId: e.target.value })} + className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 font-mono text-xs focus:outline-none focus:border-orange-500/60" + placeholder="e.g. 3" + /> +
+
+ + setFormData({ ...formData, semaphoreTeardownTemplateId: e.target.value })} + className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 font-mono text-xs focus:outline-none focus:border-orange-500/60" + placeholder="e.g. 4" + /> +
+
+
+ {/* Form submit handlers */}
+ {/* ── Ansible Semaphore ── */} + +
+
+
+ +
+
+
+

Ansible Semaphore

+ {semaphoreEnabled && semaphoreApiUrl && semaphoreTokenSet && semaphoreProjectId && ( + + + ACTIVE + + )} +
+

Trigger playbooks automatically at booking start and end

+
+
+
+ + {semaphoreEnabled ? 'ENABLED' : 'DISABLED'} + + +
+
+ +
+ +
+ + } + /> + +
+ : undefined} + > + setShowSemaphoreToken(v => !v)} + /> + + + } + /> + +
+ {semaphoreApiUrl && semaphoreTokenSet && semaphoreProjectId && ( +
+ + {semaphoreTestResult && ( +

+ {semaphoreTestResult} +

+ )} +
+ )} +
+ + {/* Save bar */}

Changes are applied after saving and a server restart.

diff --git a/src/types.ts b/src/types.ts index 83e3918..5b6221d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -34,6 +34,8 @@ export interface LabTemplate { location: string; deviceIds: string[]; topology: TopologyLink[]; + semaphoreSetupTemplateId?: string; + semaphoreTeardownTemplateId?: string; } export interface Booking { @@ -46,6 +48,10 @@ export interface Booking { status: 'active' | 'upcoming' | 'completed' | 'cancelled'; notified: boolean; emailSent?: boolean; + ansibleSetupTriggered?: boolean; + ansibleTeardownTriggered?: boolean; + ansibleSetupJobId?: string; + ansibleTeardownJobId?: string; } export interface LogEntry {