- Rollensystem: Public → Mitarbeiter → Admin (Rolle user entfällt) - DB-Migration: must_change_password-Spalte, user→mitarbeiter - require_staff_api(): erlaubt mitarbeiter + admin - POST /api/admin/invite: Einladung mit auto-generiertem Passwort + E-Mail - POST /auth/change-password: Pflicht-Passwortwechsel - Login: force_password_change-Redirect - Sidebar: sidebar-staff für Scheduler/NINA/Einstellungen - Scheduler/NINA: read-only für Mitarbeiter Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
232 lines
9.2 KiB
JavaScript
232 lines
9.2 KiB
JavaScript
const jobsTable = document.getElementById('jobsTable');
|
|
const jobModal = new bootstrap.Modal(document.getElementById('jobModal'));
|
|
let currentUser = null;
|
|
let jobs = [];
|
|
let editMode = false;
|
|
|
|
initPage({ onAuth: (user) => {
|
|
currentUser = user;
|
|
if (!user || user.role !== 'admin') {
|
|
const btn = document.getElementById('btnAddJob');
|
|
btn.disabled = true;
|
|
btn.title = 'Nur Lesezugriff';
|
|
}
|
|
loadJobs();
|
|
} });
|
|
|
|
// Template variables available in message jobs
|
|
const MSG_VARS = [
|
|
{ key: '{time}', label: '{time}', desc: 'Uhrzeit (HH:MM)' },
|
|
{ key: '{date}', label: '{date}', desc: 'Datum (TT.MM.JJJJ)' },
|
|
{ key: '{datetime}', label: '{datetime}', desc: 'Datum + Uhrzeit' },
|
|
{ key: '{weekday}', label: '{weekday}', desc: 'Wochentag (Montag…)' },
|
|
{ key: '{nodes}', label: '{nodes}', desc: 'Anzahl Nodes gesamt' },
|
|
{ key: '{nodes_24h}', label: '{nodes_24h}', desc: 'Aktive Nodes (24h)' },
|
|
{ key: '{nodes_online}', label: '{nodes_online}', desc: 'Nodes online (< 15 Min)' },
|
|
{ key: '{version}', label: '{version}', desc: 'Bot-Version' },
|
|
];
|
|
|
|
// Type toggle label + variable hints
|
|
function updateCommandLabel() {
|
|
const isMsg = document.getElementById('jobType').value === 'message';
|
|
document.getElementById('jobCommandLabel').textContent = isMsg ? 'Nachricht' : 'Kommando';
|
|
document.getElementById('jobCommand').placeholder = isMsg ? 'Hallo Mesh! Heute ist {weekday}, {time} Uhr.' : '/weather';
|
|
const hints = document.getElementById('varHints');
|
|
const badges = document.getElementById('varBadges');
|
|
if (isMsg) {
|
|
badges.innerHTML = MSG_VARS.map(v =>
|
|
`<span class="badge bg-secondary-subtle text-secondary-emphasis border border-secondary-subtle me-1 mb-1" role="button" title="${escapeHtml(v.desc)}" data-var="${escapeHtml(v.key)}" style="cursor:pointer;font-size:.7rem">${escapeHtml(v.label)}</span>`
|
|
).join('');
|
|
hints.classList.remove('d-none');
|
|
} else {
|
|
hints.classList.add('d-none');
|
|
}
|
|
}
|
|
|
|
document.getElementById('varBadges').addEventListener('click', (e) => {
|
|
const badge = e.target.closest('[data-var]');
|
|
if (!badge) return;
|
|
const input = document.getElementById('jobCommand');
|
|
const start = input.selectionStart;
|
|
const end = input.selectionEnd;
|
|
const val = input.value;
|
|
input.value = val.slice(0, start) + badge.dataset.var + val.slice(end);
|
|
const pos = start + badge.dataset.var.length;
|
|
input.setSelectionRange(pos, pos);
|
|
input.focus();
|
|
});
|
|
|
|
document.getElementById('jobType').addEventListener('change', updateCommandLabel);
|
|
|
|
// WebSocket for live updates
|
|
function connectWebSocket() {
|
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const ws = new WebSocket(`${proto}//${location.host}/ws`);
|
|
|
|
ws.onmessage = (event) => {
|
|
const msg = JSON.parse(event.data);
|
|
if (msg.type === 'scheduler_update') {
|
|
jobs = msg.data;
|
|
renderJobs();
|
|
}
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
setTimeout(connectWebSocket, 3000);
|
|
};
|
|
|
|
ws.onerror = () => { ws.close(); };
|
|
}
|
|
|
|
// Load jobs
|
|
async function loadJobs() {
|
|
try {
|
|
const resp = await fetch('/api/scheduler/jobs');
|
|
jobs = await resp.json();
|
|
renderJobs();
|
|
} catch (e) {
|
|
jobsTable.innerHTML = '<tr><td colspan="7" class="text-center text-danger py-3">Fehler beim Laden</td></tr>';
|
|
}
|
|
}
|
|
|
|
function renderJobs() {
|
|
if (jobs.length === 0) {
|
|
jobsTable.innerHTML = '<tr><td colspan="8" class="text-center text-body-secondary py-3">Keine Jobs konfiguriert</td></tr>';
|
|
return;
|
|
}
|
|
const isAdmin = currentUser && currentUser.role === 'admin';
|
|
jobsTable.innerHTML = jobs.map(job => {
|
|
const statusClass = job.enabled ? 'text-success' : 'text-body-secondary';
|
|
const statusIcon = job.enabled ? 'bi-check-circle-fill' : 'bi-pause-circle';
|
|
const statusText = job.enabled ? 'Aktiv' : 'Inaktiv';
|
|
const jobType = job.type === 'message' ? 'Nachricht' : 'Kommando';
|
|
const typeBadge = job.type === 'message' ? 'bg-warning text-dark' : 'bg-info text-white';
|
|
const statusCell = isAdmin
|
|
? `<span class="${statusClass} cursor-pointer" role="button" onclick="toggleJob('${escapeHtml(job.name)}')"><i class="bi ${statusIcon} me-1"></i>${statusText}</span>`
|
|
: `<span class="${statusClass}"><i class="bi ${statusIcon} me-1"></i>${statusText}</span>`;
|
|
const actions = isAdmin
|
|
? `<button class="btn btn-outline-warning btn-sm py-0 px-1 me-1" onclick="editJob('${escapeHtml(job.name)}')" title="Bearbeiten"><i class="bi bi-pencil"></i></button>
|
|
<button class="btn btn-outline-danger btn-sm py-0 px-1" onclick="deleteJob('${escapeHtml(job.name)}')" title="Loeschen"><i class="bi bi-trash"></i></button>`
|
|
: `<span class="badge bg-secondary-subtle text-secondary-emphasis" style="font-size:.65rem">Nur Lesezugriff</span>`;
|
|
return `<tr>
|
|
<td class="fw-semibold">${escapeHtml(job.name || '')}</td>
|
|
<td class="text-body-secondary">${escapeHtml(job.description || '')}</td>
|
|
<td><span class="badge ${typeBadge} bg-opacity-75">${jobType}</span></td>
|
|
<td><code>${escapeHtml(job.command || '')}</code></td>
|
|
<td><code>${escapeHtml(job.cron || '')}</code></td>
|
|
<td class="text-center">${job.channel != null ? job.channel : 0}</td>
|
|
<td class="text-center">${statusCell}</td>
|
|
<td class="text-end">${actions}</td>
|
|
</tr>`;
|
|
}).join('');
|
|
}
|
|
|
|
async function toggleJob(name) {
|
|
const job = jobs.find(j => j.name === name);
|
|
if (!job) return;
|
|
try {
|
|
const resp = await fetch(`/api/scheduler/jobs/${encodeURIComponent(name)}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ enabled: !job.enabled })
|
|
});
|
|
if (resp.ok) {
|
|
jobs = await resp.json();
|
|
renderJobs();
|
|
}
|
|
} catch (e) {
|
|
console.error('Toggle failed:', e);
|
|
}
|
|
}
|
|
|
|
function editJob(name) {
|
|
const job = jobs.find(j => j.name === name);
|
|
if (!job) return;
|
|
editMode = true;
|
|
document.getElementById('jobModalTitle').textContent = 'Job bearbeiten';
|
|
document.getElementById('jobOriginalName').value = job.name;
|
|
document.getElementById('jobName').value = job.name;
|
|
document.getElementById('jobDescription').value = job.description || '';
|
|
document.getElementById('jobType').value = job.type || 'command';
|
|
updateCommandLabel();
|
|
document.getElementById('jobCommand').value = job.command || '';
|
|
document.getElementById('jobCron').value = job.cron || '';
|
|
document.getElementById('jobChannel').value = job.channel != null ? job.channel : 0;
|
|
document.getElementById('jobEnabled').checked = !!job.enabled;
|
|
jobModal.show();
|
|
}
|
|
|
|
async function deleteJob(name) {
|
|
if (!confirm(`Job "${name}" wirklich loeschen?`)) return;
|
|
try {
|
|
const resp = await fetch(`/api/scheduler/jobs/${encodeURIComponent(name)}`, {
|
|
method: 'DELETE'
|
|
});
|
|
if (resp.ok) {
|
|
jobs = await resp.json();
|
|
renderJobs();
|
|
}
|
|
} catch (e) {
|
|
console.error('Delete failed:', e);
|
|
}
|
|
}
|
|
|
|
// New job button
|
|
document.getElementById('btnAddJob').addEventListener('click', () => {
|
|
editMode = false;
|
|
document.getElementById('jobModalTitle').textContent = 'Neuer Job';
|
|
document.getElementById('jobOriginalName').value = '';
|
|
document.getElementById('jobForm').reset();
|
|
document.getElementById('jobType').value = 'command';
|
|
updateCommandLabel();
|
|
document.getElementById('jobChannel').value = 0;
|
|
jobModal.show();
|
|
});
|
|
|
|
// Save job
|
|
document.getElementById('btnSaveJob').addEventListener('click', async () => {
|
|
const form = document.getElementById('jobForm');
|
|
if (!form.checkValidity()) {
|
|
form.reportValidity();
|
|
return;
|
|
}
|
|
|
|
const jobData = {
|
|
name: document.getElementById('jobName').value.trim(),
|
|
description: document.getElementById('jobDescription').value.trim(),
|
|
type: document.getElementById('jobType').value,
|
|
command: document.getElementById('jobCommand').value.trim(),
|
|
cron: document.getElementById('jobCron').value.trim(),
|
|
channel: parseInt(document.getElementById('jobChannel').value) || 0,
|
|
enabled: document.getElementById('jobEnabled').checked
|
|
};
|
|
|
|
try {
|
|
let resp;
|
|
if (editMode) {
|
|
const originalName = document.getElementById('jobOriginalName').value;
|
|
resp = await fetch(`/api/scheduler/jobs/${encodeURIComponent(originalName)}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(jobData)
|
|
});
|
|
} else {
|
|
resp = await fetch('/api/scheduler/jobs', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(jobData)
|
|
});
|
|
}
|
|
|
|
if (resp.ok) {
|
|
jobs = await resp.json();
|
|
renderJobs();
|
|
jobModal.hide();
|
|
}
|
|
} catch (e) {
|
|
console.error('Save failed:', e);
|
|
}
|
|
});
|
|
|
|
connectWebSocket();
|