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
This commit is contained in:
Brückner
2026-06-05 09:39:58 +02:00
parent 11eb06c5ad
commit 70399a00ec
6 changed files with 488 additions and 24 deletions

View File

@ -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<string | null>(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
</div>
{/* Ansible Semaphore automation status */}
{(lab?.semaphoreSetupTemplateId || lab?.semaphoreTeardownTemplateId) && (
<div className="border border-orange-900/40 rounded-xl bg-orange-950/10 p-4 font-sans">
<div className="flex items-center gap-2 mb-3">
<Terminal className="w-4 h-4 text-orange-400" />
<span className="text-[10px] uppercase tracking-wider font-bold text-orange-400">Ansible Automation</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{lab.semaphoreSetupTemplateId && (
<div className="bg-slate-950/60 border border-slate-800 rounded-lg p-3 space-y-2">
<p className="text-[10px] text-slate-400 uppercase tracking-wide font-semibold">Setup</p>
{booking.ansibleSetupTriggered ? (
<div className="flex items-center gap-1.5 text-emerald-400 text-xs font-mono">
<CheckCircle className="w-3.5 h-3.5" />
{booking.ansibleSetupJobId ? `Job #${booking.ansibleSetupJobId}` : 'Triggered'}
</div>
) : (
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500 font-mono">Pending</span>
<button
onClick={() => manualTrigger('setup')}
disabled={triggering || booking.status === 'cancelled'}
className="flex items-center gap-1 px-2 py-1 bg-orange-900/40 hover:bg-orange-800/40 border border-orange-800/50 text-orange-400 text-[10px] font-semibold rounded transition-all disabled:opacity-40 disabled:cursor-not-allowed"
>
<Play className="w-2.5 h-2.5" /> Trigger now
</button>
</div>
)}
</div>
)}
{lab.semaphoreTeardownTemplateId && (
<div className="bg-slate-950/60 border border-slate-800 rounded-lg p-3 space-y-2">
<p className="text-[10px] text-slate-400 uppercase tracking-wide font-semibold">Teardown</p>
{booking.ansibleTeardownTriggered ? (
<div className="flex items-center gap-1.5 text-emerald-400 text-xs font-mono">
<CheckCircle className="w-3.5 h-3.5" />
{booking.ansibleTeardownJobId ? `Job #${booking.ansibleTeardownJobId}` : 'Triggered'}
</div>
) : (
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500 font-mono">Pending</span>
<button
onClick={() => manualTrigger('teardown')}
disabled={triggering || booking.status === 'cancelled'}
className="flex items-center gap-1 px-2 py-1 bg-orange-900/40 hover:bg-orange-800/40 border border-orange-800/50 text-orange-400 text-[10px] font-semibold rounded transition-all disabled:opacity-40 disabled:cursor-not-allowed"
>
<Play className="w-2.5 h-2.5" /> Trigger now
</button>
</div>
)}
</div>
)}
</div>
{triggerStatus && (
<p className={`mt-2 text-[11px] font-mono ${triggerStatus.startsWith('Error') ? 'text-red-400' : 'text-emerald-400'}`}>
{triggerStatus}
</p>
)}
</div>
)}
{/* BELOW BLOCK: Restful API & Automation Integration developer panel */}
<div className="border border-slate-800 rounded-xl overflow-hidden bg-slate-950 font-mono">

View File

@ -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({
)}
</div>
{/* Ansible Semaphore Automation */}
<div className="border-t border-slate-800 pt-3">
<label className="block text-slate-300 font-bold mb-1.5 flex items-center gap-1.5">
<Terminal className="w-3.5 h-3.5 text-orange-400" />
3. Ansible Automation (optional)
</label>
<p className="text-[10px] text-slate-400 mb-2">Semaphore task template IDs to trigger at booking start and end. Leave empty to skip automation for this lab.</p>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-[10px] text-slate-400 mb-1">Setup Template ID</label>
<input
type="text"
inputMode="numeric"
value={formData.semaphoreSetupTemplateId}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-[10px] text-slate-400 mb-1">Teardown Template ID</label>
<input
type="text"
inputMode="numeric"
value={formData.semaphoreTeardownTemplateId}
onChange={(e) => 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"
/>
</div>
</div>
</div>
{/* Form submit handlers */}
<div className="pt-3 border-t border-slate-800 flex justify-end gap-2.5">
<button

View File

@ -3,7 +3,7 @@ import { authFetch } from '../lib/auth';
import { User } from '../types';
import {
Shield, Activity, Save, CheckCircle, AlertCircle, Eye, EyeOff,
Settings2, KeyRound, Globe, Clock, ChevronRight, Copy, Users, RefreshCw,
Settings2, KeyRound, Globe, Clock, ChevronRight, Copy, Users, RefreshCw, Terminal,
} from 'lucide-react';
const SECRET_SENTINEL = '__SET__';
@ -20,6 +20,10 @@ interface RawSettings {
checkmk_api_user: string;
checkmk_api_secret: string;
checkmk_sync_interval_ms: string;
semaphore_enabled: string;
semaphore_api_url: string;
semaphore_api_token: string;
semaphore_project_id: string;
}
interface SettingsProps {
@ -158,6 +162,15 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
const [showCheckmkSecret, setShowCheckmkSecret] = useState(false);
const [syncing, setSyncing] = useState(false);
const [semaphoreEnabled, setSemaphoreEnabled] = useState(false);
const [semaphoreApiUrl, setSemaphoreApiUrl] = useState('');
const [semaphoreApiToken, setSemaphoreApiToken] = useState('');
const [semaphoreTokenSet, setSemaphoreTokenSet] = useState(false);
const [semaphoreProjectId, setSemaphoreProjectId] = useState('');
const [showSemaphoreToken, setShowSemaphoreToken] = useState(false);
const [semaphoreTesting, setSemaphoreTesting] = useState(false);
const [semaphoreTestResult, setSemaphoreTestResult] = useState<string | null>(null);
useEffect(() => {
loadSettings();
fetch('/api/auth/config')
@ -191,6 +204,11 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
setCheckmkSecretSet(data.checkmk_api_secret === SECRET_SENTINEL);
setCheckmkApiSecret('');
setCheckmkSyncInterval(data.checkmk_sync_interval_ms || '60000');
setSemaphoreEnabled(data.semaphore_enabled === 'true');
setSemaphoreApiUrl(data.semaphore_api_url || '');
setSemaphoreTokenSet(data.semaphore_api_token === SECRET_SENTINEL);
setSemaphoreApiToken('');
setSemaphoreProjectId(data.semaphore_project_id || '');
} catch {
setError('Network error loading settings.');
} finally {
@ -212,9 +230,13 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
checkmk_api_url: checkmkApiUrl,
checkmk_api_user: checkmkApiUser,
checkmk_sync_interval_ms: checkmkSyncInterval,
semaphore_enabled: semaphoreEnabled ? 'true' : 'false',
semaphore_api_url: semaphoreApiUrl,
semaphore_project_id: semaphoreProjectId,
};
if (azureClientSecret) payload.azure_client_secret = azureClientSecret;
if (checkmkApiSecret) payload.checkmk_api_secret = checkmkApiSecret;
if (semaphoreApiToken) payload.semaphore_api_token = semaphoreApiToken;
try {
const res = await authFetch('/api/settings', { method: 'PUT', body: JSON.stringify(payload) });
if (!res.ok) {
@ -225,8 +247,10 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
const data: RawSettings = await res.json();
setAzureSecretSet(data.azure_client_secret === SECRET_SENTINEL);
setCheckmkSecretSet(data.checkmk_api_secret === SECRET_SENTINEL);
setSemaphoreTokenSet(data.semaphore_api_token === SECRET_SENTINEL);
setAzureClientSecret('');
setCheckmkApiSecret('');
setSemaphoreApiToken('');
setSuccessMsg('Settings saved successfully.');
setTimeout(() => setSuccessMsg(''), 4000);
} catch {
@ -255,6 +279,25 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
}
}
async function testSemaphoreConnection() {
setSemaphoreTesting(true);
setSemaphoreTestResult(null);
try {
const res = await authFetch('/api/semaphore/templates');
if (!res.ok) {
const d = await res.json();
setSemaphoreTestResult(`Error: ${d.error || `HTTP ${res.status}`}`);
return;
}
const templates = await res.json() as any[];
setSemaphoreTestResult(`Connected — ${templates.length} task template${templates.length !== 1 ? 's' : ''} found.`);
} catch {
setSemaphoreTestResult('Error: Network error connecting to Semaphore.');
} finally {
setSemaphoreTesting(false);
}
}
function copyRedirectUri() {
if (!effectiveRedirectUri) return;
navigator.clipboard.writeText(effectiveRedirectUri).then(() => {
@ -495,6 +538,97 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
</div>
</SectionCard>
{/* ── Ansible Semaphore ── */}
<SectionCard accentColor="bg-gradient-to-r from-orange-600 to-amber-600">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-orange-950/60 border border-orange-900/40 rounded-xl">
<Terminal className="w-4 h-4 text-orange-400" />
</div>
<div>
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold text-white">Ansible Semaphore</h2>
{semaphoreEnabled && semaphoreApiUrl && semaphoreTokenSet && semaphoreProjectId && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-semibold bg-orange-950/60 border border-orange-900/50 text-orange-400">
<span className="w-1 h-1 rounded-full bg-orange-400 animate-pulse inline-block" />
ACTIVE
</span>
)}
</div>
<p className="text-[11px] text-slate-500 mt-0.5">Trigger playbooks automatically at booking start and end</p>
</div>
</div>
<div className="flex items-center gap-2.5">
<span className={`text-[10px] font-semibold font-mono ${semaphoreEnabled ? 'text-orange-400' : 'text-slate-600'}`}>
{semaphoreEnabled ? 'ENABLED' : 'DISABLED'}
</span>
<button
type="button"
onClick={() => setSemaphoreEnabled(v => !v)}
className={`relative w-10 h-5 rounded-full transition-all duration-200 focus:outline-none ${semaphoreEnabled ? 'bg-orange-600 shadow-[0_0_10px_rgba(234,88,12,0.4)]' : 'bg-slate-800 border border-slate-700'}`}
>
<span className={`absolute top-0.5 w-4 h-4 bg-white rounded-full shadow transition-all duration-200 ${semaphoreEnabled ? 'left-5' : 'left-0.5'}`} />
</button>
</div>
</div>
<div className="h-px bg-slate-800/60" />
<div className={`space-y-5 transition-opacity duration-200 ${!semaphoreEnabled ? 'opacity-40 pointer-events-none' : ''}`}>
<FieldRow label="API URL">
<Input
value={semaphoreApiUrl}
onChange={setSemaphoreApiUrl}
placeholder="https://semaphore.internal"
monospace
icon={<Globe className="w-3.5 h-3.5" />}
/>
</FieldRow>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
<FieldRow
label="API Token"
hint="Profile → API Tokens in Semaphore"
badge={semaphoreTokenSet ? <ConfiguredBadge /> : undefined}
>
<SecretInput
value={semaphoreApiToken}
onChange={setSemaphoreApiToken}
alreadySet={semaphoreTokenSet}
show={showSemaphoreToken}
onToggleShow={() => setShowSemaphoreToken(v => !v)}
/>
</FieldRow>
<FieldRow label="Project ID" hint="Numeric ID from the Semaphore project URL">
<Input
value={semaphoreProjectId}
onChange={setSemaphoreProjectId}
placeholder="1"
monospace
icon={<KeyRound className="w-3.5 h-3.5" />}
/>
</FieldRow>
</div>
{semaphoreApiUrl && semaphoreTokenSet && semaphoreProjectId && (
<div className="space-y-2">
<button
type="button"
onClick={testSemaphoreConnection}
disabled={semaphoreTesting}
className="flex items-center gap-2 bg-orange-950/60 hover:bg-orange-900/40 border border-orange-900/50 text-orange-400 text-xs font-semibold px-3 py-2 rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<RefreshCw className={`w-3.5 h-3.5 ${semaphoreTesting ? 'animate-spin' : ''}`} />
{semaphoreTesting ? 'Testing…' : 'Test connection'}
</button>
{semaphoreTestResult && (
<p className={`text-[11px] font-mono ${semaphoreTestResult.startsWith('Error') ? 'text-red-400' : 'text-emerald-400'}`}>
{semaphoreTestResult}
</p>
)}
</div>
)}
</div>
</SectionCard>
{/* Save bar */}
<div className="flex items-center justify-between pt-2 border-t border-slate-800/60">
<p className="text-[11px] text-slate-600">Changes are applied after saving and a server restart.</p>

View File

@ -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 {