A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1711 lines
69 KiB
Python
1711 lines
69 KiB
Python
"""
|
|
Admin Panel JavaScript.
|
|
|
|
Enthält die JavaScript-Logik für das Admin Panel:
|
|
- Modal handling
|
|
- Tab navigation
|
|
- Document management (CRUD)
|
|
- Version management
|
|
- Approval workflow
|
|
"""
|
|
|
|
|
|
def get_admin_panel_js() -> str:
|
|
"""JavaScript für Admin Panel zurückgeben"""
|
|
return """
|
|
const adminModal = document.getElementById('admin-modal');
|
|
const adminModalClose = document.getElementById('admin-modal-close');
|
|
const adminTabs = document.querySelectorAll('.admin-tab');
|
|
const adminContents = document.querySelectorAll('.admin-content');
|
|
const btnAdmin = document.getElementById('btn-admin');
|
|
|
|
// Admin data cache
|
|
let adminDocuments = [];
|
|
let adminCookieCategories = [];
|
|
|
|
// Open admin modal
|
|
btnAdmin?.addEventListener('click', async () => {
|
|
adminModal.classList.add('active');
|
|
await loadAdminDocuments();
|
|
await loadAdminCookieCategories();
|
|
populateDocumentSelect();
|
|
});
|
|
|
|
// Close admin modal
|
|
adminModalClose?.addEventListener('click', () => {
|
|
adminModal.classList.remove('active');
|
|
});
|
|
|
|
// Close on background click
|
|
adminModal?.addEventListener('click', (e) => {
|
|
if (e.target === adminModal) {
|
|
adminModal.classList.remove('active');
|
|
}
|
|
});
|
|
|
|
// Admin tab switching
|
|
adminTabs.forEach(tab => {
|
|
tab.addEventListener('click', () => {
|
|
const tabId = tab.dataset.tab;
|
|
adminTabs.forEach(t => t.classList.remove('active'));
|
|
adminContents.forEach(c => c.classList.remove('active'));
|
|
tab.classList.add('active');
|
|
|
|
// Handle system-info tab specially
|
|
if (tabId === 'system-info') {
|
|
document.getElementById('admin-content-system-info')?.classList.add('active');
|
|
} else {
|
|
document.getElementById(`admin-${tabId}`)?.classList.add('active');
|
|
}
|
|
|
|
// Load stats when stats tab is clicked
|
|
if (tabId === 'stats') {
|
|
loadAdminStats();
|
|
}
|
|
});
|
|
});
|
|
|
|
// ==========================================
|
|
// SYSTEM INFO TAB MANAGEMENT
|
|
// ==========================================
|
|
function switchSystemInfoTab(tabName) {
|
|
// Remove active class from all tabs and content
|
|
document.querySelectorAll('.system-info-tab').forEach(t => t.classList.remove('active'));
|
|
document.querySelectorAll('.system-info-content').forEach(c => c.classList.remove('active'));
|
|
|
|
// Add active class to clicked tab
|
|
const clickedTab = event.target;
|
|
clickedTab.classList.add('active');
|
|
|
|
// Show corresponding content
|
|
const contentId = `system-info-${tabName}`;
|
|
document.getElementById(contentId)?.classList.add('active');
|
|
}
|
|
|
|
// ==========================================
|
|
// DOCUMENTS MANAGEMENT
|
|
// ==========================================
|
|
async function loadAdminDocuments() {
|
|
const container = document.getElementById('admin-doc-table-container');
|
|
container.innerHTML = '<div class="admin-loading">Lade Dokumente...</div>';
|
|
|
|
try {
|
|
const res = await fetch('/api/consent/admin/documents');
|
|
if (!res.ok) throw new Error('Failed to load');
|
|
const data = await res.json();
|
|
adminDocuments = data.documents || [];
|
|
renderDocumentsTable();
|
|
} catch(e) {
|
|
container.innerHTML = '<div class="admin-empty">Fehler beim Laden der Dokumente.</div>';
|
|
}
|
|
}
|
|
|
|
function renderDocumentsTable() {
|
|
const container = document.getElementById('admin-doc-table-container');
|
|
|
|
// Alle Dokumente anzeigen
|
|
const allDocs = adminDocuments;
|
|
|
|
if (allDocs.length === 0) {
|
|
container.innerHTML = '<div class="admin-empty">Keine Dokumente vorhanden. Klicken Sie auf "+ Neues Dokument" um ein Dokument zu erstellen.</div>';
|
|
return;
|
|
}
|
|
|
|
const typeLabels = {
|
|
'terms': 'AGB',
|
|
'privacy': 'Datenschutz',
|
|
'cookies': 'Cookies',
|
|
'community': 'Community',
|
|
'imprint': 'Impressum'
|
|
};
|
|
|
|
const html = `
|
|
<table class="admin-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Typ</th>
|
|
<th>Name</th>
|
|
<th>Beschreibung</th>
|
|
<th>Status</th>
|
|
<th>Aktionen</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${allDocs.map(doc => `
|
|
<tr>
|
|
<td><span class="admin-badge admin-badge-${doc.is_mandatory ? 'mandatory' : 'optional'}">${typeLabels[doc.type] || doc.type}</span></td>
|
|
<td>${doc.name}</td>
|
|
<td style="color: var(--bp-text-muted); font-size: 12px;">${doc.description || '-'}</td>
|
|
<td>
|
|
${doc.is_active ? '<span class="admin-badge admin-badge-published">Aktiv</span>' : '<span class="admin-badge admin-badge-draft">Inaktiv</span>'}
|
|
${doc.is_mandatory ? '<span class="admin-badge admin-badge-mandatory" style="margin-left: 4px;">Pflicht</span>' : ''}
|
|
</td>
|
|
<td class="admin-actions">
|
|
<button class="admin-btn admin-btn-secondary" onclick="editDocument('${doc.id}')" title="Bearbeiten">✏️</button>
|
|
<button class="admin-btn admin-btn-primary" onclick="goToVersions('${doc.id}')">Versionen</button>
|
|
</td>
|
|
</tr>
|
|
`).join('')}
|
|
</tbody>
|
|
</table>
|
|
`;
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
function goToVersions(docId) {
|
|
// Wechsle zum Versionen-Tab und wähle das Dokument aus
|
|
const versionsTab = document.querySelector('.admin-tab[data-tab="versions"]');
|
|
if (versionsTab) {
|
|
versionsTab.click();
|
|
setTimeout(() => {
|
|
const select = document.getElementById('admin-version-doc-select');
|
|
if (select) {
|
|
select.value = docId;
|
|
loadVersionsForDocument();
|
|
}
|
|
}, 100);
|
|
}
|
|
}
|
|
|
|
function showDocumentForm(doc = null) {
|
|
const form = document.getElementById('admin-document-form');
|
|
const title = document.getElementById('admin-document-form-title');
|
|
|
|
if (doc) {
|
|
title.textContent = 'Dokument bearbeiten';
|
|
document.getElementById('admin-document-id').value = doc.id;
|
|
document.getElementById('admin-document-type').value = doc.type;
|
|
document.getElementById('admin-document-name').value = doc.name;
|
|
document.getElementById('admin-document-description').value = doc.description || '';
|
|
document.getElementById('admin-document-mandatory').checked = doc.is_mandatory;
|
|
} else {
|
|
title.textContent = 'Neues Dokument erstellen';
|
|
document.getElementById('admin-document-id').value = '';
|
|
document.getElementById('admin-document-type').value = '';
|
|
document.getElementById('admin-document-name').value = '';
|
|
document.getElementById('admin-document-description').value = '';
|
|
document.getElementById('admin-document-mandatory').checked = true;
|
|
}
|
|
|
|
form.style.display = 'block';
|
|
}
|
|
|
|
function hideDocumentForm() {
|
|
document.getElementById('admin-document-form').style.display = 'none';
|
|
}
|
|
|
|
function editDocument(docId) {
|
|
const doc = adminDocuments.find(d => d.id === docId);
|
|
if (doc) showDocumentForm(doc);
|
|
}
|
|
|
|
async function saveDocument() {
|
|
const docId = document.getElementById('admin-document-id').value;
|
|
const docType = document.getElementById('admin-document-type').value;
|
|
const docName = document.getElementById('admin-document-name').value;
|
|
|
|
if (!docType || !docName) {
|
|
alert('Bitte füllen Sie alle Pflichtfelder aus (Typ und Name).');
|
|
return;
|
|
}
|
|
|
|
const data = {
|
|
type: docType,
|
|
name: docName,
|
|
description: document.getElementById('admin-document-description').value || null,
|
|
is_mandatory: document.getElementById('admin-document-mandatory').checked
|
|
};
|
|
|
|
try {
|
|
const url = docId ? `/api/consent/admin/documents/${docId}` : '/api/consent/admin/documents';
|
|
const method = docId ? 'PUT' : 'POST';
|
|
|
|
const res = await fetch(url, {
|
|
method,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(data)
|
|
});
|
|
|
|
if (!res.ok) throw new Error('Failed to save');
|
|
|
|
hideDocumentForm();
|
|
await loadAdminDocuments();
|
|
populateDocumentSelect();
|
|
alert('Dokument gespeichert!');
|
|
} catch(e) {
|
|
alert('Fehler beim Speichern: ' + e.message);
|
|
}
|
|
}
|
|
|
|
async function deleteDocument(docId) {
|
|
if (!confirm('Dokument wirklich deaktivieren?')) return;
|
|
|
|
try {
|
|
const res = await fetch(`/api/consent/admin/documents/${docId}`, { method: 'DELETE' });
|
|
if (!res.ok) throw new Error('Failed to delete');
|
|
|
|
await loadAdminDocuments();
|
|
populateDocumentSelect();
|
|
alert('Dokument deaktiviert!');
|
|
} catch(e) {
|
|
alert('Fehler: ' + e.message);
|
|
}
|
|
}
|
|
|
|
// ==========================================
|
|
// VERSIONS MANAGEMENT
|
|
// ==========================================
|
|
function populateDocumentSelect() {
|
|
const select = document.getElementById('admin-version-doc-select');
|
|
const uniqueDocs = [...new Map(adminDocuments.map(d => [d.type, d])).values()];
|
|
|
|
select.innerHTML = '<option value="">-- Dokument auswählen --</option>' +
|
|
adminDocuments.filter(d => d.is_active).map(doc =>
|
|
`<option value="${doc.id}">${doc.name} (${doc.type})</option>`
|
|
).join('');
|
|
}
|
|
|
|
async function loadVersionsForDocument() {
|
|
const docId = document.getElementById('admin-version-doc-select').value;
|
|
const container = document.getElementById('admin-version-table-container');
|
|
const btnNew = document.getElementById('btn-new-version');
|
|
|
|
if (!docId) {
|
|
container.innerHTML = '<div class="admin-empty">Wählen Sie ein Dokument aus.</div>';
|
|
btnNew.disabled = true;
|
|
return;
|
|
}
|
|
|
|
btnNew.disabled = false;
|
|
container.innerHTML = '<div class="admin-loading">Lade Versionen...</div>';
|
|
|
|
try {
|
|
const res = await fetch(`/api/consent/admin/documents/${docId}/versions`);
|
|
if (!res.ok) throw new Error('Failed to load');
|
|
const data = await res.json();
|
|
renderVersionsTable(data.versions || []);
|
|
} catch(e) {
|
|
container.innerHTML = '<div class="admin-empty">Fehler beim Laden der Versionen.</div>';
|
|
}
|
|
}
|
|
|
|
function renderVersionsTable(versions) {
|
|
const container = document.getElementById('admin-version-table-container');
|
|
if (versions.length === 0) {
|
|
container.innerHTML = '<div class="admin-empty">Keine Versionen vorhanden.</div>';
|
|
return;
|
|
}
|
|
|
|
const getStatusBadge = (status) => {
|
|
const statusLabels = {
|
|
'draft': 'Entwurf',
|
|
'review': 'In Prüfung',
|
|
'approved': 'Genehmigt',
|
|
'rejected': 'Abgelehnt',
|
|
'scheduled': 'Geplant',
|
|
'published': 'Veröffentlicht',
|
|
'archived': 'Archiviert'
|
|
};
|
|
return statusLabels[status] || status;
|
|
};
|
|
|
|
const formatScheduledDate = (isoDate) => {
|
|
if (!isoDate) return '';
|
|
const date = new Date(isoDate);
|
|
return date.toLocaleString('de-DE', {
|
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
|
hour: '2-digit', minute: '2-digit'
|
|
});
|
|
};
|
|
|
|
const html = `
|
|
<table class="admin-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Version</th>
|
|
<th>Sprache</th>
|
|
<th>Titel</th>
|
|
<th>Status</th>
|
|
<th>Aktionen</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${versions.map(v => `
|
|
<tr>
|
|
<td>${v.version}</td>
|
|
<td>${v.language.toUpperCase()}</td>
|
|
<td>${v.title}</td>
|
|
<td>
|
|
<span class="admin-badge admin-badge-${v.status}">${getStatusBadge(v.status)}</span>
|
|
${v.scheduled_publish_at ? `<br><small>Geplant: ${formatScheduledDate(v.scheduled_publish_at)}</small>` : ''}
|
|
</td>
|
|
<td class="admin-actions">
|
|
${v.status === 'draft' ? `
|
|
<button class="admin-btn admin-btn-edit" onclick="editVersion('${v.id}')">Bearbeiten</button>
|
|
<button class="admin-btn admin-btn-primary" onclick="submitForReview('${v.id}')">Zur Prüfung</button>
|
|
<button class="admin-btn admin-btn-delete" onclick="deleteVersion('${v.id}')" title="Version dauerhaft löschen">🗑️</button>
|
|
` : ''}
|
|
${v.status === 'review' ? `
|
|
<button class="admin-btn admin-btn-edit" onclick="showCompareView('${v.id}')">Vergleichen</button>
|
|
<button class="admin-btn admin-btn-primary" onclick="showApprovalDialog('${v.id}')">Genehmigen</button>
|
|
<button class="admin-btn admin-btn-delete" onclick="rejectVersion('${v.id}')">Ablehnen</button>
|
|
` : ''}
|
|
${v.status === 'rejected' ? `
|
|
<button class="admin-btn admin-btn-edit" onclick="editVersion('${v.id}')">Bearbeiten</button>
|
|
<button class="admin-btn admin-btn-edit" onclick="showCompareView('${v.id}')">Vergleichen</button>
|
|
<button class="admin-btn admin-btn-delete" onclick="deleteVersion('${v.id}')" title="Version dauerhaft löschen">🗑️</button>
|
|
` : ''}
|
|
${v.status === 'scheduled' ? `
|
|
<button class="admin-btn admin-btn-edit" onclick="showCompareView('${v.id}')">Vergleichen</button>
|
|
<span class="admin-info-text">Wartet auf Veröffentlichung</span>
|
|
` : ''}
|
|
${v.status === 'approved' ? `
|
|
<button class="admin-btn admin-btn-edit" onclick="showCompareView('${v.id}')">Vergleichen</button>
|
|
<button class="admin-btn admin-btn-publish" onclick="publishVersion('${v.id}')">Sofort veröffentlichen</button>
|
|
<button class="admin-btn admin-btn-delete" onclick="rejectVersion('${v.id}')">Zurücksetzen</button>
|
|
` : ''}
|
|
${v.status === 'published' ? `
|
|
<button class="admin-btn admin-btn-delete" onclick="archiveVersion('${v.id}')">Archivieren</button>
|
|
` : ''}
|
|
<button class="admin-btn" onclick="showApprovalHistory('${v.id}')">Historie</button>
|
|
</td>
|
|
</tr>
|
|
`).join('')}
|
|
</tbody>
|
|
</table>
|
|
`;
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
function showVersionForm() {
|
|
const form = document.getElementById('admin-version-form');
|
|
document.getElementById('admin-version-id').value = '';
|
|
document.getElementById('admin-version-number').value = '';
|
|
document.getElementById('admin-version-lang').value = 'de';
|
|
document.getElementById('admin-version-title').value = '';
|
|
document.getElementById('admin-version-summary').value = '';
|
|
document.getElementById('admin-version-content').value = '';
|
|
// Clear rich text editor
|
|
const editor = document.getElementById('admin-version-editor');
|
|
if (editor) {
|
|
editor.innerHTML = '';
|
|
document.getElementById('editor-char-count').textContent = '0 Zeichen';
|
|
}
|
|
form.classList.add('active');
|
|
}
|
|
|
|
function hideVersionForm() {
|
|
document.getElementById('admin-version-form').classList.remove('active');
|
|
}
|
|
|
|
async function editVersion(versionId) {
|
|
// Lade die Version und fülle das Formular
|
|
const docId = document.getElementById('admin-version-doc-select').value;
|
|
|
|
try {
|
|
const res = await fetch(`/api/consent/admin/documents/${docId}/versions`);
|
|
if (!res.ok) throw new Error('Failed to load versions');
|
|
const data = await res.json();
|
|
|
|
const version = (data.versions || []).find(v => v.id === versionId);
|
|
if (!version) {
|
|
alert('Version nicht gefunden');
|
|
return;
|
|
}
|
|
|
|
// Formular öffnen und Daten einfügen
|
|
const form = document.getElementById('admin-version-form');
|
|
document.getElementById('admin-version-id').value = version.id;
|
|
document.getElementById('admin-version-number').value = version.version;
|
|
document.getElementById('admin-version-lang').value = version.language;
|
|
document.getElementById('admin-version-title').value = version.title;
|
|
document.getElementById('admin-version-summary').value = version.summary || '';
|
|
|
|
// Rich-Text-Editor mit Inhalt füllen
|
|
const editor = document.getElementById('admin-version-editor');
|
|
if (editor) {
|
|
editor.innerHTML = version.content || '';
|
|
const charCount = editor.textContent.length;
|
|
document.getElementById('editor-char-count').textContent = charCount + ' Zeichen';
|
|
}
|
|
document.getElementById('admin-version-content').value = version.content || '';
|
|
|
|
form.classList.add('active');
|
|
} catch(e) {
|
|
alert('Fehler beim Laden der Version: ' + e.message);
|
|
}
|
|
}
|
|
|
|
async function saveVersion() {
|
|
const docId = document.getElementById('admin-version-doc-select').value;
|
|
const versionId = document.getElementById('admin-version-id').value;
|
|
|
|
// Get content from rich text editor
|
|
const editor = document.getElementById('admin-version-editor');
|
|
const content = editor ? editor.innerHTML : document.getElementById('admin-version-content').value;
|
|
|
|
const data = {
|
|
document_id: docId,
|
|
version: document.getElementById('admin-version-number').value,
|
|
language: document.getElementById('admin-version-lang').value,
|
|
title: document.getElementById('admin-version-title').value,
|
|
summary: document.getElementById('admin-version-summary').value,
|
|
content: content
|
|
};
|
|
|
|
try {
|
|
const url = versionId ? `/api/consent/admin/versions/${versionId}` : '/api/consent/admin/versions';
|
|
const method = versionId ? 'PUT' : 'POST';
|
|
|
|
const res = await fetch(url, {
|
|
method,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(data)
|
|
});
|
|
|
|
if (!res.ok) throw new Error('Failed to save');
|
|
|
|
hideVersionForm();
|
|
await loadVersionsForDocument();
|
|
alert('Version gespeichert!');
|
|
} catch(e) {
|
|
alert('Fehler beim Speichern: ' + e.message);
|
|
}
|
|
}
|
|
|
|
async function publishVersion(versionId) {
|
|
if (!confirm('Version wirklich veröffentlichen?')) return;
|
|
|
|
try {
|
|
const res = await fetch(`/api/consent/admin/versions/${versionId}/publish`, { method: 'POST' });
|
|
if (!res.ok) throw new Error('Failed to publish');
|
|
|
|
await loadVersionsForDocument();
|
|
alert('Version veröffentlicht!');
|
|
} catch(e) {
|
|
alert('Fehler: ' + e.message);
|
|
}
|
|
}
|
|
|
|
async function archiveVersion(versionId) {
|
|
if (!confirm('Version wirklich archivieren?')) return;
|
|
|
|
try {
|
|
const res = await fetch(`/api/consent/admin/versions/${versionId}/archive`, { method: 'POST' });
|
|
if (!res.ok) throw new Error('Failed to archive');
|
|
|
|
await loadVersionsForDocument();
|
|
alert('Version archiviert!');
|
|
} catch(e) {
|
|
alert('Fehler: ' + e.message);
|
|
}
|
|
}
|
|
|
|
async function deleteVersion(versionId) {
|
|
if (!confirm('Version wirklich dauerhaft löschen?\\n\\nDie Versionsnummer wird wieder frei und kann erneut verwendet werden.\\n\\nDiese Aktion kann nicht rückgängig gemacht werden!')) return;
|
|
|
|
try {
|
|
const res = await fetch(`/api/consent/admin/versions/${versionId}`, { method: 'DELETE' });
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
throw new Error(err.detail?.message || err.error || 'Löschen fehlgeschlagen');
|
|
}
|
|
|
|
await loadVersionsForDocument();
|
|
alert('Version wurde dauerhaft gelöscht.');
|
|
} catch(e) {
|
|
alert('Fehler: ' + e.message);
|
|
}
|
|
}
|
|
|
|
// ==========================================
|
|
// DSB APPROVAL WORKFLOW
|
|
// ==========================================
|
|
|
|
async function submitForReview(versionId) {
|
|
if (!confirm('Version zur DSB-Prüfung einreichen?')) return;
|
|
|
|
try {
|
|
const res = await fetch(`/api/consent/admin/versions/${versionId}/submit-review`, { method: 'POST' });
|
|
if (!res.ok) {
|
|
const data = await res.json();
|
|
throw new Error(data.detail?.error || 'Einreichung fehlgeschlagen');
|
|
}
|
|
|
|
await loadVersionsForDocument();
|
|
alert('Version wurde zur Prüfung eingereicht!');
|
|
} catch(e) {
|
|
alert('Fehler: ' + e.message);
|
|
}
|
|
}
|
|
|
|
// Dialog für Genehmigung mit Veröffentlichungszeitpunkt
|
|
let approvalVersionId = null;
|
|
|
|
function showApprovalDialog(versionId) {
|
|
approvalVersionId = versionId;
|
|
const dialog = document.getElementById('approval-dialog');
|
|
|
|
// Setze Minimum-Datum auf morgen
|
|
const tomorrow = new Date();
|
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
tomorrow.setHours(0, 0, 0, 0);
|
|
document.getElementById('approval-date').min = tomorrow.toISOString().split('T')[0];
|
|
document.getElementById('approval-date').value = '';
|
|
document.getElementById('approval-time').value = '00:00';
|
|
document.getElementById('approval-comment').value = '';
|
|
|
|
dialog.classList.add('active');
|
|
}
|
|
|
|
function hideApprovalDialog() {
|
|
document.getElementById('approval-dialog').classList.remove('active');
|
|
approvalVersionId = null;
|
|
}
|
|
|
|
async function submitApproval() {
|
|
if (!approvalVersionId) return;
|
|
|
|
const dateInput = document.getElementById('approval-date').value;
|
|
const timeInput = document.getElementById('approval-time').value;
|
|
const comment = document.getElementById('approval-comment').value;
|
|
|
|
let scheduledPublishAt = null;
|
|
if (dateInput) {
|
|
// Kombiniere Datum und Zeit zu ISO 8601
|
|
const datetime = new Date(dateInput + 'T' + (timeInput || '00:00') + ':00');
|
|
scheduledPublishAt = datetime.toISOString();
|
|
}
|
|
|
|
try {
|
|
const body = { comment: comment || '' };
|
|
if (scheduledPublishAt) {
|
|
body.scheduled_publish_at = scheduledPublishAt;
|
|
}
|
|
|
|
const res = await fetch(`/api/consent/admin/versions/${approvalVersionId}/approve`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body)
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const data = await res.json();
|
|
throw new Error(data.detail?.error || data.detail || 'Genehmigung fehlgeschlagen');
|
|
}
|
|
|
|
hideApprovalDialog();
|
|
await loadVersionsForDocument();
|
|
|
|
if (scheduledPublishAt) {
|
|
const date = new Date(scheduledPublishAt);
|
|
alert('Version genehmigt! Geplante Veröffentlichung: ' + date.toLocaleString('de-DE'));
|
|
} else {
|
|
alert('Version genehmigt! Sie kann jetzt manuell veröffentlicht werden.');
|
|
}
|
|
} catch(e) {
|
|
alert('Fehler: ' + e.message);
|
|
}
|
|
}
|
|
|
|
// Alte Funktion für Rückwärtskompatibilität
|
|
async function approveVersion(versionId) {
|
|
showApprovalDialog(versionId);
|
|
}
|
|
|
|
async function rejectVersion(versionId) {
|
|
const comment = prompt('Begründung für Ablehnung (erforderlich):');
|
|
if (!comment) {
|
|
alert('Eine Begründung ist erforderlich.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(`/api/consent/admin/versions/${versionId}/reject`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ comment: comment })
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const data = await res.json();
|
|
throw new Error(data.detail?.error || data.detail || 'Ablehnung fehlgeschlagen');
|
|
}
|
|
|
|
await loadVersionsForDocument();
|
|
alert('Version abgelehnt und zurück in Entwurf-Status versetzt.');
|
|
} catch(e) {
|
|
alert('Fehler: ' + e.message);
|
|
}
|
|
}
|
|
|
|
// Store current compare version for actions
|
|
let currentCompareVersionId = null;
|
|
let currentCompareVersionStatus = null;
|
|
let currentCompareDocId = null;
|
|
|
|
async function showCompareView(versionId) {
|
|
try {
|
|
const res = await fetch(`/api/consent/admin/versions/${versionId}/compare`);
|
|
if (!res.ok) throw new Error('Vergleich konnte nicht geladen werden');
|
|
const data = await res.json();
|
|
|
|
const currentVersion = data.current_version;
|
|
const publishedVersion = data.published_version;
|
|
const history = data.approval_history || [];
|
|
|
|
// Store version info for actions
|
|
currentCompareVersionId = versionId;
|
|
currentCompareVersionStatus = currentVersion.status;
|
|
currentCompareDocId = currentVersion.document_id;
|
|
|
|
// Update header info
|
|
document.getElementById('compare-published-info').textContent =
|
|
publishedVersion ? `${publishedVersion.title} (v${publishedVersion.version})` : 'Keine Version';
|
|
document.getElementById('compare-draft-info').textContent =
|
|
`${currentVersion.title} (v${currentVersion.version})`;
|
|
document.getElementById('compare-published-version').textContent =
|
|
publishedVersion ? `v${publishedVersion.version}` : '';
|
|
document.getElementById('compare-draft-version').textContent =
|
|
`v${currentVersion.version} - ${currentVersion.status}`;
|
|
|
|
// Populate content panels
|
|
const leftPanel = document.getElementById('compare-content-left');
|
|
const rightPanel = document.getElementById('compare-content-right');
|
|
|
|
leftPanel.innerHTML = publishedVersion
|
|
? publishedVersion.content
|
|
: '<div class="no-content">Keine veröffentlichte Version vorhanden</div>';
|
|
rightPanel.innerHTML = currentVersion.content || '<div class="no-content">Kein Inhalt</div>';
|
|
|
|
// Populate history
|
|
const historyContainer = document.getElementById('compare-history-container');
|
|
if (history.length > 0) {
|
|
historyContainer.innerHTML = `
|
|
<div class="compare-history-title">Genehmigungsverlauf</div>
|
|
<div class="compare-history-list">
|
|
${history.map(h => `
|
|
<span class="compare-history-item">
|
|
<strong>${h.action}</strong> von ${h.approver || 'System'}
|
|
(${new Date(h.created_at).toLocaleString('de-DE')})
|
|
${h.comment ? ': ' + h.comment : ''}
|
|
</span>
|
|
`).join(' | ')}
|
|
</div>
|
|
`;
|
|
} else {
|
|
historyContainer.innerHTML = '';
|
|
}
|
|
|
|
// Render action buttons based on status
|
|
renderCompareActions(currentVersion.status, versionId);
|
|
|
|
// Setup synchronized scrolling
|
|
setupSyncScroll(leftPanel, rightPanel);
|
|
|
|
// Show the overlay
|
|
document.getElementById('version-compare-view').classList.add('active');
|
|
document.body.style.overflow = 'hidden';
|
|
} catch(e) {
|
|
alert('Fehler beim Laden des Vergleichs: ' + e.message);
|
|
}
|
|
}
|
|
|
|
function renderCompareActions(status, versionId) {
|
|
const actionsContainer = document.getElementById('compare-actions-container');
|
|
|
|
let buttons = '';
|
|
|
|
// Edit button - available for draft, review, and rejected
|
|
if (status === 'draft' || status === 'review' || status === 'rejected') {
|
|
buttons += `<button class="btn btn-primary" onclick="editVersionFromCompare('${versionId}')">
|
|
✏️ Version bearbeiten
|
|
</button>`;
|
|
}
|
|
|
|
// Status-specific actions
|
|
if (status === 'draft') {
|
|
buttons += `<button class="btn" onclick="submitForReviewFromCompare('${versionId}')">
|
|
📤 Zur Prüfung einreichen
|
|
</button>`;
|
|
}
|
|
|
|
if (status === 'review') {
|
|
buttons += `<button class="btn btn-success" onclick="approveVersionFromCompare('${versionId}')">
|
|
✅ Genehmigen
|
|
</button>`;
|
|
buttons += `<button class="btn btn-danger" onclick="rejectVersionFromCompare('${versionId}')">
|
|
❌ Ablehnen
|
|
</button>`;
|
|
}
|
|
|
|
if (status === 'approved') {
|
|
buttons += `<button class="btn btn-primary" onclick="publishVersionFromCompare('${versionId}')">
|
|
🚀 Veröffentlichen
|
|
</button>`;
|
|
}
|
|
|
|
// Delete button for draft/rejected
|
|
if (status === 'draft' || status === 'rejected') {
|
|
buttons += `<button class="btn btn-danger" onclick="deleteVersionFromCompare('${versionId}')" style="margin-left: auto;">
|
|
🗑️ Löschen
|
|
</button>`;
|
|
}
|
|
|
|
actionsContainer.innerHTML = buttons;
|
|
}
|
|
|
|
async function editVersionFromCompare(versionId) {
|
|
// Store the doc ID before closing compare view
|
|
const docId = currentCompareDocId;
|
|
|
|
// Close compare view
|
|
hideCompareView();
|
|
|
|
// Switch to versions tab
|
|
const versionsTab = document.querySelector('.admin-tab[data-tab="versions"]');
|
|
if (versionsTab) {
|
|
versionsTab.click();
|
|
}
|
|
|
|
// Wait a moment for the tab to become active
|
|
await new Promise(resolve => setTimeout(resolve, 150));
|
|
|
|
// Ensure document select is populated
|
|
populateDocumentSelect();
|
|
|
|
// Set the document select if we have the doc ID
|
|
if (docId) {
|
|
const select = document.getElementById('admin-version-doc-select');
|
|
if (select) {
|
|
select.value = docId;
|
|
// Load versions for this document
|
|
await loadVersionsForDocument();
|
|
}
|
|
}
|
|
|
|
// Now load the version data directly and open the form
|
|
try {
|
|
const res = await fetch(`/api/consent/admin/documents/${docId}/versions`);
|
|
if (!res.ok) throw new Error('Failed to load versions');
|
|
const data = await res.json();
|
|
|
|
const version = (data.versions || []).find(v => v.id === versionId);
|
|
if (!version) {
|
|
alert('Version nicht gefunden');
|
|
return;
|
|
}
|
|
|
|
// Open the form and fill with version data
|
|
const form = document.getElementById('admin-version-form');
|
|
document.getElementById('admin-version-id').value = version.id;
|
|
document.getElementById('admin-version-number').value = version.version;
|
|
document.getElementById('admin-version-lang').value = version.language;
|
|
document.getElementById('admin-version-title').value = version.title;
|
|
document.getElementById('admin-version-summary').value = version.summary || '';
|
|
|
|
// Fill rich text editor with content
|
|
const editor = document.getElementById('admin-version-editor');
|
|
if (editor) {
|
|
editor.innerHTML = version.content || '';
|
|
const charCount = editor.textContent.length;
|
|
document.getElementById('editor-char-count').textContent = charCount + ' Zeichen';
|
|
}
|
|
document.getElementById('admin-version-content').value = version.content || '';
|
|
|
|
form.classList.add('active');
|
|
} catch(e) {
|
|
alert('Fehler beim Laden der Version: ' + e.message);
|
|
}
|
|
}
|
|
|
|
async function submitForReviewFromCompare(versionId) {
|
|
await submitForReview(versionId);
|
|
hideCompareView();
|
|
await loadVersionsForDocument();
|
|
}
|
|
|
|
async function approveVersionFromCompare(versionId) {
|
|
const comment = prompt('Kommentar zur Genehmigung (optional):');
|
|
try {
|
|
const res = await fetch(`/api/consent/admin/versions/${versionId}/approve`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ comment: comment || '' })
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
throw new Error(err.detail?.error || err.error || 'Genehmigung fehlgeschlagen');
|
|
}
|
|
alert('Version genehmigt!');
|
|
hideCompareView();
|
|
await loadVersionsForDocument();
|
|
} catch(e) {
|
|
alert('Fehler: ' + e.message);
|
|
}
|
|
}
|
|
|
|
async function rejectVersionFromCompare(versionId) {
|
|
const comment = prompt('Begründung für die Ablehnung (erforderlich):');
|
|
if (!comment) {
|
|
alert('Eine Begründung ist erforderlich.');
|
|
return;
|
|
}
|
|
try {
|
|
const res = await fetch(`/api/consent/admin/versions/${versionId}/reject`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ comment: comment })
|
|
});
|
|
if (!res.ok) throw new Error('Ablehnung fehlgeschlagen');
|
|
alert('Version abgelehnt. Der Autor kann sie überarbeiten.');
|
|
hideCompareView();
|
|
await loadVersionsForDocument();
|
|
} catch(e) {
|
|
alert('Fehler: ' + e.message);
|
|
}
|
|
}
|
|
|
|
async function publishVersionFromCompare(versionId) {
|
|
if (!confirm('Version wirklich veröffentlichen?')) return;
|
|
try {
|
|
const res = await fetch(`/api/consent/admin/versions/${versionId}/publish`, { method: 'POST' });
|
|
if (!res.ok) throw new Error('Veröffentlichung fehlgeschlagen');
|
|
alert('Version veröffentlicht!');
|
|
hideCompareView();
|
|
await loadVersionsForDocument();
|
|
} catch(e) {
|
|
alert('Fehler: ' + e.message);
|
|
}
|
|
}
|
|
|
|
async function deleteVersionFromCompare(versionId) {
|
|
if (!confirm('Version wirklich dauerhaft löschen?\\n\\nDie Versionsnummer wird wieder frei.')) return;
|
|
try {
|
|
const res = await fetch(`/api/consent/admin/versions/${versionId}`, { method: 'DELETE' });
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
throw new Error(err.detail?.message || err.error || 'Löschen fehlgeschlagen');
|
|
}
|
|
alert('Version gelöscht!');
|
|
hideCompareView();
|
|
await loadVersionsForDocument();
|
|
} catch(e) {
|
|
alert('Fehler: ' + e.message);
|
|
}
|
|
}
|
|
|
|
function hideCompareView() {
|
|
document.getElementById('version-compare-view').classList.remove('active');
|
|
document.body.style.overflow = '';
|
|
// Remove scroll listeners
|
|
const leftPanel = document.getElementById('compare-content-left');
|
|
const rightPanel = document.getElementById('compare-content-right');
|
|
if (leftPanel) leftPanel.onscroll = null;
|
|
if (rightPanel) rightPanel.onscroll = null;
|
|
}
|
|
|
|
// Synchronized scrolling between two panels
|
|
let syncScrollActive = false;
|
|
function setupSyncScroll(leftPanel, rightPanel) {
|
|
// Remove any existing listeners first
|
|
leftPanel.onscroll = null;
|
|
rightPanel.onscroll = null;
|
|
|
|
// Flag to prevent infinite scroll loops
|
|
let isScrolling = false;
|
|
|
|
rightPanel.onscroll = function() {
|
|
if (isScrolling) return;
|
|
isScrolling = true;
|
|
|
|
// Calculate scroll percentage
|
|
const rightScrollPercent = rightPanel.scrollTop / (rightPanel.scrollHeight - rightPanel.clientHeight);
|
|
|
|
// Apply same percentage to left panel
|
|
const leftMaxScroll = leftPanel.scrollHeight - leftPanel.clientHeight;
|
|
leftPanel.scrollTop = rightScrollPercent * leftMaxScroll;
|
|
|
|
setTimeout(() => { isScrolling = false; }, 10);
|
|
};
|
|
|
|
leftPanel.onscroll = function() {
|
|
if (isScrolling) return;
|
|
isScrolling = true;
|
|
|
|
// Calculate scroll percentage
|
|
const leftScrollPercent = leftPanel.scrollTop / (leftPanel.scrollHeight - leftPanel.clientHeight);
|
|
|
|
// Apply same percentage to right panel
|
|
const rightMaxScroll = rightPanel.scrollHeight - rightPanel.clientHeight;
|
|
rightPanel.scrollTop = leftScrollPercent * rightMaxScroll;
|
|
|
|
setTimeout(() => { isScrolling = false; }, 10);
|
|
};
|
|
}
|
|
|
|
async function showApprovalHistory(versionId) {
|
|
try {
|
|
const res = await fetch(`/api/consent/admin/versions/${versionId}/approval-history`);
|
|
if (!res.ok) throw new Error('Historie konnte nicht geladen werden');
|
|
const data = await res.json();
|
|
const history = data.approval_history || [];
|
|
|
|
const content = history.length === 0
|
|
? '<p>Keine Genehmigungshistorie vorhanden.</p>'
|
|
: `
|
|
<table class="admin-table" style="font-size: 14px;">
|
|
<thead>
|
|
<tr>
|
|
<th>Aktion</th>
|
|
<th>Benutzer</th>
|
|
<th>Kommentar</th>
|
|
<th>Datum</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${history.map(h => `
|
|
<tr>
|
|
<td><span class="admin-badge admin-badge-${h.action}">${h.action}</span></td>
|
|
<td>${h.approver || h.name || '-'}</td>
|
|
<td>${h.comment || '-'}</td>
|
|
<td>${new Date(h.created_at).toLocaleString('de-DE')}</td>
|
|
</tr>
|
|
`).join('')}
|
|
</tbody>
|
|
</table>
|
|
`;
|
|
|
|
showCustomModal('Genehmigungsverlauf', content, [
|
|
{ text: 'Schließen', onClick: () => hideCustomModal() }
|
|
]);
|
|
} catch(e) {
|
|
alert('Fehler: ' + e.message);
|
|
}
|
|
}
|
|
|
|
// Custom Modal Functions
|
|
function showCustomModal(title, content, buttons = []) {
|
|
let modal = document.getElementById('custom-modal');
|
|
if (!modal) {
|
|
modal = document.createElement('div');
|
|
modal.id = 'custom-modal';
|
|
modal.className = 'modal-overlay';
|
|
document.body.appendChild(modal);
|
|
}
|
|
|
|
modal.innerHTML = `
|
|
<div class="modal-content" style="max-width: 900px; width: 95%;">
|
|
<div class="modal-header">
|
|
<h2>${title}</h2>
|
|
<button class="modal-close" onclick="hideCustomModal()">×</button>
|
|
</div>
|
|
<div class="modal-body" style="max-height: 80vh; overflow-y: auto;">
|
|
${content}
|
|
</div>
|
|
${buttons.length > 0 ? `
|
|
<div class="modal-footer" style="display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px;">
|
|
${buttons.map(b => `
|
|
<button class="admin-btn ${b.primary ? 'admin-btn-primary' : ''}" onclick="(${b.onClick.toString()})()">${b.text}</button>
|
|
`).join('')}
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
`;
|
|
modal.classList.add('active');
|
|
}
|
|
|
|
function hideCustomModal() {
|
|
const modal = document.getElementById('custom-modal');
|
|
if (modal) modal.classList.remove('active');
|
|
}
|
|
|
|
// ==========================================
|
|
// COOKIE CATEGORIES MANAGEMENT
|
|
// ==========================================
|
|
async function loadAdminCookieCategories() {
|
|
const container = document.getElementById('admin-cookie-table-container');
|
|
container.innerHTML = '<div class="admin-loading">Lade Cookie-Kategorien...</div>';
|
|
|
|
try {
|
|
const res = await fetch('/api/consent/admin/cookies/categories');
|
|
if (!res.ok) throw new Error('Failed to load');
|
|
const data = await res.json();
|
|
adminCookieCategories = data.categories || [];
|
|
renderCookieCategoriesTable();
|
|
} catch(e) {
|
|
container.innerHTML = '<div class="admin-empty">Fehler beim Laden der Kategorien.</div>';
|
|
}
|
|
}
|
|
|
|
function renderCookieCategoriesTable() {
|
|
const container = document.getElementById('admin-cookie-table-container');
|
|
if (adminCookieCategories.length === 0) {
|
|
container.innerHTML = '<div class="admin-empty">Keine Cookie-Kategorien vorhanden.</div>';
|
|
return;
|
|
}
|
|
|
|
const html = `
|
|
<table class="admin-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Anzeigename (DE)</th>
|
|
<th>Typ</th>
|
|
<th>Aktionen</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${adminCookieCategories.map(cat => `
|
|
<tr>
|
|
<td><code>${cat.name}</code></td>
|
|
<td>${cat.display_name_de}</td>
|
|
<td>
|
|
${cat.is_mandatory ? '<span class="admin-badge admin-badge-mandatory">Notwendig</span>' : '<span class="admin-badge admin-badge-optional">Optional</span>'}
|
|
</td>
|
|
<td class="admin-actions">
|
|
<button class="admin-btn admin-btn-edit" onclick="editCookieCategory('${cat.id}')">Bearbeiten</button>
|
|
${!cat.is_mandatory ? `<button class="admin-btn admin-btn-delete" onclick="deleteCookieCategory('${cat.id}')">Löschen</button>` : ''}
|
|
</td>
|
|
</tr>
|
|
`).join('')}
|
|
</tbody>
|
|
</table>
|
|
`;
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
function showCookieForm(cat = null) {
|
|
const form = document.getElementById('admin-cookie-form');
|
|
|
|
if (cat) {
|
|
document.getElementById('admin-cookie-id').value = cat.id;
|
|
document.getElementById('admin-cookie-name').value = cat.name;
|
|
document.getElementById('admin-cookie-display-de').value = cat.display_name_de;
|
|
document.getElementById('admin-cookie-display-en').value = cat.display_name_en || '';
|
|
document.getElementById('admin-cookie-desc-de').value = cat.description_de || '';
|
|
document.getElementById('admin-cookie-mandatory').checked = cat.is_mandatory;
|
|
} else {
|
|
document.getElementById('admin-cookie-id').value = '';
|
|
document.getElementById('admin-cookie-name').value = '';
|
|
document.getElementById('admin-cookie-display-de').value = '';
|
|
document.getElementById('admin-cookie-display-en').value = '';
|
|
document.getElementById('admin-cookie-desc-de').value = '';
|
|
document.getElementById('admin-cookie-mandatory').checked = false;
|
|
}
|
|
|
|
form.classList.add('active');
|
|
}
|
|
|
|
function hideCookieForm() {
|
|
document.getElementById('admin-cookie-form').classList.remove('active');
|
|
}
|
|
|
|
function editCookieCategory(catId) {
|
|
const cat = adminCookieCategories.find(c => c.id === catId);
|
|
if (cat) showCookieForm(cat);
|
|
}
|
|
|
|
async function saveCookieCategory() {
|
|
const catId = document.getElementById('admin-cookie-id').value;
|
|
const data = {
|
|
name: document.getElementById('admin-cookie-name').value,
|
|
display_name_de: document.getElementById('admin-cookie-display-de').value,
|
|
display_name_en: document.getElementById('admin-cookie-display-en').value,
|
|
description_de: document.getElementById('admin-cookie-desc-de').value,
|
|
is_mandatory: document.getElementById('admin-cookie-mandatory').checked
|
|
};
|
|
|
|
try {
|
|
const url = catId ? `/api/consent/admin/cookies/categories/${catId}` : '/api/consent/admin/cookies/categories';
|
|
const method = catId ? 'PUT' : 'POST';
|
|
|
|
const res = await fetch(url, {
|
|
method,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(data)
|
|
});
|
|
|
|
if (!res.ok) throw new Error('Failed to save');
|
|
|
|
hideCookieForm();
|
|
await loadAdminCookieCategories();
|
|
alert('Kategorie gespeichert!');
|
|
} catch(e) {
|
|
alert('Fehler beim Speichern: ' + e.message);
|
|
}
|
|
}
|
|
|
|
async function deleteCookieCategory(catId) {
|
|
if (!confirm('Kategorie wirklich löschen?')) return;
|
|
|
|
try {
|
|
const res = await fetch(`/api/consent/admin/cookies/categories/${catId}`, { method: 'DELETE' });
|
|
if (!res.ok) throw new Error('Failed to delete');
|
|
|
|
await loadAdminCookieCategories();
|
|
alert('Kategorie gelöscht!');
|
|
} catch(e) {
|
|
alert('Fehler: ' + e.message);
|
|
}
|
|
}
|
|
|
|
// ==========================================
|
|
// GPU INFRASTRUCTURE CONTROLS
|
|
// ==========================================
|
|
|
|
let gpuStatusInterval = null;
|
|
|
|
async function gpuFetch(endpoint, options = {}) {
|
|
const headers = {
|
|
'Content-Type': 'application/json',
|
|
...options.headers,
|
|
};
|
|
|
|
try {
|
|
const response = await fetch(`/infra/vast${endpoint}`, {
|
|
...options,
|
|
headers,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ detail: response.statusText }));
|
|
throw new Error(error.detail || 'Request failed');
|
|
}
|
|
|
|
return await response.json();
|
|
} catch (error) {
|
|
console.error('GPU API Error:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function gpuRefreshStatus() {
|
|
const btnRefresh = document.querySelector('.gpu-btn-refresh');
|
|
const btnStart = document.getElementById('gpu-btn-start');
|
|
const btnStop = document.getElementById('gpu-btn-stop');
|
|
|
|
try {
|
|
if (btnRefresh) btnRefresh.disabled = true;
|
|
|
|
const status = await gpuFetch('/status');
|
|
|
|
// Update status badge
|
|
const badge = document.getElementById('gpu-status-badge');
|
|
const statusText = document.getElementById('gpu-status-text');
|
|
const statusDot = badge.querySelector('.gpu-status-dot');
|
|
|
|
badge.className = 'gpu-status-badge ' + status.status;
|
|
statusText.textContent = formatGpuStatus(status.status);
|
|
statusDot.className = 'gpu-status-dot ' + status.status;
|
|
|
|
// Update info cards
|
|
document.getElementById('gpu-name').textContent = status.gpu_name || '-';
|
|
document.getElementById('gpu-dph').textContent = status.dph_total
|
|
? `$${status.dph_total.toFixed(2)}/h`
|
|
: '-';
|
|
|
|
// Update endpoint
|
|
const endpointDiv = document.getElementById('gpu-endpoint');
|
|
const endpointUrl = document.getElementById('gpu-endpoint-url');
|
|
if (status.endpoint_base_url && status.status === 'running') {
|
|
endpointDiv.style.display = 'block';
|
|
endpointDiv.className = 'gpu-endpoint-url active';
|
|
endpointUrl.textContent = status.endpoint_base_url;
|
|
} else {
|
|
endpointDiv.style.display = 'none';
|
|
}
|
|
|
|
// Update shutdown warning
|
|
const warningDiv = document.getElementById('gpu-shutdown-warning');
|
|
const shutdownMinutes = document.getElementById('gpu-shutdown-minutes');
|
|
if (status.auto_shutdown_in_minutes !== null && status.status === 'running') {
|
|
warningDiv.style.display = 'flex';
|
|
shutdownMinutes.textContent = status.auto_shutdown_in_minutes;
|
|
} else {
|
|
warningDiv.style.display = 'none';
|
|
}
|
|
|
|
// Update totals
|
|
document.getElementById('gpu-total-runtime').textContent = formatGpuRuntime(status.total_runtime_hours || 0);
|
|
document.getElementById('gpu-total-cost-all').textContent = `$${(status.total_cost_usd || 0).toFixed(2)}`;
|
|
|
|
// Update buttons
|
|
const isRunning = status.status === 'running';
|
|
const isLoading = ['loading', 'scheduling', 'creating'].includes(status.status);
|
|
|
|
btnStart.disabled = isRunning || isLoading;
|
|
btnStop.disabled = !isRunning;
|
|
|
|
if (isLoading) {
|
|
btnStart.innerHTML = '<span class="gpu-spinner"></span> Startet...';
|
|
} else {
|
|
btnStart.innerHTML = `
|
|
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3l14 9-14 9V3z"/>
|
|
</svg>
|
|
Starten
|
|
`;
|
|
}
|
|
|
|
// Load audit log
|
|
loadGpuAuditLog();
|
|
|
|
} catch (error) {
|
|
console.error('Failed to refresh GPU status:', error);
|
|
document.getElementById('gpu-status-text').textContent = 'Fehler';
|
|
document.getElementById('gpu-status-badge').className = 'gpu-status-badge error';
|
|
} finally {
|
|
if (btnRefresh) btnRefresh.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function gpuPowerOn() {
|
|
const btnStart = document.getElementById('gpu-btn-start');
|
|
const btnStop = document.getElementById('gpu-btn-stop');
|
|
|
|
if (!confirm('GPU-Instanz starten? Es fallen Kosten an solange sie laeuft.')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
btnStart.disabled = true;
|
|
btnStop.disabled = true;
|
|
btnStart.innerHTML = '<span class="gpu-spinner"></span> Startet...';
|
|
|
|
const result = await gpuFetch('/power/on', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
wait_for_health: true,
|
|
}),
|
|
});
|
|
|
|
alert('GPU gestartet: ' + (result.message || result.status));
|
|
await gpuRefreshStatus();
|
|
startGpuStatusPolling();
|
|
|
|
} catch (error) {
|
|
alert('GPU Start fehlgeschlagen: ' + error.message);
|
|
await gpuRefreshStatus();
|
|
}
|
|
}
|
|
|
|
async function gpuPowerOff() {
|
|
const btnStart = document.getElementById('gpu-btn-start');
|
|
const btnStop = document.getElementById('gpu-btn-stop');
|
|
|
|
if (!confirm('GPU-Instanz stoppen?')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
btnStart.disabled = true;
|
|
btnStop.disabled = true;
|
|
btnStop.innerHTML = '<span class="gpu-spinner"></span> Stoppt...';
|
|
|
|
const result = await gpuFetch('/power/off', {
|
|
method: 'POST',
|
|
body: JSON.stringify({}),
|
|
});
|
|
|
|
const msg = result.session_runtime_minutes
|
|
? `GPU gestoppt. Session: ${result.session_runtime_minutes.toFixed(1)} min, $${result.session_cost_usd.toFixed(3)}`
|
|
: 'GPU gestoppt';
|
|
|
|
alert(msg);
|
|
await gpuRefreshStatus();
|
|
stopGpuStatusPolling();
|
|
|
|
} catch (error) {
|
|
alert('GPU Stop fehlgeschlagen: ' + error.message);
|
|
await gpuRefreshStatus();
|
|
} finally {
|
|
btnStop.innerHTML = `
|
|
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"/>
|
|
</svg>
|
|
Stoppen
|
|
`;
|
|
}
|
|
}
|
|
|
|
async function loadGpuAuditLog() {
|
|
try {
|
|
const entries = await gpuFetch('/audit?limit=20');
|
|
const container = document.getElementById('gpu-audit-log');
|
|
|
|
if (!entries || entries.length === 0) {
|
|
container.innerHTML = '<div class="gpu-audit-entry">Keine Eintraege</div>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = entries.map(entry => {
|
|
const time = new Date(entry.ts).toLocaleString('de-DE', {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
});
|
|
return `
|
|
<div class="gpu-audit-entry">
|
|
<span class="gpu-audit-time">${time}</span>
|
|
<span class="gpu-audit-event">${entry.event}</span>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
} catch (error) {
|
|
console.error('Failed to load audit log:', error);
|
|
}
|
|
}
|
|
|
|
function formatGpuStatus(status) {
|
|
const statusMap = {
|
|
'running': 'Laeuft',
|
|
'stopped': 'Gestoppt',
|
|
'exited': 'Beendet',
|
|
'loading': 'Laedt...',
|
|
'scheduling': 'Plane...',
|
|
'creating': 'Erstelle...',
|
|
'unconfigured': 'Nicht konfiguriert',
|
|
'not_found': 'Nicht gefunden',
|
|
'unknown': 'Unbekannt',
|
|
'error': 'Fehler',
|
|
};
|
|
return statusMap[status] || status;
|
|
}
|
|
|
|
function formatGpuRuntime(hours) {
|
|
const h = Math.floor(hours);
|
|
const m = Math.round((hours - h) * 60);
|
|
return `${h}h ${m}m`;
|
|
}
|
|
|
|
function startGpuStatusPolling() {
|
|
stopGpuStatusPolling();
|
|
gpuStatusInterval = setInterval(gpuRefreshStatus, 30000);
|
|
}
|
|
|
|
function stopGpuStatusPolling() {
|
|
if (gpuStatusInterval) {
|
|
clearInterval(gpuStatusInterval);
|
|
gpuStatusInterval = null;
|
|
}
|
|
}
|
|
|
|
// Initialize GPU tab when clicked
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
const gpuTab = document.querySelector('.admin-tab[data-tab="gpu"]');
|
|
if (gpuTab) {
|
|
gpuTab.addEventListener('click', () => {
|
|
gpuRefreshStatus();
|
|
startGpuStatusPolling();
|
|
});
|
|
}
|
|
|
|
// Initialize Mac Mini tab when clicked
|
|
const macMiniTab = document.querySelector('.admin-tab[data-tab="mac-mini"]');
|
|
if (macMiniTab) {
|
|
macMiniTab.addEventListener('click', () => {
|
|
macMiniRefreshStatus();
|
|
startMacMiniStatusPolling();
|
|
});
|
|
}
|
|
});
|
|
|
|
// ==========================================
|
|
// MAC MINI CONTROL TAB
|
|
// ==========================================
|
|
let macMiniState = {
|
|
ip: '192.168.178.100',
|
|
isOnline: false,
|
|
downloadInProgress: false,
|
|
pollInterval: null
|
|
};
|
|
|
|
function startMacMiniStatusPolling() {
|
|
stopMacMiniStatusPolling();
|
|
macMiniState.pollInterval = setInterval(macMiniRefreshStatus, 30000);
|
|
}
|
|
|
|
function stopMacMiniStatusPolling() {
|
|
if (macMiniState.pollInterval) {
|
|
clearInterval(macMiniState.pollInterval);
|
|
macMiniState.pollInterval = null;
|
|
}
|
|
}
|
|
|
|
async function macMiniRefreshStatus() {
|
|
const statusBadge = document.getElementById('mac-mini-overall-status');
|
|
if (!statusBadge) return;
|
|
|
|
statusBadge.className = 'mac-mini-status-badge checking';
|
|
statusBadge.textContent = 'Prüfe...';
|
|
statusBadge.style.cssText = 'padding: 6px 16px; border-radius: 20px; font-size: 14px; font-weight: 600; background: rgba(251, 191, 36, 0.2); color: #fbbf24; border: 1px solid #fbbf24;';
|
|
|
|
try {
|
|
const response = await fetch('/api/mac-mini/status');
|
|
const data = await response.json();
|
|
|
|
macMiniState.isOnline = data.online;
|
|
macMiniState.ip = data.ip || macMiniState.ip;
|
|
|
|
// Update overall status badge
|
|
if (data.online) {
|
|
statusBadge.className = 'mac-mini-status-badge online';
|
|
statusBadge.textContent = 'Online';
|
|
statusBadge.style.cssText = 'padding: 6px 16px; border-radius: 20px; font-size: 14px; font-weight: 600; background: rgba(34, 197, 94, 0.2); color: #22c55e; border: 1px solid #22c55e;';
|
|
} else {
|
|
statusBadge.className = 'mac-mini-status-badge offline';
|
|
statusBadge.textContent = 'Offline';
|
|
statusBadge.style.cssText = 'padding: 6px 16px; border-radius: 20px; font-size: 14px; font-weight: 600; background: rgba(239, 68, 68, 0.2); color: #ef4444; border: 1px solid #ef4444;';
|
|
}
|
|
|
|
// Update IP
|
|
const ipEl = document.getElementById('mac-mini-ip');
|
|
if (ipEl) ipEl.textContent = macMiniState.ip;
|
|
|
|
// Update connection status
|
|
updateMacMiniStatusValue('status-ssh', data.ssh ? 'Verbunden' : 'Nicht verfügbar', data.ssh);
|
|
updateMacMiniStatusValue('status-ping', data.ping ? 'OK' : 'Timeout', data.ping);
|
|
|
|
// Update services
|
|
updateMacMiniStatusValue('status-backend', data.backend ? 'Läuft' : 'Offline', data.backend);
|
|
updateMacMiniStatusValue('status-ollama', data.ollama ? 'Läuft' : 'Offline', data.ollama);
|
|
updateMacMiniStatusValue('status-docker', data.docker ? 'Läuft' : 'Offline', data.docker);
|
|
|
|
// Update system info
|
|
const uptimeEl = document.getElementById('status-uptime');
|
|
const cpuEl = document.getElementById('status-cpu');
|
|
const memoryEl = document.getElementById('status-memory');
|
|
if (uptimeEl) uptimeEl.textContent = data.uptime || '--';
|
|
if (cpuEl) cpuEl.textContent = data.cpu_load || '--';
|
|
if (memoryEl) memoryEl.textContent = data.memory || '--';
|
|
|
|
// Update Docker containers
|
|
updateDockerContainerList(data.containers || []);
|
|
|
|
// Update Ollama models
|
|
updateOllamaModelList(data.models || []);
|
|
|
|
// Enable/disable buttons based on status
|
|
const btnWake = document.getElementById('btn-wake');
|
|
const btnRestart = document.getElementById('btn-restart');
|
|
const btnShutdown = document.getElementById('btn-shutdown');
|
|
if (btnWake) btnWake.disabled = data.online;
|
|
if (btnRestart) btnRestart.disabled = !data.online;
|
|
if (btnShutdown) btnShutdown.disabled = !data.online;
|
|
|
|
} catch (error) {
|
|
console.error('Error fetching Mac Mini status:', error);
|
|
statusBadge.className = 'mac-mini-status-badge offline';
|
|
statusBadge.textContent = 'Fehler';
|
|
statusBadge.style.cssText = 'padding: 6px 16px; border-radius: 20px; font-size: 14px; font-weight: 600; background: rgba(239, 68, 68, 0.2); color: #ef4444; border: 1px solid #ef4444;';
|
|
}
|
|
}
|
|
|
|
function updateMacMiniStatusValue(elementId, text, isOk) {
|
|
const el = document.getElementById(elementId);
|
|
if (el) {
|
|
el.textContent = text;
|
|
el.style.color = isOk ? '#22c55e' : '#ef4444';
|
|
}
|
|
}
|
|
|
|
function updateDockerContainerList(containers) {
|
|
const list = document.getElementById('docker-container-list');
|
|
if (!list) return;
|
|
|
|
if (containers.length === 0) {
|
|
list.innerHTML = '<div style="color: var(--bp-text-muted); text-align: center; padding: 20px;">Keine Container gefunden</div>';
|
|
return;
|
|
}
|
|
|
|
list.innerHTML = containers.map(c => {
|
|
const isHealthy = c.status.includes('healthy');
|
|
const isRunning = c.status.includes('Up');
|
|
const statusColor = isHealthy ? '#22c55e' : (isRunning ? '#3b82f6' : '#ef4444');
|
|
const statusBg = isHealthy ? 'rgba(34, 197, 94, 0.2)' : (isRunning ? 'rgba(59, 130, 246, 0.2)' : 'rgba(239, 68, 68, 0.2)');
|
|
return '<div style="display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; background: var(--bp-surface); border-radius: 8px; border: 1px solid var(--bp-border);">' +
|
|
'<span style="color: var(--bp-text); font-size: 14px; font-family: monospace;">' + c.name + '</span>' +
|
|
'<span style="padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 600; background: ' + statusBg + '; color: ' + statusColor + ';">' + c.status + '</span>' +
|
|
'</div>';
|
|
}).join('');
|
|
}
|
|
|
|
function updateOllamaModelList(models) {
|
|
const list = document.getElementById('ollama-model-list');
|
|
if (!list) return;
|
|
|
|
if (models.length === 0) {
|
|
list.innerHTML = '<div style="color: var(--bp-text-muted); text-align: center; padding: 20px;">Keine Modelle installiert</div>';
|
|
return;
|
|
}
|
|
|
|
list.innerHTML = models.map(m => {
|
|
const sizeGB = (m.size / (1024 * 1024 * 1024)).toFixed(1);
|
|
const details = m.details || {};
|
|
return '<div style="display: flex; justify-content: space-between; align-items: center; padding: 16px; background: var(--bp-surface); border-radius: 8px; border: 1px solid var(--bp-border);">' +
|
|
'<div style="display: flex; flex-direction: column; gap: 4px;">' +
|
|
'<span style="color: var(--bp-text); font-size: 16px; font-weight: 600;">' + m.name + '</span>' +
|
|
'<span style="color: var(--bp-text-muted); font-size: 13px;">' + (details.parameter_size || '') + ' | ' + (details.quantization_level || '') + '</span>' +
|
|
'</div>' +
|
|
'<span style="color: var(--bp-primary); font-size: 14px; font-weight: 600;">' + sizeGB + ' GB</span>' +
|
|
'</div>';
|
|
}).join('');
|
|
}
|
|
|
|
// Power Controls
|
|
async function macMiniWake() {
|
|
if (!confirm('Mac Mini per Wake-on-LAN aufwecken?')) return;
|
|
|
|
try {
|
|
const response = await fetch('/api/mac-mini/wake', { method: 'POST' });
|
|
const data = await response.json();
|
|
alert(data.message || 'Wake-on-LAN Paket gesendet');
|
|
setTimeout(macMiniRefreshStatus, 5000);
|
|
} catch (error) {
|
|
alert('Fehler: ' + error.message);
|
|
}
|
|
}
|
|
|
|
async function macMiniRestart() {
|
|
if (!confirm('Mac Mini wirklich neu starten?')) return;
|
|
|
|
try {
|
|
const response = await fetch('/api/mac-mini/restart', { method: 'POST' });
|
|
const data = await response.json();
|
|
alert(data.message || 'Neustart ausgelöst');
|
|
setTimeout(macMiniRefreshStatus, 60000);
|
|
} catch (error) {
|
|
alert('Fehler: ' + error.message);
|
|
}
|
|
}
|
|
|
|
async function macMiniShutdown() {
|
|
if (!confirm('Mac Mini wirklich herunterfahren?')) return;
|
|
|
|
try {
|
|
const response = await fetch('/api/mac-mini/shutdown', { method: 'POST' });
|
|
const data = await response.json();
|
|
alert(data.message || 'Shutdown ausgelöst');
|
|
setTimeout(macMiniRefreshStatus, 10000);
|
|
} catch (error) {
|
|
alert('Fehler: ' + error.message);
|
|
}
|
|
}
|
|
|
|
// Docker Controls
|
|
async function macMiniDockerUp() {
|
|
try {
|
|
const response = await fetch('/api/mac-mini/docker/up', { method: 'POST' });
|
|
const data = await response.json();
|
|
alert(data.message || 'Container werden gestartet...');
|
|
setTimeout(macMiniRefreshStatus, 10000);
|
|
} catch (error) {
|
|
alert('Fehler: ' + error.message);
|
|
}
|
|
}
|
|
|
|
async function macMiniDockerDown() {
|
|
if (!confirm('Alle Container stoppen?')) return;
|
|
|
|
try {
|
|
const response = await fetch('/api/mac-mini/docker/down', { method: 'POST' });
|
|
const data = await response.json();
|
|
alert(data.message || 'Container werden gestoppt...');
|
|
setTimeout(macMiniRefreshStatus, 5000);
|
|
} catch (error) {
|
|
alert('Fehler: ' + error.message);
|
|
}
|
|
}
|
|
|
|
// Ollama Model Download
|
|
async function macMiniPullModel() {
|
|
const input = document.getElementById('model-download-input');
|
|
const modelName = input ? input.value.trim() : '';
|
|
|
|
if (!modelName) {
|
|
alert('Bitte Modellnamen eingeben');
|
|
return;
|
|
}
|
|
|
|
if (macMiniState.downloadInProgress) {
|
|
alert('Download läuft bereits');
|
|
return;
|
|
}
|
|
|
|
macMiniState.downloadInProgress = true;
|
|
const btnPull = document.getElementById('btn-pull-model');
|
|
if (btnPull) btnPull.disabled = true;
|
|
|
|
const progressDiv = document.getElementById('download-progress');
|
|
const progressBar = document.getElementById('download-progress-bar');
|
|
const progressText = document.getElementById('download-progress-text');
|
|
const downloadStats = document.getElementById('download-stats');
|
|
const downloadLog = document.getElementById('download-log');
|
|
const modelNameEl = document.getElementById('download-model-name');
|
|
|
|
if (progressDiv) progressDiv.style.display = 'block';
|
|
if (modelNameEl) modelNameEl.textContent = modelName;
|
|
if (downloadLog) downloadLog.textContent = 'Starte Download...\\n';
|
|
|
|
try {
|
|
const response = await fetch('/api/mac-mini/ollama/pull', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ model: modelName })
|
|
});
|
|
|
|
const reader = response.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
|
|
const text = decoder.decode(value);
|
|
const lines = text.split('\\n').filter(l => l.trim());
|
|
|
|
for (const line of lines) {
|
|
try {
|
|
const data = JSON.parse(line);
|
|
|
|
if (data.status && downloadLog) {
|
|
downloadLog.textContent += data.status + '\\n';
|
|
downloadLog.scrollTop = downloadLog.scrollHeight;
|
|
}
|
|
|
|
if (data.total && data.completed) {
|
|
const percent = Math.round((data.completed / data.total) * 100);
|
|
const completedMB = (data.completed / (1024 * 1024)).toFixed(1);
|
|
const totalMB = (data.total / (1024 * 1024)).toFixed(1);
|
|
|
|
if (progressBar) progressBar.style.width = percent + '%';
|
|
if (progressText) progressText.textContent = percent + '%';
|
|
if (downloadStats) downloadStats.textContent = completedMB + ' MB / ' + totalMB + ' MB';
|
|
}
|
|
|
|
if (data.status === 'success' && downloadLog) {
|
|
downloadLog.textContent += '\\n✅ Download abgeschlossen!\\n';
|
|
if (progressBar) progressBar.style.width = '100%';
|
|
if (progressText) progressText.textContent = '100%';
|
|
}
|
|
} catch (e) {
|
|
if (downloadLog) downloadLog.textContent += line + '\\n';
|
|
}
|
|
}
|
|
}
|
|
|
|
setTimeout(macMiniRefreshStatus, 2000);
|
|
|
|
} catch (error) {
|
|
if (downloadLog) downloadLog.textContent += '\\n❌ Fehler: ' + error.message + '\\n';
|
|
} finally {
|
|
macMiniState.downloadInProgress = false;
|
|
if (btnPull) btnPull.disabled = false;
|
|
}
|
|
}
|
|
"""
|