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>
232 lines
8.6 KiB
TypeScript
232 lines
8.6 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useState, useCallback } from 'react'
|
|
import Link from 'next/link'
|
|
|
|
interface NightModeConfig {
|
|
enabled: boolean
|
|
shutdown_time: string
|
|
startup_time: string
|
|
last_action: string | null
|
|
last_action_time: string | null
|
|
}
|
|
|
|
interface NightModeStatus {
|
|
config: NightModeConfig
|
|
current_time: string
|
|
next_action: string | null
|
|
next_action_time: string | null
|
|
time_until_next_action: string | null
|
|
services_status: Record<string, string>
|
|
}
|
|
|
|
export function NightModeWidget() {
|
|
const [status, setStatus] = useState<NightModeStatus | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
const fetchData = useCallback(async () => {
|
|
try {
|
|
const response = await fetch('/api/admin/night-mode')
|
|
if (response.ok) {
|
|
setStatus(await response.json())
|
|
setError(null)
|
|
} else {
|
|
setError('Nicht erreichbar')
|
|
}
|
|
} catch {
|
|
setError('Verbindung fehlgeschlagen')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
fetchData()
|
|
const interval = setInterval(fetchData, 30000)
|
|
return () => clearInterval(interval)
|
|
}, [fetchData])
|
|
|
|
const toggleEnabled = async () => {
|
|
if (!status) return
|
|
setActionLoading('toggle')
|
|
|
|
try {
|
|
const response = await fetch('/api/admin/night-mode', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ ...status.config, enabled: !status.config.enabled }),
|
|
})
|
|
if (response.ok) {
|
|
fetchData()
|
|
}
|
|
} catch {
|
|
// ignore
|
|
} finally {
|
|
setActionLoading(null)
|
|
}
|
|
}
|
|
|
|
const executeAction = async (action: 'start' | 'stop') => {
|
|
setActionLoading(action)
|
|
try {
|
|
const response = await fetch('/api/admin/night-mode/execute', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ action }),
|
|
})
|
|
if (response.ok) {
|
|
fetchData()
|
|
}
|
|
} catch {
|
|
// ignore
|
|
} finally {
|
|
setActionLoading(null)
|
|
}
|
|
}
|
|
|
|
const runningCount = Object.values(status?.services_status || {}).filter(
|
|
s => s.toLowerCase() === 'running' || s.toLowerCase().includes('up')
|
|
).length
|
|
const totalCount = Object.keys(status?.services_status || {}).length
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-4">
|
|
<div className="animate-pulse flex items-center gap-3">
|
|
<div className="w-10 h-10 bg-slate-200 rounded-full" />
|
|
<div className="flex-1">
|
|
<div className="h-4 bg-slate-200 rounded w-24 mb-2" />
|
|
<div className="h-3 bg-slate-200 rounded w-32" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 bg-slate-100 rounded-full flex items-center justify-center">
|
|
<svg className="w-5 h-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<h3 className="font-medium text-slate-900">Nachtabschaltung</h3>
|
|
<p className="text-sm text-red-500">{error}</p>
|
|
</div>
|
|
</div>
|
|
<Link href="/infrastructure/night-mode" className="text-sm text-primary-600 hover:text-primary-700">
|
|
Details
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
|
|
{/* Header */}
|
|
<div className="px-4 py-3 border-b border-slate-200 flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
|
status?.config.enabled ? 'bg-orange-100' : 'bg-slate-100'
|
|
}`}>
|
|
<svg className={`w-5 h-5 ${status?.config.enabled ? 'text-orange-600' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<h3 className="font-semibold text-slate-900">Nachtabschaltung</h3>
|
|
<p className="text-sm text-slate-500">
|
|
{status?.config.enabled
|
|
? `${status.config.shutdown_time} - ${status.config.startup_time}`
|
|
: 'Deaktiviert'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<Link href="/infrastructure/night-mode" className="text-sm text-primary-600 hover:text-primary-700">
|
|
Einstellungen
|
|
</Link>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="p-4">
|
|
{/* Status Row */}
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-4">
|
|
{/* Toggle */}
|
|
<button
|
|
onClick={toggleEnabled}
|
|
disabled={actionLoading !== null}
|
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
|
status?.config.enabled ? 'bg-orange-600' : 'bg-slate-300'
|
|
} ${actionLoading === 'toggle' ? 'opacity-50' : ''}`}
|
|
>
|
|
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
|
|
status?.config.enabled ? 'translate-x-6' : 'translate-x-1'
|
|
}`} />
|
|
</button>
|
|
|
|
{/* Countdown */}
|
|
{status?.config.enabled && status.time_until_next_action && (
|
|
<div className="flex items-center gap-2">
|
|
<span className={`text-sm font-medium ${
|
|
status.next_action === 'shutdown' ? 'text-red-600' : 'text-green-600'
|
|
}`}>
|
|
{status.next_action === 'shutdown' ? '⏸' : '▶'} {status.time_until_next_action}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Service Count */}
|
|
<div className="text-sm text-slate-500">
|
|
<span className="font-semibold text-green-600">{runningCount}</span>
|
|
<span className="text-slate-400">/{totalCount}</span>
|
|
<span className="ml-1">aktiv</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Action Buttons */}
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => executeAction('stop')}
|
|
disabled={actionLoading !== null}
|
|
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 bg-red-50 text-red-700 rounded-lg text-sm font-medium hover:bg-red-100 disabled:opacity-50 transition-colors"
|
|
>
|
|
{actionLoading === 'stop' ? (
|
|
<span className="animate-spin text-xs">⟳</span>
|
|
) : (
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 10h6v4H9z" />
|
|
</svg>
|
|
)}
|
|
Stoppen
|
|
</button>
|
|
<button
|
|
onClick={() => executeAction('start')}
|
|
disabled={actionLoading !== null}
|
|
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 bg-green-50 text-green-700 rounded-lg text-sm font-medium hover:bg-green-100 disabled:opacity-50 transition-colors"
|
|
>
|
|
{actionLoading === 'start' ? (
|
|
<span className="animate-spin text-xs">⟳</span>
|
|
) : (
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
)}
|
|
Starten
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|