feat(dashboard): Add Night Mode widget to dashboard
Add a compact Night Mode widget to the main dashboard that allows: - Quick toggle of night mode on/off - View countdown to next scheduled action - Manual start/stop of all services - See count of running vs stopped services The widget links to the full night-mode settings page for detailed configuration. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import { getStoredRole, isCategoryVisibleForRole, RoleId } from '@/lib/roles'
|
||||
import { CategoryCard } from '@/components/common/ModuleCard'
|
||||
import { InfoNote } from '@/components/common/InfoBox'
|
||||
import { ServiceStatus } from '@/components/common/ServiceStatus'
|
||||
import { NightModeWidget } from '@/components/dashboard/NightModeWidget'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface Stats {
|
||||
@@ -111,7 +112,18 @@ export default function DashboardPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Infrastructure & System Status */}
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Infrastruktur</h2>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
{/* Night Mode Widget */}
|
||||
<NightModeWidget />
|
||||
|
||||
{/* System Status */}
|
||||
<ServiceStatus />
|
||||
</div>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Aktivitaet</h2>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Recent DSR */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm">
|
||||
@@ -127,9 +139,6 @@ export default function DashboardPage() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Status */}
|
||||
<ServiceStatus />
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
|
||||
231
admin-v2/components/dashboard/NightModeWidget.tsx
Normal file
231
admin-v2/components/dashboard/NightModeWidget.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user