This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
breakpilot-pwa/backend/frontend/components/admin_panel_js.py
Benjamin Admin 21a844cb8a fix: Restore all files lost during destructive rebase
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>
2026-02-09 09:51:32 +01:00

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()">&times;</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;
}
}
"""