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:
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user