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/modules/mac_mini_control.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

877 lines
23 KiB
Python

"""
Mac Mini Remote Control Module for BreakPilot Admin Panel.
Features:
- Power control (shutdown, restart, wake-on-LAN)
- Service status monitoring
- Docker container management
- Ollama model downloads with progress
"""
class MacMiniControlModule:
"""Mac Mini Remote Control Panel."""
MAC_MINI_IP = "192.168.178.100"
MAC_MINI_USER = "benjaminadmin"
@staticmethod
def get_css() -> str:
return """
/* ============================================
Mac Mini Control Panel
============================================ */
.mac-mini-dashboard {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
}
.mac-mini-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.mac-mini-header h1 {
color: var(--bp-text, #e5e7eb);
font-size: 28px;
margin: 0;
display: flex;
align-items: center;
gap: 12px;
}
.mac-mini-status-badge {
padding: 6px 16px;
border-radius: 20px;
font-size: 14px;
font-weight: 600;
}
.mac-mini-status-badge.online {
background: rgba(34, 197, 94, 0.2);
color: #22c55e;
border: 1px solid #22c55e;
}
.mac-mini-status-badge.offline {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
border: 1px solid #ef4444;
}
.mac-mini-status-badge.checking {
background: rgba(251, 191, 36, 0.2);
color: #fbbf24;
border: 1px solid #fbbf24;
}
/* Power Controls */
.power-controls {
display: flex;
gap: 12px;
margin-bottom: 24px;
}
.power-btn {
padding: 12px 24px;
border-radius: 8px;
border: none;
font-size: 14px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
transition: all 0.2s;
}
.power-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.power-btn.wake {
background: #22c55e;
color: white;
}
.power-btn.wake:hover:not(:disabled) {
background: #16a34a;
}
.power-btn.restart {
background: #f59e0b;
color: white;
}
.power-btn.restart:hover:not(:disabled) {
background: #d97706;
}
.power-btn.shutdown {
background: #ef4444;
color: white;
}
.power-btn.shutdown:hover:not(:disabled) {
background: #dc2626;
}
.power-btn.refresh {
background: var(--bp-surface, #1e293b);
color: var(--bp-text, #e5e7eb);
border: 1px solid var(--bp-border, #334155);
}
.power-btn.refresh:hover:not(:disabled) {
background: var(--bp-surface-elevated, #334155);
}
/* Status Grid */
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.status-card {
background: var(--bp-surface-elevated, #1e293b);
border: 1px solid var(--bp-border, #334155);
border-radius: 12px;
padding: 20px;
}
.status-card h3 {
color: var(--bp-text, #e5e7eb);
font-size: 16px;
margin: 0 0 16px 0;
display: flex;
align-items: center;
gap: 8px;
}
.status-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid var(--bp-border, #334155);
}
.status-item:last-child {
border-bottom: none;
}
.status-item-name {
color: var(--bp-text-muted, #9ca3af);
font-size: 14px;
}
.status-item-value {
font-size: 14px;
font-weight: 500;
}
.status-item-value.ok {
color: #22c55e;
}
.status-item-value.error {
color: #ef4444;
}
.status-item-value.warning {
color: #fbbf24;
}
/* Docker Containers */
.container-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.container-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: var(--bp-surface, #0f172a);
border-radius: 8px;
border: 1px solid var(--bp-border, #334155);
}
.container-name {
color: var(--bp-text, #e5e7eb);
font-size: 14px;
font-family: monospace;
}
.container-status {
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
.container-status.healthy {
background: rgba(34, 197, 94, 0.2);
color: #22c55e;
}
.container-status.running {
background: rgba(59, 130, 246, 0.2);
color: #3b82f6;
}
.container-status.stopped {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
/* Ollama Section */
.ollama-section {
background: var(--bp-surface-elevated, #1e293b);
border: 1px solid var(--bp-border, #334155);
border-radius: 12px;
padding: 20px;
margin-bottom: 24px;
}
.ollama-section h3 {
color: var(--bp-text, #e5e7eb);
font-size: 18px;
margin: 0 0 16px 0;
display: flex;
align-items: center;
gap: 8px;
}
.model-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 20px;
}
.model-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
background: var(--bp-surface, #0f172a);
border-radius: 8px;
border: 1px solid var(--bp-border, #334155);
}
.model-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.model-name {
color: var(--bp-text, #e5e7eb);
font-size: 16px;
font-weight: 600;
}
.model-details {
color: var(--bp-text-muted, #9ca3af);
font-size: 13px;
}
.model-size {
color: var(--bp-primary, #6C1B1B);
font-size: 14px;
font-weight: 600;
}
/* Download Section */
.download-section {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid var(--bp-border, #334155);
}
.download-input-row {
display: flex;
gap: 12px;
margin-bottom: 16px;
}
.download-input {
flex: 1;
padding: 12px 16px;
background: var(--bp-surface, #0f172a);
border: 1px solid var(--bp-border, #334155);
border-radius: 8px;
color: var(--bp-text, #e5e7eb);
font-size: 14px;
}
.download-input::placeholder {
color: var(--bp-text-muted, #9ca3af);
}
.download-btn {
padding: 12px 24px;
background: var(--bp-primary, #6C1B1B);
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.download-btn:hover:not(:disabled) {
background: #7f1d1d;
}
.download-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Download Progress */
.download-progress {
display: none;
margin-top: 16px;
}
.download-progress.active {
display: block;
}
.progress-header {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
}
.progress-model {
color: var(--bp-text, #e5e7eb);
font-weight: 600;
}
.progress-stats {
color: var(--bp-text-muted, #9ca3af);
font-size: 13px;
}
.progress-bar-container {
height: 24px;
background: var(--bp-surface, #0f172a);
border-radius: 12px;
overflow: hidden;
position: relative;
}
.progress-bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--bp-primary, #6C1B1B), #991b1b);
border-radius: 12px;
transition: width 0.3s ease;
position: relative;
}
.progress-bar-fill::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
90deg,
transparent,
rgba(255,255,255,0.1),
transparent
);
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
.progress-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 12px;
font-weight: 600;
text-shadow: 0 1px 2px rgba(0,0,0,0.5);
}
/* Log Output */
.log-output {
margin-top: 16px;
padding: 16px;
background: #0a0a0a;
border-radius: 8px;
font-family: monospace;
font-size: 12px;
color: #22c55e;
max-height: 200px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
}
/* Responsive */
@media (max-width: 768px) {
.power-controls {
flex-wrap: wrap;
}
.power-btn {
flex: 1;
min-width: 120px;
justify-content: center;
}
}
"""
@staticmethod
def get_html() -> str:
return """
<div class="mac-mini-dashboard" id="mac-mini-dashboard">
<!-- Header -->
<div class="mac-mini-header">
<h1>
<span style="font-size: 32px;">🖥️</span>
Mac Mini Control
</h1>
<span class="mac-mini-status-badge checking" id="mac-mini-overall-status">
Prüfe...
</span>
</div>
<!-- Power Controls -->
<div class="power-controls">
<button class="power-btn wake" onclick="macMiniWake()" id="btn-wake">
⚡ Wake on LAN
</button>
<button class="power-btn restart" onclick="macMiniRestart()" id="btn-restart">
🔄 Neustart
</button>
<button class="power-btn shutdown" onclick="macMiniShutdown()" id="btn-shutdown">
⏻ Herunterfahren
</button>
<button class="power-btn refresh" onclick="macMiniRefreshStatus()">
🔍 Status aktualisieren
</button>
</div>
<!-- Status Grid -->
<div class="status-grid">
<!-- Connection Status -->
<div class="status-card">
<h3>🌐 Verbindung</h3>
<div class="status-item">
<span class="status-item-name">IP-Adresse</span>
<span class="status-item-value" id="mac-mini-ip">192.168.178.163</span>
</div>
<div class="status-item">
<span class="status-item-name">SSH</span>
<span class="status-item-value" id="status-ssh">--</span>
</div>
<div class="status-item">
<span class="status-item-name">Ping</span>
<span class="status-item-value" id="status-ping">--</span>
</div>
</div>
<!-- Services Status -->
<div class="status-card">
<h3>⚙️ Services</h3>
<div class="status-item">
<span class="status-item-name">Backend API</span>
<span class="status-item-value" id="status-backend">--</span>
</div>
<div class="status-item">
<span class="status-item-name">Ollama</span>
<span class="status-item-value" id="status-ollama">--</span>
</div>
<div class="status-item">
<span class="status-item-name">Docker</span>
<span class="status-item-value" id="status-docker">--</span>
</div>
</div>
<!-- System Info -->
<div class="status-card">
<h3>💻 System</h3>
<div class="status-item">
<span class="status-item-name">Uptime</span>
<span class="status-item-value" id="status-uptime">--</span>
</div>
<div class="status-item">
<span class="status-item-name">CPU Load</span>
<span class="status-item-value" id="status-cpu">--</span>
</div>
<div class="status-item">
<span class="status-item-name">Memory</span>
<span class="status-item-value" id="status-memory">--</span>
</div>
</div>
</div>
<!-- Docker Containers -->
<div class="status-card" style="margin-bottom: 24px;">
<h3>🐳 Docker Container</h3>
<div class="container-list" id="docker-container-list">
<div style="color: var(--bp-text-muted); text-align: center; padding: 20px;">
Lade Container-Status...
</div>
</div>
<div style="margin-top: 16px; display: flex; gap: 8px;">
<button class="power-btn refresh" onclick="macMiniDockerUp()" style="flex: 1;">
▶️ Container starten
</button>
<button class="power-btn refresh" onclick="macMiniDockerDown()" style="flex: 1;">
⏹️ Container stoppen
</button>
</div>
</div>
<!-- Ollama Section -->
<div class="ollama-section">
<h3>🤖 Ollama LLM Modelle</h3>
<div class="model-list" id="ollama-model-list">
<div style="color: var(--bp-text-muted); text-align: center; padding: 20px;">
Lade Modelle...
</div>
</div>
<div class="download-section">
<h4 style="color: var(--bp-text); margin: 0 0 12px 0;">📥 Neues Modell herunterladen</h4>
<div class="download-input-row">
<input type="text" class="download-input" id="model-download-input"
placeholder="Modellname (z.B. llama3.2, mistral, qwen2.5:7b)">
<button class="download-btn" onclick="macMiniPullModel()" id="btn-pull-model">
Herunterladen
</button>
</div>
<div class="download-progress" id="download-progress">
<div class="progress-header">
<span class="progress-model" id="download-model-name">--</span>
<span class="progress-stats" id="download-stats">-- / --</span>
</div>
<div class="progress-bar-container">
<div class="progress-bar-fill" id="download-progress-bar" style="width: 0%"></div>
<span class="progress-text" id="download-progress-text">0%</span>
</div>
<div class="log-output" id="download-log"></div>
</div>
</div>
</div>
</div>
"""
@staticmethod
def get_js() -> str:
return """
// Mac Mini Control State
let macMiniState = {
ip: '192.168.178.163',
isOnline: false,
downloadInProgress: false,
pollInterval: null
};
// Initialize Mac Mini Control
function initMacMiniControl() {
macMiniRefreshStatus();
// Auto-refresh every 30 seconds
macMiniState.pollInterval = setInterval(macMiniRefreshStatus, 30000);
}
// Refresh all status
async function macMiniRefreshStatus() {
const statusBadge = document.getElementById('mac-mini-overall-status');
statusBadge.className = 'mac-mini-status-badge checking';
statusBadge.textContent = 'Prüfe...';
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
if (data.online) {
statusBadge.className = 'mac-mini-status-badge online';
statusBadge.textContent = 'Online';
} else {
statusBadge.className = 'mac-mini-status-badge offline';
statusBadge.textContent = 'Offline';
}
// Update IP
document.getElementById('mac-mini-ip').textContent = macMiniState.ip;
// Update connection status
updateStatusValue('status-ssh', data.ssh ? 'Verbunden' : 'Nicht verfügbar', data.ssh);
updateStatusValue('status-ping', data.ping ? 'OK' : 'Timeout', data.ping);
// Update services
updateStatusValue('status-backend', data.backend ? 'Läuft' : 'Offline', data.backend);
updateStatusValue('status-ollama', data.ollama ? 'Läuft' : 'Offline', data.ollama);
updateStatusValue('status-docker', data.docker ? 'Läuft' : 'Offline', data.docker);
// Update system info
document.getElementById('status-uptime').textContent = data.uptime || '--';
document.getElementById('status-cpu').textContent = data.cpu_load || '--';
document.getElementById('status-memory').textContent = data.memory || '--';
// Update Docker containers
updateDockerContainers(data.containers || []);
// Update Ollama models
updateOllamaModels(data.models || []);
// Enable/disable buttons based on status
document.getElementById('btn-wake').disabled = data.online;
document.getElementById('btn-restart').disabled = !data.online;
document.getElementById('btn-shutdown').disabled = !data.online;
} catch (error) {
console.error('Error fetching Mac Mini status:', error);
statusBadge.className = 'mac-mini-status-badge offline';
statusBadge.textContent = 'Fehler';
}
}
function updateStatusValue(elementId, text, isOk) {
const el = document.getElementById(elementId);
el.textContent = text;
el.className = 'status-item-value ' + (isOk ? 'ok' : 'error');
}
function updateDockerContainers(containers) {
const list = document.getElementById('docker-container-list');
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 statusClass = c.status.includes('healthy') ? 'healthy' :
c.status.includes('Up') ? 'running' : 'stopped';
return `
<div class="container-item">
<span class="container-name">${c.name}</span>
<span class="container-status ${statusClass}">${c.status}</span>
</div>
`;
}).join('');
}
function updateOllamaModels(models) {
const list = document.getElementById('ollama-model-list');
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);
return `
<div class="model-item">
<div class="model-info">
<span class="model-name">${m.name}</span>
<span class="model-details">${m.details?.parameter_size || ''} | ${m.details?.quantization_level || ''}</span>
</div>
<span class="model-size">${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.value.trim();
if (!modelName) {
alert('Bitte Modellnamen eingeben');
return;
}
if (macMiniState.downloadInProgress) {
alert('Download läuft bereits');
return;
}
macMiniState.downloadInProgress = true;
document.getElementById('btn-pull-model').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');
progressDiv.classList.add('active');
modelNameEl.textContent = modelName;
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.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);
progressBar.style.width = percent + '%';
progressText.textContent = percent + '%';
downloadStats.textContent = `${completedMB} MB / ${totalMB} MB`;
}
if (data.status === 'success') {
downloadLog.textContent += '\\n✅ Download abgeschlossen!\\n';
progressBar.style.width = '100%';
progressText.textContent = '100%';
}
} catch (e) {
// Not JSON, just log it
downloadLog.textContent += line + '\\n';
}
}
}
// Refresh models list
setTimeout(macMiniRefreshStatus, 2000);
} catch (error) {
downloadLog.textContent += '\\n❌ Fehler: ' + error.message + '\\n';
} finally {
macMiniState.downloadInProgress = false;
document.getElementById('btn-pull-model').disabled = false;
}
}
// Cleanup on panel hide
function cleanupMacMiniControl() {
if (macMiniState.pollInterval) {
clearInterval(macMiniState.pollInterval);
macMiniState.pollInterval = null;
}
}
"""
@classmethod
def render(cls) -> str:
return f"""
<style>{cls.get_css()}</style>
{cls.get_html()}
<script>{cls.get_js()}</script>
"""