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:
10
server-db.ts
10
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', 'checkMkUrl', "checkMkUrl TEXT NOT NULL DEFAULT ''");
|
||||||
ensureColumn('devices', 'cmkHostname', "cmkHostname 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)
|
// Seed default settings (INSERT OR IGNORE = only if key absent)
|
||||||
const _insertDefault = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)');
|
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_user', 'automation'],
|
||||||
['checkmk_api_secret', ''],
|
['checkmk_api_secret', ''],
|
||||||
['checkmk_sync_interval_ms', '60000'],
|
['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);
|
for (const [k, v] of _defaultSettings) _insertDefault.run(k, v);
|
||||||
|
|
||||||
|
|||||||
215
server.ts
215
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<string, string>): Record<string, string> {
|
function maskSettings(raw: Record<string, string>): Record<string, string> {
|
||||||
const out = { ...raw };
|
const out = { ...raw };
|
||||||
@ -247,7 +247,9 @@ async function startServer() {
|
|||||||
app.put('/api/settings', requireAuth, (req, res) => {
|
app.put('/api/settings', requireAuth, (req, res) => {
|
||||||
try {
|
try {
|
||||||
const allowed = ['azure_enabled', 'azure_client_id', 'azure_tenant_id', 'azure_client_secret',
|
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<string, string>;
|
const updates = req.body as Record<string, string>;
|
||||||
for (const key of allowed) {
|
for (const key of allowed) {
|
||||||
if (key in updates && updates[key] !== '__SET__') {
|
if (key in updates && updates[key] !== '__SET__') {
|
||||||
@ -409,7 +411,9 @@ async function startServer() {
|
|||||||
const labs: LabTemplate[] = rows.map(r => ({
|
const labs: LabTemplate[] = rows.map(r => ({
|
||||||
id: r.id, name: r.name, description: r.description,
|
id: r.id, name: r.name, description: r.description,
|
||||||
contactPerson: r.contactPerson, location: r.location,
|
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);
|
res.json(labs);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@ -419,14 +423,14 @@ async function startServer() {
|
|||||||
|
|
||||||
app.post('/api/labs', requireAuth, (req, res) => {
|
app.post('/api/labs', requireAuth, (req, res) => {
|
||||||
try {
|
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)) {
|
if (!name || !deviceIds || !Array.isArray(deviceIds)) {
|
||||||
return res.status(400).json({ error: 'Missing name or associated device configurations.' });
|
return res.status(400).json({ error: 'Missing name or associated device configurations.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = uid("lab");
|
const id = uid("lab");
|
||||||
db.prepare(`INSERT INTO labs (id, name, description, contactPerson, location, deviceIds, topology) VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
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 || []));
|
.run(id, name, description || '', contactPerson || '', location || '', JSON.stringify(deviceIds), JSON.stringify(topology || []), semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId || '');
|
||||||
|
|
||||||
const logId = uid("log");
|
const logId = uid("log");
|
||||||
db.prepare(`INSERT INTO logs (id, timestamp, type, message, userId) VALUES (?, ?, ?, ?, ?)`)
|
db.prepare(`INSERT INTO logs (id, timestamp, type, message, userId) VALUES (?, ?, ?, ?, ?)`)
|
||||||
@ -435,7 +439,7 @@ async function startServer() {
|
|||||||
req.user!.userId);
|
req.user!.userId);
|
||||||
|
|
||||||
const r = db.prepare('SELECT * FROM labs WHERE id = ?').get(id) as any;
|
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) {
|
} catch (err: any) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
}
|
}
|
||||||
@ -444,10 +448,10 @@ async function startServer() {
|
|||||||
app.put('/api/labs/:id', requireAuth, (req, res) => {
|
app.put('/api/labs/:id', requireAuth, (req, res) => {
|
||||||
try {
|
try {
|
||||||
const id = req.params.id;
|
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 = ?`)
|
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), id);
|
.run(name, description, contactPerson, location, JSON.stringify(deviceIds), JSON.stringify(topology), semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId || '', id);
|
||||||
|
|
||||||
const logId = uid("log");
|
const logId = uid("log");
|
||||||
db.prepare(`INSERT INTO logs (id, timestamp, type, message, userId) VALUES (?, ?, ?, ?, ?)`)
|
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);
|
`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;
|
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) {
|
} catch (err: any) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
}
|
}
|
||||||
@ -491,7 +495,11 @@ async function startServer() {
|
|||||||
id: r.id, labId: r.labId, userId: r.userId,
|
id: r.id, labId: r.labId, userId: r.userId,
|
||||||
startDateTime: r.startDateTime, endDateTime: r.endDateTime,
|
startDateTime: r.startDateTime, endDateTime: r.endDateTime,
|
||||||
notes: r.notes || '', status: r.status as any,
|
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);
|
res.json(bookings);
|
||||||
} catch (err: any) {
|
} 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 {
|
try {
|
||||||
const id = req.params.id;
|
const id = req.params.id;
|
||||||
const { status, operatorName } = req.body;
|
const { status, operatorName } = req.body;
|
||||||
@ -541,16 +549,38 @@ async function startServer() {
|
|||||||
db.prepare('UPDATE bookings SET status = ? WHERE id = ?').run(status, id);
|
db.prepare('UPDATE bookings SET status = ? WHERE id = ?').run(status, id);
|
||||||
|
|
||||||
if (status === 'cancelled') {
|
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");
|
const logId = uid("log");
|
||||||
db.prepare(`INSERT INTO logs (id, timestamp, type, message, userId) VALUES (?, ?, ?, ?, ?)`)
|
db.prepare(`INSERT INTO logs (id, timestamp, type, message, userId) VALUES (?, ?, ?, ?, ?)`)
|
||||||
.run(logId, new Date().toISOString(), 'booking',
|
.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.`,
|
`${operatorName || 'An operator'} canceled the reservation for lab template "${lab?.name || 'Unknown'}" and released associated nodes back into the pool.`,
|
||||||
req.user!.userId);
|
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;
|
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) {
|
} catch (err: any) {
|
||||||
res.status(500).json({ error: err.message });
|
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<string, string>): Promise<number | null> {
|
||||||
|
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', () => {
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
console.log(`[Server] Core Server running at http://0.0.0.0:${PORT}`);
|
console.log(`[Server] Core Server running at http://0.0.0.0:${PORT}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -5,9 +5,10 @@
|
|||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Booking, LabTemplate, Device, User } from '../types';
|
import { Booking, LabTemplate, Device, User } from '../types';
|
||||||
import {
|
import { authFetch } from '../lib/auth';
|
||||||
X, Calendar, Clock, UserIcon, Database, Terminal, Cpu, Play, Check,
|
import {
|
||||||
Trash2, Ban, Copy, CheckCircle, HelpCircle, HardDrive
|
X, Calendar, Clock, UserIcon, Database, Terminal, Cpu, Play, Check,
|
||||||
|
Trash2, Ban, Copy, CheckCircle, HelpCircle, HardDrive,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface BookingDetailsModalProps {
|
interface BookingDetailsModalProps {
|
||||||
@ -39,6 +40,30 @@ export default function BookingDetailsModal({
|
|||||||
// Find devices mapped to this booking
|
// Find devices mapped to this booking
|
||||||
const mappedDevices = devices.filter(d => lab?.deviceIds.includes(d.id));
|
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')
|
// Developer panel tabs ('rest', 'ansible', 'terminal')
|
||||||
const [activeTab, setActiveTab] = useState<'rest' | 'ansible' | 'terminal'>('ansible');
|
const [activeTab, setActiveTab] = useState<'rest' | 'ansible' | 'terminal'>('ansible');
|
||||||
const [isCopied, setIsCopied] = useState(false);
|
const [isCopied, setIsCopied] = useState(false);
|
||||||
@ -294,6 +319,67 @@ ${ipList.map(ip => ` - "${ip}"`).join('\n') || ' - "No registered targ
|
|||||||
|
|
||||||
</div>
|
</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 */}
|
{/* BELOW BLOCK: Restful API & Automation Integration developer panel */}
|
||||||
<div className="border border-slate-800 rounded-xl overflow-hidden bg-slate-950 font-mono">
|
<div className="border border-slate-800 rounded-xl overflow-hidden bg-slate-950 font-mono">
|
||||||
|
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import { LabTemplate, Device, TopologyLink } from '../types';
|
|||||||
import TopologyPanel from './TopologyPanel';
|
import TopologyPanel from './TopologyPanel';
|
||||||
import {
|
import {
|
||||||
Server, Plus, Edit3, Trash, User, MapPin,
|
Server, Plus, Edit3, Trash, User, MapPin,
|
||||||
Layers, ChevronRight, Save, X, Check, Pencil
|
Layers, ChevronRight, Save, X, Check, Pencil, Terminal,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface LabTemplatesProps {
|
interface LabTemplatesProps {
|
||||||
@ -47,12 +47,16 @@ export default function LabTemplates({
|
|||||||
contactPerson: string;
|
contactPerson: string;
|
||||||
location: string;
|
location: string;
|
||||||
deviceIds: string[];
|
deviceIds: string[];
|
||||||
|
semaphoreSetupTemplateId: string;
|
||||||
|
semaphoreTeardownTemplateId: string;
|
||||||
}>({
|
}>({
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
contactPerson: '',
|
contactPerson: '',
|
||||||
location: '',
|
location: '',
|
||||||
deviceIds: []
|
deviceIds: [],
|
||||||
|
semaphoreSetupTemplateId: '',
|
||||||
|
semaphoreTeardownTemplateId: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate filtered devices associated with selected lab
|
// Calculate filtered devices associated with selected lab
|
||||||
@ -68,7 +72,9 @@ export default function LabTemplates({
|
|||||||
description: '',
|
description: '',
|
||||||
contactPerson: '',
|
contactPerson: '',
|
||||||
location: '',
|
location: '',
|
||||||
deviceIds: []
|
deviceIds: [],
|
||||||
|
semaphoreSetupTemplateId: '',
|
||||||
|
semaphoreTeardownTemplateId: '',
|
||||||
});
|
});
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
};
|
};
|
||||||
@ -82,7 +88,9 @@ export default function LabTemplates({
|
|||||||
description: lab.description,
|
description: lab.description,
|
||||||
contactPerson: lab.contactPerson,
|
contactPerson: lab.contactPerson,
|
||||||
location: lab.location,
|
location: lab.location,
|
||||||
deviceIds: [...lab.deviceIds]
|
deviceIds: [...lab.deviceIds],
|
||||||
|
semaphoreSetupTemplateId: lab.semaphoreSetupTemplateId || '',
|
||||||
|
semaphoreTeardownTemplateId: lab.semaphoreTeardownTemplateId || '',
|
||||||
});
|
});
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
};
|
};
|
||||||
@ -126,7 +134,9 @@ export default function LabTemplates({
|
|||||||
contactPerson: formData.contactPerson,
|
contactPerson: formData.contactPerson,
|
||||||
location: formData.location,
|
location: formData.location,
|
||||||
deviceIds: formData.deviceIds,
|
deviceIds: formData.deviceIds,
|
||||||
topology: tempLinks
|
topology: tempLinks,
|
||||||
|
semaphoreSetupTemplateId: formData.semaphoreSetupTemplateId,
|
||||||
|
semaphoreTeardownTemplateId: formData.semaphoreTeardownTemplateId,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (formMode === 'add') {
|
if (formMode === 'add') {
|
||||||
@ -525,6 +535,39 @@ export default function LabTemplates({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
{/* Form submit handlers */}
|
||||||
<div className="pt-3 border-t border-slate-800 flex justify-end gap-2.5">
|
<div className="pt-3 border-t border-slate-800 flex justify-end gap-2.5">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { authFetch } from '../lib/auth';
|
|||||||
import { User } from '../types';
|
import { User } from '../types';
|
||||||
import {
|
import {
|
||||||
Shield, Activity, Save, CheckCircle, AlertCircle, Eye, EyeOff,
|
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';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const SECRET_SENTINEL = '__SET__';
|
const SECRET_SENTINEL = '__SET__';
|
||||||
@ -20,6 +20,10 @@ interface RawSettings {
|
|||||||
checkmk_api_user: string;
|
checkmk_api_user: string;
|
||||||
checkmk_api_secret: string;
|
checkmk_api_secret: string;
|
||||||
checkmk_sync_interval_ms: string;
|
checkmk_sync_interval_ms: string;
|
||||||
|
semaphore_enabled: string;
|
||||||
|
semaphore_api_url: string;
|
||||||
|
semaphore_api_token: string;
|
||||||
|
semaphore_project_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SettingsProps {
|
interface SettingsProps {
|
||||||
@ -158,6 +162,15 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
const [showCheckmkSecret, setShowCheckmkSecret] = useState(false);
|
const [showCheckmkSecret, setShowCheckmkSecret] = useState(false);
|
||||||
const [syncing, setSyncing] = 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(() => {
|
useEffect(() => {
|
||||||
loadSettings();
|
loadSettings();
|
||||||
fetch('/api/auth/config')
|
fetch('/api/auth/config')
|
||||||
@ -191,6 +204,11 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
setCheckmkSecretSet(data.checkmk_api_secret === SECRET_SENTINEL);
|
setCheckmkSecretSet(data.checkmk_api_secret === SECRET_SENTINEL);
|
||||||
setCheckmkApiSecret('');
|
setCheckmkApiSecret('');
|
||||||
setCheckmkSyncInterval(data.checkmk_sync_interval_ms || '60000');
|
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 {
|
} catch {
|
||||||
setError('Network error loading settings.');
|
setError('Network error loading settings.');
|
||||||
} finally {
|
} finally {
|
||||||
@ -212,9 +230,13 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
checkmk_api_url: checkmkApiUrl,
|
checkmk_api_url: checkmkApiUrl,
|
||||||
checkmk_api_user: checkmkApiUser,
|
checkmk_api_user: checkmkApiUser,
|
||||||
checkmk_sync_interval_ms: checkmkSyncInterval,
|
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 (azureClientSecret) payload.azure_client_secret = azureClientSecret;
|
||||||
if (checkmkApiSecret) payload.checkmk_api_secret = checkmkApiSecret;
|
if (checkmkApiSecret) payload.checkmk_api_secret = checkmkApiSecret;
|
||||||
|
if (semaphoreApiToken) payload.semaphore_api_token = semaphoreApiToken;
|
||||||
try {
|
try {
|
||||||
const res = await authFetch('/api/settings', { method: 'PUT', body: JSON.stringify(payload) });
|
const res = await authFetch('/api/settings', { method: 'PUT', body: JSON.stringify(payload) });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@ -225,8 +247,10 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
const data: RawSettings = await res.json();
|
const data: RawSettings = await res.json();
|
||||||
setAzureSecretSet(data.azure_client_secret === SECRET_SENTINEL);
|
setAzureSecretSet(data.azure_client_secret === SECRET_SENTINEL);
|
||||||
setCheckmkSecretSet(data.checkmk_api_secret === SECRET_SENTINEL);
|
setCheckmkSecretSet(data.checkmk_api_secret === SECRET_SENTINEL);
|
||||||
|
setSemaphoreTokenSet(data.semaphore_api_token === SECRET_SENTINEL);
|
||||||
setAzureClientSecret('');
|
setAzureClientSecret('');
|
||||||
setCheckmkApiSecret('');
|
setCheckmkApiSecret('');
|
||||||
|
setSemaphoreApiToken('');
|
||||||
setSuccessMsg('Settings saved successfully.');
|
setSuccessMsg('Settings saved successfully.');
|
||||||
setTimeout(() => setSuccessMsg(''), 4000);
|
setTimeout(() => setSuccessMsg(''), 4000);
|
||||||
} catch {
|
} 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() {
|
function copyRedirectUri() {
|
||||||
if (!effectiveRedirectUri) return;
|
if (!effectiveRedirectUri) return;
|
||||||
navigator.clipboard.writeText(effectiveRedirectUri).then(() => {
|
navigator.clipboard.writeText(effectiveRedirectUri).then(() => {
|
||||||
@ -495,6 +538,97 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</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 */}
|
{/* Save bar */}
|
||||||
<div className="flex items-center justify-between pt-2 border-t border-slate-800/60">
|
<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>
|
<p className="text-[11px] text-slate-600">Changes are applied after saving and a server restart.</p>
|
||||||
|
|||||||
@ -34,6 +34,8 @@ export interface LabTemplate {
|
|||||||
location: string;
|
location: string;
|
||||||
deviceIds: string[];
|
deviceIds: string[];
|
||||||
topology: TopologyLink[];
|
topology: TopologyLink[];
|
||||||
|
semaphoreSetupTemplateId?: string;
|
||||||
|
semaphoreTeardownTemplateId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Booking {
|
export interface Booking {
|
||||||
@ -46,6 +48,10 @@ export interface Booking {
|
|||||||
status: 'active' | 'upcoming' | 'completed' | 'cancelled';
|
status: 'active' | 'upcoming' | 'completed' | 'cancelled';
|
||||||
notified: boolean;
|
notified: boolean;
|
||||||
emailSent?: boolean;
|
emailSent?: boolean;
|
||||||
|
ansibleSetupTriggered?: boolean;
|
||||||
|
ansibleTeardownTriggered?: boolean;
|
||||||
|
ansibleSetupJobId?: string;
|
||||||
|
ansibleTeardownJobId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LogEntry {
|
export interface LogEntry {
|
||||||
|
|||||||
Reference in New Issue
Block a user