syco.me Homelab Dashboard

This commit is contained in:
2026-05-10 21:23:42 +02:00
parent 933e492d15
commit 90de2c1674
45 changed files with 6666 additions and 0 deletions
+59
View File
@@ -0,0 +1,59 @@
import { Header } from './components/Header'
import { ProxmoxWidget } from './components/widgets/ProxmoxWidget'
import { NasWidget } from './components/widgets/NasWidget'
import { AdGuardWidget } from './components/widgets/AdGuardWidget'
import { CrowdSecWidget } from './components/widgets/CrowdSecWidget'
import { HeadscaleWidget } from './components/widgets/HeadscaleWidget'
import { KumaWidget } from './components/widgets/KumaWidget'
import { FritzboxWidget } from './components/widgets/FritzboxWidget'
import { AuthentikWidget } from './components/widgets/AuthentikWidget'
import { VaultwardenWidget } from './components/widgets/VaultwardenWidget'
import { ArrCalendarWidget } from './components/widgets/ArrCalendarWidget'
import { ArrStatsWidget } from './components/widgets/ArrStatsWidget'
import { QbittorrentWidget } from './components/widgets/QbittorrentWidget'
import { GrafanaWidget } from './components/widgets/GrafanaWidget'
import { PrometheusWidget } from './components/widgets/PrometheusWidget'
import { LokiWidget } from './components/widgets/LokiWidget'
export default function App() {
return (
<>
<div className="blob blob-1" />
<div className="blob blob-2" />
<div className="shell">
<Header />
<div className="section-label">Infrastructure</div>
<div className="infra-grid">
<ProxmoxWidget />
<NasWidget />
<AdGuardWidget />
<HeadscaleWidget />
<FritzboxWidget />
</div>
<div className="section-label">Media</div>
<div className="infra-grid">
<ArrCalendarWidget />
<ArrStatsWidget />
<QbittorrentWidget />
</div>
<div className="section-label">Monitoring</div>
<div className="infra-grid">
<KumaWidget />
<CrowdSecWidget />
<GrafanaWidget />
<PrometheusWidget />
<LokiWidget />
</div>
<div className="section-label">Access</div>
<div className="infra-grid">
<AuthentikWidget />
<VaultwardenWidget />
</div>
</div>
</>
)
}
+35
View File
@@ -0,0 +1,35 @@
import { useState, useEffect } from 'react'
export function Header() {
const [time, setTime] = useState('')
const [date, setDate] = useState('')
useEffect(() => {
const tick = () => {
const now = new Date()
setTime(now.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' }))
setDate(now.toLocaleDateString('en-GB', { weekday: 'long', day: '2-digit', month: 'long', year: 'numeric' }))
}
tick()
const id = setInterval(tick, 1000)
return () => clearInterval(id)
}, [])
return (
<header>
<div className="logo">
<div className="logo-mark">S</div>
<div>
<div className="logo-text">syco.me</div>
<div className="logo-sub">homelab dashboard</div>
</div>
</div>
<div className="topbar-right">
<div className="clock-widget">
<div className="clock-time">{time}</div>
<div className="clock-date">{date}</div>
</div>
</div>
</header>
)
}
+43
View File
@@ -0,0 +1,43 @@
interface Props {
name: string
envVars: string[]
}
export function PlaceholderWidget({ name, envVars }: Props) {
return (
<div className="card">
<div className="widget-header">
<div className="widget-title">
<span className="dot" style={{ background: 'var(--muted)' }} />
{name}
</div>
</div>
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: 80,
gap: 8,
}}>
<div style={{ fontSize: 10, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: 1 }}>
Not configured
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, alignItems: 'center' }}>
{envVars.map(v => (
<code key={v} style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: 9,
color: 'var(--text2)',
background: 'var(--surface2)',
padding: '2px 6px',
borderRadius: 3,
}}>
{v}
</code>
))}
</div>
</div>
</div>
)
}
+69
View File
@@ -0,0 +1,69 @@
import { useApi } from '../../hooks/useApi'
import { AdGuardData } from '../../types'
function fmt(n: number): string {
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
return String(n)
}
export function AdGuardWidget() {
const { data, loading, error } = useApi<AdGuardData>('/api/adguard/stats')
const maxQ = data ? Math.max(...data.timeSlots.map(s => s.queries), 1) : 1
return (
<div className="card">
<div className="widget-header">
<div className="widget-title">
<span className="dot" style={{ background: 'var(--green)' }} />
AdGuard Home
</div>
<div className="widget-badge">.50</div>
</div>
{loading && <div className="widget-loading">Connecting</div>}
{error && <div className="widget-error"> {error}</div>}
{data && (
<>
<div className="stat-row">
<div className="stat-item">
<div className="stat-value">{fmt(data.totalQueries)}</div>
<div className="stat-label">queries / 24h</div>
</div>
<div className="stat-item">
<div className="stat-value red">{data.blockedPercent}%</div>
<div className="stat-label">blocked</div>
</div>
</div>
<div className="ag-chart">
{data.timeSlots.map((slot, i) => (
<div key={i} style={{ flex: 1, display: 'flex', alignItems: 'flex-end', gap: 1, height: '100%' }}>
<div
className="ag-bar"
style={{ flex: 1, height: `${Math.max(2, Math.round((slot.queries / maxQ) * 100))}%` }}
/>
<div
className="ag-bar blocked"
style={{ flex: 1, height: `${Math.max(2, Math.round((slot.blocked / maxQ) * 100))}%` }}
/>
</div>
))}
</div>
<div className="ag-legend">
<div className="ag-legend-item">
<div className="ag-legend-swatch" style={{ background: 'var(--accent)', opacity: 0.5 }} />
queries
</div>
<div className="ag-legend-item">
<div className="ag-legend-swatch" style={{ background: 'var(--red)', opacity: 0.6 }} />
blocked
</div>
</div>
</>
)}
</div>
)
}
@@ -0,0 +1,217 @@
import { useState, useEffect } from 'react'
import { ArrCalendarData, ArrCalendarItem } from '../../types'
const TYPE_COLOR: Record<ArrCalendarItem['type'], string> = {
movie: 'var(--accent2)',
episode: 'var(--green)',
album: 'var(--yellow)',
}
const TYPE_LABEL: Record<ArrCalendarItem['type'], string> = {
movie: 'Movie',
episode: 'TV',
album: 'Music',
}
const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
function monthRange(year: number, month: number) {
const start = new Date(year, month, 1).toISOString().slice(0, 10)
const end = new Date(year, month + 1, 0).toISOString().slice(0, 10)
return { start, end }
}
function pad(n: number) { return String(n).padStart(2, '0') }
export function ArrCalendarWidget() {
const today = new Date()
const [year, setYear] = useState(today.getFullYear())
const [month, setMonth] = useState(today.getMonth())
const [data, setData] = useState<ArrCalendarData | null>(null)
const [loading, setLoading] = useState(true)
const [selected, setSelected] = useState<string | null>(null)
useEffect(() => {
setLoading(true)
const { start, end } = monthRange(year, month)
fetch(`/api/arr/calendar?start=${start}&end=${end}`)
.then(r => r.json())
.then(d => { setData(d); setLoading(false) })
.catch(() => setLoading(false))
}, [year, month])
function prevMonth() {
if (month === 0) { setYear(y => y - 1); setMonth(11) }
else setMonth(m => m - 1)
setSelected(null)
}
function nextMonth() {
if (month === 11) { setYear(y => y + 1); setMonth(0) }
else setMonth(m => m + 1)
setSelected(null)
}
const byDate: Record<string, ArrCalendarItem[]> = {}
for (const item of data?.items ?? []) {
;(byDate[item.date] ??= []).push(item)
}
const firstDay = new Date(year, month, 1)
const daysInMonth = new Date(year, month + 1, 0).getDate()
// Monday=0 … Sunday=6
const startOffset = (firstDay.getDay() + 6) % 7
const todayStr = `${today.getFullYear()}-${pad(today.getMonth()+1)}-${pad(today.getDate())}`
const cells: (number | null)[] = [
...Array(startOffset).fill(null),
...Array.from({ length: daysInMonth }, (_, i) => i + 1),
]
while (cells.length % 7 !== 0) cells.push(null)
const selectedItems = selected ? (byDate[selected] ?? []) : []
return (
<div className="card" style={{ gridColumn: 'span 2', alignSelf: 'start' }}>
<div className="widget-header">
<div className="widget-title">
<span className="dot" style={{ background: 'var(--accent2)' }} />
Media Calendar
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ display: 'flex', gap: 6 }}>
{(['movie', 'episode', 'album'] as const).map(t => (
<span key={t} style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: 9,
color: TYPE_COLOR[t],
background: 'rgba(255,255,255,0.05)',
padding: '2px 6px',
borderRadius: 4,
}}>{TYPE_LABEL[t]}</span>
))}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button onClick={prevMonth} style={navBtn}></button>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: 11, color: 'var(--text)', minWidth: 90, textAlign: 'center' }}>
{firstDay.toLocaleDateString('en-GB', { month: 'long', year: 'numeric' })}
</span>
<button onClick={nextMonth} style={navBtn}></button>
</div>
</div>
</div>
{loading && <div className="widget-loading">Loading</div>}
{!loading && (
<>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 2, marginBottom: 8 }}>
{WEEKDAYS.map(d => (
<div key={d} style={{ textAlign: 'center', fontSize: 10, color: 'var(--muted)', padding: '2px 0', textTransform: 'uppercase', letterSpacing: 1 }}>
{d}
</div>
))}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 2 }}>
{cells.map((day, i) => {
if (!day) return <div key={i} />
const dateStr = `${year}-${pad(month+1)}-${pad(day)}`
const events = byDate[dateStr] ?? []
const isToday = dateStr === todayStr
const isSel = dateStr === selected
return (
<div
key={i}
onClick={() => setSelected(isSel ? null : dateStr)}
style={{
background: isSel ? 'rgba(255,255,255,0.1)' : isToday ? 'rgba(255,255,255,0.05)' : 'transparent',
border: isToday ? '1px solid rgba(255,255,255,0.15)' : '1px solid transparent',
borderRadius: 6,
padding: '4px 6px',
cursor: events.length > 0 ? 'pointer' : 'default',
minHeight: 52,
display: 'flex',
flexDirection: 'column',
gap: 2,
}}
>
<span style={{ fontSize: 11, color: isToday ? 'var(--text)' : 'var(--text2)', fontWeight: isToday ? 600 : 400 }}>
{day}
</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{events.slice(0, 3).map((e, j) => (
<div key={j} style={{
fontSize: 9,
color: TYPE_COLOR[e.type],
background: 'rgba(255,255,255,0.05)',
borderLeft: `2px solid ${TYPE_COLOR[e.type]}`,
paddingLeft: 3,
borderRadius: 2,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
opacity: e.downloaded ? 0.5 : 1,
}}>
{e.title}
</div>
))}
{events.length > 3 && (
<div style={{ fontSize: 9, color: 'var(--muted)' }}>+{events.length - 3} more</div>
)}
</div>
</div>
)
})}
</div>
{selected && selectedItems.length > 0 && (
<div style={{ marginTop: 12, borderTop: '1px solid var(--border)', paddingTop: 10, display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ fontSize: 10, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 2 }}>
{new Date(selected + 'T12:00:00').toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long' })}
</div>
{selectedItems.map((item, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: 9,
color: TYPE_COLOR[item.type],
background: 'rgba(255,255,255,0.05)',
padding: '1px 5px',
borderRadius: 3,
flexShrink: 0,
width: 38,
textAlign: 'center',
}}>
{TYPE_LABEL[item.type]}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 12, color: item.downloaded ? 'var(--muted)' : 'var(--text)', textDecoration: item.downloaded ? 'line-through' : 'none', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{item.title}
</div>
{item.subtitle && (
<div style={{ fontSize: 10, color: 'var(--muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{item.subtitle}
</div>
)}
</div>
</div>
))}
</div>
)}
</>
)}
</div>
)
}
const navBtn: React.CSSProperties = {
background: 'none',
border: 'none',
color: 'var(--text2)',
fontSize: 16,
cursor: 'pointer',
padding: '0 4px',
lineHeight: 1,
}
+76
View File
@@ -0,0 +1,76 @@
import { useApi } from '../../hooks/useApi'
import { ArrStatsData, ArrServiceStats } from '../../types'
function ServiceRow({ name, color, data, countLabel }: {
name: string
color: string
data: ArrServiceStats
countLabel: string
}) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: color, flexShrink: 0, display: 'inline-block' }} />
<span style={{ fontSize: 11, color: 'var(--text2)', textTransform: 'uppercase', letterSpacing: 1 }}>{name}</span>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<StatBox value={data.movies ?? data.series ?? data.artists ?? 0} label={countLabel} />
<StatBox value={data.missing} label="missing" alert={data.missing > 0} />
<StatBox value={data.queue} label="in queue" highlight={data.queue > 0} />
</div>
</div>
)
}
function StatBox({ value, label, alert, highlight }: { value: number; label: string; alert?: boolean; highlight?: boolean }) {
return (
<div style={{
flex: 1,
background: 'var(--surface2)',
borderRadius: 6,
padding: '6px 8px',
display: 'flex',
flexDirection: 'column',
gap: 2,
}}>
<span style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: 16,
fontWeight: 600,
color: alert ? 'var(--red)' : highlight ? 'var(--yellow)' : 'var(--text)',
lineHeight: 1,
}}>
{value}
</span>
<span style={{ fontSize: 9, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: 1 }}>
{label}
</span>
</div>
)
}
export function ArrStatsWidget() {
const { data, loading, error } = useApi<ArrStatsData>('/api/arr/stats', 120_000)
return (
<div className="card" style={{ alignSelf: 'start' }}>
<div className="widget-header">
<div className="widget-title">
<span className="dot" style={{ background: 'var(--accent2)' }} />
Media
</div>
</div>
{loading && <div className="widget-loading">Connecting</div>}
{error && <div className="widget-error"> {error}</div>}
{data && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{data.radarr && <ServiceRow name="Radarr" color="var(--accent2)" data={{ ...data.radarr, movies: data.radarr.movies }} countLabel="movies" />}
{data.sonarr && <ServiceRow name="Sonarr" color="var(--green)" data={{ ...data.sonarr, series: data.sonarr.series }} countLabel="series" />}
{data.lidarr && <ServiceRow name="Lidarr" color="var(--yellow)" data={{ ...data.lidarr, artists: data.lidarr.artists }} countLabel="artists" />}
</div>
)}
</div>
)
}
@@ -0,0 +1,80 @@
import { useApi } from '../../hooks/useApi'
import { AuthentikData } from '../../types'
function timeAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime()
const m = Math.floor(diff / 60_000)
if (m < 1) return 'just now'
if (m < 60) return `${m}m ago`
const h = Math.floor(m / 60)
if (h < 24) return `${h}h ago`
return `${Math.floor(h / 24)}d ago`
}
export function AuthentikWidget() {
const { data, loading, error } = useApi<AuthentikData>('/api/authentik/stats')
return (
<div className="card">
<div className="widget-header">
<div className="widget-title">
<span className="dot" style={{ background: 'var(--accent2)' }} />
Authentik
</div>
<div className="widget-badge">.42</div>
</div>
{loading && <div className="widget-loading">Connecting</div>}
{error && <div className="widget-error"> {error}</div>}
{data && (
<>
<div className="stat-row" style={{ marginBottom: 14 }}>
<div className="stat-item">
<div className="stat-value accent">{data.userCount}</div>
<div className="stat-label">users</div>
</div>
<div className="stat-item">
<div className={`stat-value ${data.failedLast24h > 0 ? 'red' : 'green'}`}>
{data.failedLast24h}
</div>
<div className="stat-label">failed / 24h</div>
</div>
</div>
<div>
{data.recentLogins.map((e, i) => (
<div key={i} className="list-item">
<div className="list-item-left">
<div style={{
width: 6, height: 6, borderRadius: '50%', flexShrink: 0,
background: e.success ? 'var(--green)' : 'var(--red)',
}} />
<span>{e.username}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{e.clientIp && (
<span style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: 10,
color: 'var(--muted)',
}}>
{e.clientIp}
</span>
)}
<span style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: 10,
color: 'var(--text2)',
}}>
{timeAgo(e.created)}
</span>
</div>
</div>
))}
</div>
</>
)}
</div>
)
}
+116
View File
@@ -0,0 +1,116 @@
import { useApi } from '../../hooks/useApi'
import { CrowdSecData } from '../../types'
function timeAgo(iso: string) {
const diff = Date.now() - new Date(iso).getTime()
const m = Math.floor(diff / 60_000)
if (m < 60) return `${m}m ago`
const h = Math.floor(m / 60)
if (h < 24) return `${h}h ago`
return `${Math.floor(h / 24)}d ago`
}
export function CrowdSecWidget() {
const { data, loading, error } = useApi<CrowdSecData>('/api/crowdsec/stats', 60_000)
const allClear = data && data.activeBans === 0
return (
<div className="card">
<div className="widget-header">
<div className="widget-title">
<span className="dot" style={{ background: allClear ? 'var(--green)' : data ? 'var(--red)' : 'var(--muted)' }} />
CrowdSec
</div>
{data && (
<span style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: 10,
color: 'var(--text2)',
background: 'rgba(255,255,255,0.05)',
padding: '2px 8px',
borderRadius: 4,
}}>
{data.activeBans.toLocaleString()} active ban{data.activeBans !== 1 ? 's' : ''}
</span>
)}
</div>
{loading && <div className="widget-loading">Connecting</div>}
{error && <div className="widget-error"> {error}</div>}
{data && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', gap: 8 }}>
<StatBox label="Active bans" value={data.activeBans} alert={data.activeBans > 0} />
<StatBox label="Last 24 h" value={data.alertsLast24h} alert={data.alertsLast24h > 0} />
<StatBox label="This week" value={data.blocksThisWeek} />
</div>
{data.origins.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ fontSize: 10, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: 1 }}>Sources</div>
{data.origins.map(o => (
<div key={o.name} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 11, color: 'var(--text2)', width: 90, flexShrink: 0 }}>{o.name}</span>
<div style={{ flex: 1, height: 4, background: 'var(--surface2)', borderRadius: 2, overflow: 'hidden' }}>
<div style={{
height: '100%',
width: `${(o.count / data.activeBans) * 100}%`,
background: 'var(--red)',
borderRadius: 2,
opacity: 0.7,
}} />
</div>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: 10, color: 'var(--muted)', width: 50, textAlign: 'right', flexShrink: 0 }}>
{o.count.toLocaleString()}
</span>
</div>
))}
</div>
)}
{data.recent.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ fontSize: 10, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: 1 }}>Recent bans</div>
{data.recent.map((r, i) => (
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8 }}>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: 10, color: 'var(--text2)', flexShrink: 0 }}>{r.value}</span>
<span style={{ fontSize: 10, color: 'var(--muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.scenario}</span>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: 10, color: 'var(--muted)', flexShrink: 0 }}>{timeAgo(r.created_at)}</span>
</div>
))}
</div>
)}
</div>
)}
</div>
)
}
function StatBox({ label, value, alert }: { label: string; value: number; alert?: boolean }) {
return (
<div style={{
flex: 1,
background: 'var(--surface2)',
borderRadius: 6,
padding: '8px 10px',
display: 'flex',
flexDirection: 'column',
gap: 4,
}}>
<span style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: 20,
fontWeight: 600,
color: alert ? 'var(--red)' : 'var(--text)',
lineHeight: 1,
}}>
{value.toLocaleString()}
</span>
<span style={{ fontSize: 10, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: 1 }}>
{label}
</span>
</div>
)
}
+87
View File
@@ -0,0 +1,87 @@
import { useApi } from '../../hooks/useApi'
import { FritzboxData } from '../../types'
export function FritzboxWidget() {
const { data, loading, error } = useApi<FritzboxData>('/api/fritzbox/status')
const maxRate = data
? Math.max(...data.history.map(h => Math.max(h.rx, h.tx)), 1)
: 1
return (
<div className="card">
<div className="widget-header">
<div className="widget-title">
<span
className="dot"
style={{ background: data ? (data.connected ? 'var(--green)' : 'var(--red)') : 'var(--muted)' }}
/>
FritzBox
</div>
{data && (
<span className={`pill ${data.connected ? 'pill-green' : 'pill-red'}`}>
{data.connected ? 'online' : 'offline'}
</span>
)}
</div>
{loading && <div className="widget-loading">Connecting</div>}
{error && <div className="widget-error"> {error}</div>}
{data && (
<>
{data.externalIp && (
<div style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: 11,
color: 'var(--text2)',
marginBottom: 14,
}}>
{data.externalIp}
</div>
)}
<div className="stat-row" style={{ marginBottom: 12 }}>
<div className="stat-item">
<div className="stat-value green">{data.rxMbps}</div>
<div className="stat-label"> mbps</div>
</div>
<div className="stat-item">
<div className="stat-value accent">{data.txMbps}</div>
<div className="stat-label"> mbps</div>
</div>
</div>
{data.history.length > 1 && (
<>
<div className="ag-chart">
{data.history.map((h, i) => (
<div key={i} style={{ flex: 1, display: 'flex', alignItems: 'flex-end', gap: 1, height: '100%' }}>
<div
className="ag-bar"
style={{ flex: 1, height: `${Math.max(2, Math.round((h.rx / maxRate) * 100))}%` }}
/>
<div
className="ag-bar"
style={{ flex: 1, height: `${Math.max(2, Math.round((h.tx / maxRate) * 100))}%`, background: 'var(--accent2)', opacity: 0.5 }}
/>
</div>
))}
</div>
<div className="ag-legend">
<div className="ag-legend-item">
<div className="ag-legend-swatch" style={{ background: 'var(--accent)', opacity: 0.5 }} />
download
</div>
<div className="ag-legend-item">
<div className="ag-legend-swatch" style={{ background: 'var(--accent2)', opacity: 0.5 }} />
upload
</div>
</div>
</>
)}
</>
)}
</div>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { PlaceholderWidget } from '../PlaceholderWidget'
export function GrafanaWidget() {
return <PlaceholderWidget name="Grafana" envVars={['GRAFANA_HOST', 'GRAFANA_TOKEN']} />
}
@@ -0,0 +1,78 @@
import { useApi } from '../../hooks/useApi'
import { HeadscaleData } from '../../types'
function timeAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime()
const m = Math.floor(diff / 60_000)
if (m < 1) return 'just now'
if (m < 60) return `${m}m ago`
const h = Math.floor(m / 60)
if (h < 24) return `${h}h ago`
return `${Math.floor(h / 24)}d ago`
}
export function HeadscaleWidget() {
const { data, loading, error } = useApi<HeadscaleData>('/api/headscale/nodes')
return (
<div className="card">
<div className="widget-header">
<div className="widget-title">
<span className="dot" style={{ background: 'var(--accent2)' }} />
Headscale
</div>
{data && (
<div className="widget-badge">.43</div>
)}
</div>
{loading && <div className="widget-loading">Connecting</div>}
{error && <div className="widget-error"> {error}</div>}
{data && (
<>
<div className="stat-row" style={{ marginBottom: 14 }}>
<div className="stat-item">
<div className="stat-value green">{data.online}</div>
<div className="stat-label">online</div>
</div>
<div className="stat-item">
<div className="stat-value">{data.total}</div>
<div className="stat-label">total nodes</div>
</div>
</div>
<div>
{data.nodes.map(node => (
<div key={node.id} className="list-item">
<div className="list-item-left">
<div
style={{
width: 6, height: 6, borderRadius: '50%', flexShrink: 0,
background: node.online ? 'var(--green)' : 'var(--muted)',
}}
/>
<span>{node.name}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: 10,
color: 'var(--muted)',
}}>
{node.online ? node.ip : timeAgo(node.lastSeen)}
</span>
<span
className={`pill ${node.online ? 'pill-green' : 'pill-blue'}`}
>
{node.online ? 'online' : 'offline'}
</span>
</div>
</div>
))}
</div>
</>
)}
</div>
)
}
+83
View File
@@ -0,0 +1,83 @@
import { useApi } from '../../hooks/useApi'
import { KumaData } from '../../types'
const BEAT_COLOR: Record<number, string> = {
1: 'var(--green)',
0: 'var(--red)',
2: 'var(--yellow)',
3: 'var(--muted)',
}
export function KumaWidget() {
const { data, loading, error } = useApi<KumaData>('/api/kuma/monitors', 60_000)
const allUp = data && data.down === 0 && data.total > 0
return (
<div className="card" style={{ alignSelf: 'start' }}>
<div className="widget-header">
<div className="widget-title">
<span className="dot" style={{ background: allUp ? 'var(--green)' : data ? 'var(--red)' : 'var(--muted)' }} />
Uptime Kuma
</div>
{data && (
<span style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: 10,
color: 'var(--text2)',
background: 'rgba(255,255,255,0.05)',
padding: '2px 8px',
borderRadius: 4,
}}>
{data.total} monitors · {data.up} up
</span>
)}
</div>
{loading && <div className="widget-loading">Connecting</div>}
{error && <div className="widget-error"> {error}</div>}
{data && (
<div style={{
display: 'flex',
flexDirection: 'column',
gap: 5,
maxHeight: 130,
overflowY: 'auto',
overflowX: 'hidden',
paddingRight: 4,
}}>
{data.monitors.map(m => (
<div key={m.id}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 2 }}>
<span style={{ fontSize: 11, color: 'var(--text2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', marginRight: 6 }}>{m.name}</span>
{m.ping != null && (
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: 9, color: 'var(--muted)', flexShrink: 0 }}>
{m.ping}ms
</span>
)}
</div>
<div style={{ display: 'flex', gap: 2, height: 8 }}>
{m.beats.length === 0 ? (
<div style={{ flex: 1, background: 'var(--surface2)', borderRadius: 2 }} />
) : (
m.beats.map((s, i) => (
<div
key={i}
style={{
flex: 1,
background: BEAT_COLOR[s] ?? 'var(--muted)',
borderRadius: 2,
opacity: 0.7 + (i / m.beats.length) * 0.3,
}}
/>
))
)}
</div>
</div>
))}
</div>
)}
</div>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { PlaceholderWidget } from '../PlaceholderWidget'
export function LokiWidget() {
return <PlaceholderWidget name="Loki" envVars={['LOKI_HOST']} />
}
+86
View File
@@ -0,0 +1,86 @@
import { useApi } from '../../hooks/useApi'
import { SynologyData, SynologyInfoData } from '../../types'
import { formatBytes, formatUptime, pct, barColor } from '../../utils'
export function NasWidget() {
const { data, loading, error } = useApi<SynologyData>('/api/synology/storage')
const { data: info } = useApi<SynologyInfoData>('/api/synology/info')
const totals = data
? data.volumes.reduce((acc, v) => ({ used: acc.used + v.used, total: acc.total + v.total }), { used: 0, total: 0 })
: null
return (
<div className="card">
<div className="widget-header">
<div className="widget-title">
<span className="dot" style={{ background: 'var(--yellow)' }} />
{info?.model ? info.model : 'Synology NAS'}
</div>
<div className="widget-badge">.31</div>
</div>
{loading && <div className="widget-loading">Connecting</div>}
{error && <div className="widget-error"> {error}</div>}
{data && (
<>
{info && (
<div className="stat-row" style={{ marginBottom: 14 }}>
<div className="stat-item">
<div className="stat-value" style={{ fontSize: 14 }}>{info.dsmVersion}</div>
<div className="stat-label">DSM</div>
</div>
<div className="stat-item">
<div className="stat-value" style={{ fontSize: 14 }}>{formatUptime(info.uptime)}</div>
<div className="stat-label">uptime</div>
</div>
{info.temperature != null && (
<div className="stat-item">
<div className={`stat-value ${info.temperature >= 60 ? 'red' : info.temperature >= 45 ? 'yellow' : 'green'}`} style={{ fontSize: 14 }}>
{info.temperature}°C
</div>
<div className="stat-label">temp</div>
</div>
)}
</div>
)}
<div className="progress-group" style={{ marginBottom: 14 }}>
{data.volumes.map(v => {
const p = pct(v.used, v.total)
return (
<div key={v.id}>
<div className="progress-header">
<span className="progress-name">{v.label}</span>
<span className="progress-val">{p}%</span>
</div>
<div className="progress-track">
<div className={`progress-fill ${barColor(p)}`} style={{ width: `${p}%` }} />
</div>
</div>
)
})}
</div>
{totals && (
<div className="stat-row">
<div className="stat-item">
<div className="stat-value accent">{formatBytes(totals.total)}</div>
<div className="stat-label">total</div>
</div>
<div className="stat-item">
<div className="stat-value yellow">{formatBytes(totals.used)}</div>
<div className="stat-label">used</div>
</div>
<div className="stat-item">
<div className="stat-value green">{formatBytes(totals.total - totals.used)}</div>
<div className="stat-label">free</div>
</div>
</div>
)}
</>
)}
</div>
)
}
@@ -0,0 +1,5 @@
import { PlaceholderWidget } from '../PlaceholderWidget'
export function PrometheusWidget() {
return <PlaceholderWidget name="Prometheus" envVars={['PROMETHEUS_HOST']} />
}
+77
View File
@@ -0,0 +1,77 @@
import { useApi } from '../../hooks/useApi'
import { ProxmoxData } from '../../types'
import { formatBytes, formatUptime, pct, barColor } from '../../utils'
function ProgressBar({ label, value, total, color, valueLabel }: {
label: string; value: number; total: number; color: string; valueLabel?: string
}) {
const p = pct(value, total)
return (
<div>
<div className="progress-header">
<span className="progress-name">{label}</span>
<span className="progress-val">{valueLabel ?? `${p}% · ${formatBytes(value)} / ${formatBytes(total)}`}</span>
</div>
<div className="progress-track">
<div className={`progress-fill ${color}`} style={{ width: `${p}%` }} />
</div>
</div>
)
}
export function ProxmoxWidget() {
const { data, loading, error } = useApi<ProxmoxData>('/api/proxmox/status')
return (
<div className="card">
<div className="widget-header">
<div className="widget-title">
<span className="dot" style={{ background: 'var(--accent)' }} />
Proxmox VE
</div>
<div className="widget-badge">M920q</div>
</div>
{loading && <div className="widget-loading">Connecting</div>}
{error && <div className="widget-error"> {error}</div>}
{data && (
<>
<div className="node-name">{data.node}</div>
<div className="node-uptime"> {formatUptime(data.uptime)}</div>
<div className="progress-group">
<ProgressBar
label="CPU"
value={data.cpu}
total={1}
color={barColor(Math.round(data.cpu * 100))}
valueLabel={`${Math.round(data.cpu * 100)}%`}
/>
<ProgressBar label="RAM" value={data.memory.used} total={data.memory.total} color={barColor(pct(data.memory.used, data.memory.total))} />
{data.storages.map(st => (
<ProgressBar
key={st.name}
label={st.name}
value={st.used}
total={st.total}
color={barColor(pct(st.used, st.total))}
/>
))}
</div>
<div className="stat-row" style={{ marginTop: 14 }}>
<div className="stat-item">
<div className="stat-value accent">{data.lxcCount}</div>
<div className="stat-label">LXC running</div>
</div>
<div className="stat-item">
<div className="stat-value">{data.vmCount}</div>
<div className="stat-label">VMs</div>
</div>
</div>
</>
)}
</div>
)
}
@@ -0,0 +1,102 @@
import { useApi } from '../../hooks/useApi'
import { QbtData } from '../../types'
function formatSpeed(bps: number): string {
if (bps < 1024) return `${bps} B/s`
if (bps < 1024 ** 2) return `${(bps / 1024).toFixed(1)} KB/s`
if (bps < 1024 ** 3) return `${(bps / 1024 ** 2).toFixed(1)} MB/s`
return `${(bps / 1024 ** 3).toFixed(2)} GB/s`
}
function formatBytes(b: number): string {
if (b < 1024) return `${b} B`
if (b < 1024 ** 2) return `${(b / 1024).toFixed(1)} KB`
if (b < 1024 ** 3) return `${(b / 1024 ** 2).toFixed(1)} MB`
return `${(b / 1024 ** 3).toFixed(2)} GB`
}
export function QbittorrentWidget() {
const { data, loading, error } = useApi<QbtData>('/api/qbt/stats', 10_000)
const isActive = data && (data.dlSpeed > 0 || data.ulSpeed > 0)
return (
<div className="card">
<div className="widget-header">
<div className="widget-title">
<span className="dot" style={{ background: isActive ? 'var(--green)' : data ? 'var(--muted)' : 'var(--muted)' }} />
qBittorrent
</div>
{data && (
<span style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: 10,
color: 'var(--text2)',
background: 'rgba(255,255,255,0.05)',
padding: '2px 8px',
borderRadius: 4,
}}>
{data.total} torrents
</span>
)}
</div>
{loading && <div className="widget-loading">Connecting</div>}
{error && <div className="widget-error"> {error}</div>}
{data && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, background: 'var(--surface2)', borderRadius: 6, padding: '6px 10px' }}>
<div style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: 13, color: 'var(--green)', fontWeight: 600 }}>
{formatSpeed(data.dlSpeed)}
</div>
<div style={{ fontSize: 9, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: 1, marginTop: 2 }}>download</div>
</div>
<div style={{ flex: 1, background: 'var(--surface2)', borderRadius: 6, padding: '6px 10px' }}>
<div style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: 13, color: 'var(--accent2)', fontWeight: 600 }}>
{formatSpeed(data.ulSpeed)}
</div>
<div style={{ fontSize: 9, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: 1, marginTop: 2 }}>upload</div>
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
{[
{ label: 'downloading', value: data.downloading, color: 'var(--green)' },
{ label: 'seeding', value: data.seeding, color: 'var(--accent2)' },
{ label: 'paused', value: data.paused, color: 'var(--muted)' },
].map(s => (
<div key={s.label} style={{ flex: 1, textAlign: 'center' }}>
<div style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: 16, fontWeight: 600, color: s.value > 0 ? s.color : 'var(--text2)' }}>
{s.value}
</div>
<div style={{ fontSize: 9, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: 1 }}>{s.label}</div>
</div>
))}
</div>
{data.active.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{data.active.map((t, i) => (
<div key={i}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 2 }}>
<span style={{ fontSize: 11, color: 'var(--text2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', marginRight: 8 }}>
{t.name}
</span>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: 9, color: 'var(--muted)', flexShrink: 0 }}>
{formatBytes(t.size * t.progress)} / {formatBytes(t.size)}
</span>
</div>
<div style={{ height: 4, background: 'var(--surface2)', borderRadius: 2, overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${t.progress * 100}%`, background: 'var(--green)', borderRadius: 2 }} />
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
)
}
@@ -0,0 +1,97 @@
import { useApi } from '../../hooks/useApi'
import { VaultwardenData } from '../../types'
function timeAgo(iso: string | null): string {
if (!iso) return 'never'
const diff = Date.now() - new Date(iso).getTime()
const m = Math.floor(diff / 60_000)
if (m < 1) return 'just now'
if (m < 60) return `${m}m ago`
const h = Math.floor(m / 60)
if (h < 24) return `${h}h ago`
return `${Math.floor(h / 24)}d ago`
}
function formatDate(iso: string | null): string {
if (!iso) return '—'
return new Date(iso).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })
}
export function VaultwardenWidget() {
const { data, loading, error } = useApi<VaultwardenData>('/api/vaultwarden/stats')
return (
<div className="card">
<div className="widget-header">
<div className="widget-title">
<span className="dot" style={{ background: 'var(--green)' }} />
Vaultwarden
</div>
<div style={{ display: 'flex', align: 'center', gap: 6 }}>
{data?.version && (
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: 9, color: 'var(--muted)' }}>
v{data.version}
</span>
)}
<div className="widget-badge">.47</div>
</div>
</div>
{loading && <div className="widget-loading">Connecting</div>}
{error && <div className="widget-error"> {error}</div>}
{data && (
<>
<div className="stat-row" style={{ marginBottom: 14 }}>
<div className="stat-item">
<div className="stat-value green">{data.userCount}</div>
<div className="stat-label">users</div>
</div>
<div className="stat-item">
<div className={`stat-value ${data.users.filter(u => u.twoFa).length > 0 ? 'green' : 'red'}`}>
{data.users.filter(u => u.twoFa).length}/{data.userCount}
</div>
<div className="stat-label">2FA</div>
</div>
</div>
<div>
{data.users.map((u, i) => (
<div key={i} className="list-item" style={{ flexDirection: 'column', alignItems: 'stretch', gap: 4 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div className="list-item-left">
<div style={{
width: 6, height: 6, borderRadius: '50%', flexShrink: 0,
background: u.enabled ? 'var(--green)' : 'var(--muted)',
}} />
<span style={{ fontWeight: 600 }}>{u.name}</span>
</div>
<div style={{ display: 'flex', gap: 6 }}>
{u.twoFa
? <span className="pill pill-green">2FA</span>
: <span className="pill pill-red">no 2FA</span>
}
</div>
</div>
<div style={{
display: 'flex', justifyContent: 'space-between',
fontFamily: "'JetBrains Mono', monospace", fontSize: 10, color: 'var(--muted)',
paddingLeft: 14,
}}>
<span>{u.email}</span>
<span>active {timeAgo(u.lastActive)}</span>
</div>
<div style={{
fontFamily: "'JetBrains Mono', monospace", fontSize: 10, color: 'var(--muted)',
paddingLeft: 14,
}}>
member since {formatDate(u.created)}
</div>
</div>
))}
</div>
</>
)}
</div>
)
}
+46
View File
@@ -0,0 +1,46 @@
import { useState, useEffect, useCallback } from 'react'
interface ApiState<T> {
data: T | null
loading: boolean
error: string | null
lastUpdated: Date | null
}
export function useApi<T>(url: string, intervalMs = 30_000): ApiState<T> {
const [state, setState] = useState<ApiState<T>>({
data: null,
loading: true,
error: null,
lastUpdated: null,
})
const hasData = state.data !== null
const fetch_ = useCallback(async () => {
try {
const res = await fetch(url)
if (!res.ok) {
const body = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(body.error ?? res.statusText)
}
const data: T = await res.json()
setState({ data, loading: false, error: null, lastUpdated: new Date() })
} catch (err: unknown) {
setState(prev => ({
...prev,
loading: false,
error: err instanceof Error ? err.message : 'Unknown error',
}))
}
}, [url])
useEffect(() => {
fetch_()
// Retry every 3s while we have no data yet (startup race / transient error),
// then slow down to the normal interval once data is flowing.
const id = setInterval(fetch_, hasData ? intervalMs : 3_000)
return () => clearInterval(id)
}, [fetch_, intervalMs, hasData])
return state
}
+232
View File
@@ -0,0 +1,232 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0d0f14;
--surface: #13151c;
--surface2: #1a1d27;
--border: #22263a;
--accent: #4f8ef7;
--accent2: #7c5ff5;
--green: #34d399;
--yellow: #fbbf24;
--red: #f87171;
--muted: #4a5068;
--text: #e2e6f3;
--text2: #8890aa;
--radius: 12px;
}
body {
background: var(--bg);
color: var(--text);
font-family: 'Syne', sans-serif;
min-height: 100vh;
overflow-x: hidden;
}
body::before {
content: '';
position: fixed;
inset: 0;
background-image:
linear-gradient(var(--border) 1px, transparent 1px),
linear-gradient(90deg, var(--border) 1px, transparent 1px);
background-size: 40px 40px;
opacity: 0.25;
pointer-events: none;
z-index: 0;
}
.blob {
position: fixed;
border-radius: 50%;
filter: blur(120px);
pointer-events: none;
z-index: 0;
opacity: 0.07;
}
.blob-1 { width: 500px; height: 500px; background: var(--accent); top: -150px; left: -100px; }
.blob-2 { width: 400px; height: 400px; background: var(--accent2); bottom: -100px; right: -80px; }
/* ── Layout ── */
.shell { position: relative; z-index: 1; max-width: 1440px; margin: 0 auto; padding: 0 28px 48px; }
/* ── Header ── */
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 22px 0 18px;
border-bottom: 1px solid var(--border);
margin-bottom: 28px;
}
.logo { display: flex; align-items: center; gap: 10px; }
.logo-mark {
width: 32px; height: 32px;
background: linear-gradient(135deg, var(--accent), var(--accent2));
border-radius: 8px;
display: flex; align-items: center; justify-content: center;
font-size: 14px; font-weight: 800; letter-spacing: -1px; color: #fff;
}
.logo-text { font-size: 18px; font-weight: 700; letter-spacing: -0.5px; }
.logo-sub { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--muted); }
.topbar-right { display: flex; align-items: center; gap: 20px; }
.clock-widget { text-align: right; }
.clock-time { font-family: 'JetBrains Mono', monospace; font-size: 22px; font-weight: 500; letter-spacing: 1px; }
.clock-date { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text2); margin-top: 2px; }
/* ── Section label ── */
.section-label {
font-family: 'JetBrains Mono', monospace;
font-size: 10px; font-weight: 500;
letter-spacing: 2px; text-transform: uppercase;
color: var(--muted);
margin-bottom: 12px;
display: flex; align-items: center; gap: 8px;
}
.section-label::after { content: ''; flex: 1; height: 1px; background: var(--border); }
/* ── Grid ── */
.infra-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 14px;
margin-bottom: 28px;
}
/* ── Card ── */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
position: relative;
overflow: hidden;
transition: border-color 0.2s, transform 0.2s;
}
.card:hover { border-color: #2e3452; transform: translateY(-1px); }
.card::before {
content: '';
position: absolute; inset: 0;
background: linear-gradient(135deg, rgba(255,255,255,0.015) 0%, transparent 60%);
pointer-events: none;
}
@keyframes fadeUp {
from { opacity: 0; transform: translateY(14px); }
to { opacity: 1; transform: translateY(0); }
}
.card { animation: fadeUp 0.4s ease both; }
/* ── Widget header ── */
.widget-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 14px;
}
.widget-title {
font-size: 11px; font-weight: 600; letter-spacing: 1px;
text-transform: uppercase; color: var(--text2);
display: flex; align-items: center; gap: 6px;
}
.widget-title .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); flex-shrink: 0; }
.widget-badge {
font-family: 'JetBrains Mono', monospace;
font-size: 9px; padding: 2px 6px; border-radius: 4px;
background: rgba(79,142,247,0.12); color: var(--accent);
}
/* ── Node meta ── */
.node-name { font-size: 15px; font-weight: 700; margin-bottom: 2px; }
.node-uptime { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--muted); margin-bottom: 14px; }
/* ── Progress bars ── */
.progress-group { display: flex; flex-direction: column; gap: 10px; }
.progress-header { display: flex; justify-content: space-between; margin-bottom: 5px; }
.progress-name { font-size: 11px; color: var(--text2); }
.progress-val { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text); }
.progress-track { height: 4px; background: var(--surface2); border-radius: 2px; overflow: hidden; }
.progress-fill {
height: 100%; border-radius: 2px;
background: linear-gradient(90deg, var(--accent), var(--accent2));
transition: width 0.6s ease;
}
.progress-fill.green { background: linear-gradient(90deg, var(--green), #6ee7b7); }
.progress-fill.yellow { background: linear-gradient(90deg, var(--yellow), #f59e0b); }
.progress-fill.red { background: linear-gradient(90deg, var(--red), #fca5a5); }
/* ── Stat row ── */
.stat-row { display: flex; gap: 12px; }
.stat-item { flex: 1; }
.stat-value {
font-family: 'JetBrains Mono', monospace;
font-size: 22px; font-weight: 500; line-height: 1;
background: linear-gradient(135deg, var(--text), var(--text2));
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
}
.stat-value.green { background: linear-gradient(135deg, var(--green), #6ee7b7); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
.stat-value.yellow { background: linear-gradient(135deg, var(--yellow), #fcd34d); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
.stat-value.accent { background: linear-gradient(135deg, var(--accent), #93c5fd); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
.stat-value.red { background: linear-gradient(135deg, var(--red), #fca5a5); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
.stat-label { font-size: 10px; color: var(--muted); margin-top: 4px; font-family: 'JetBrains Mono', monospace; }
/* ── Pill / list item ── */
.list-item {
display: flex; align-items: center; justify-content: space-between;
padding: 7px 0;
border-bottom: 1px solid var(--border);
font-size: 12px;
}
.list-item:last-child { border-bottom: none; }
.list-item-left { display: flex; align-items: center; gap: 8px; color: var(--text2); }
.pill { padding: 2px 8px; border-radius: 20px; font-size: 10px; font-family: 'JetBrains Mono', monospace; font-weight: 500; }
.pill-green { background: rgba(52,211,153,0.12); color: var(--green); }
.pill-yellow { background: rgba(251,191,36,0.12); color: var(--yellow); }
.pill-red { background: rgba(248,113,113,0.12); color: var(--red); }
.pill-blue { background: rgba(79,142,247,0.12); color: var(--accent); }
/* ── AdGuard bar chart ── */
.ag-chart { display: flex; align-items: flex-end; gap: 3px; height: 40px; margin-top: 10px; }
.ag-bar { flex: 1; border-radius: 2px 2px 0 0; background: var(--accent); opacity: 0.5; min-height: 2px; }
.ag-bar.blocked { background: var(--red); opacity: 0.6; }
.ag-legend { display: flex; gap: 10px; margin-top: 8px; }
.ag-legend-item { display: flex; align-items: center; gap: 5px; font-size: 10px; color: var(--muted); font-family: 'JetBrains Mono', monospace; }
.ag-legend-swatch { width: 8px; height: 8px; border-radius: 1px; }
/* ── CrowdSec ── */
.crowdsec-big { font-family: 'JetBrains Mono', monospace; font-size: 36px; font-weight: 500; color: var(--red); line-height: 1; margin: 6px 0 2px; }
.crowdsec-sub { font-size: 10px; color: var(--muted); font-family: 'JetBrains Mono', monospace; }
/* ── Loading / error states ── */
.widget-loading {
display: flex; align-items: center; justify-content: center;
min-height: 80px; color: var(--muted);
font-family: 'JetBrains Mono', monospace; font-size: 11px;
}
.widget-loading::before {
content: '';
width: 14px; height: 14px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
margin-right: 8px;
}
@keyframes spin { to { transform: rotate(360deg); } }
.widget-error {
font-family: 'JetBrains Mono', monospace; font-size: 11px;
color: var(--red); padding: 12px 0;
display: flex; align-items: flex-start; gap: 6px;
}
/* ── Responsive ── */
@media (max-width: 540px) {
.shell { padding: 0 14px 32px; }
.stat-value { font-size: 18px; }
.clock-time { font-size: 16px; }
.topbar-right { gap: 12px; }
}
/* ── Scrollbar ── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
)
+144
View File
@@ -0,0 +1,144 @@
export interface ProxmoxStorage { name: string; type: string; used: number; total: number }
export interface ProxmoxData {
node: string
uptime: number
cpu: number
memory: { used: number; total: number }
storages: ProxmoxStorage[]
lxcCount: number
vmCount: number
}
export interface SynologyVolume {
id: string
label: string
used: number
total: number
}
export interface SynologyData {
volumes: SynologyVolume[]
}
export interface AdGuardTimeSlot {
queries: number
blocked: number
}
export interface AdGuardData {
totalQueries: number
blockedQueries: number
blockedPercent: string
timeSlots: AdGuardTimeSlot[]
}
export interface CrowdSecOrigin { name: string; count: number }
export interface CrowdSecRecent { value: string; scenario: string; created_at: string }
export interface CrowdSecData {
activeBans: number
alertsLast24h: number
blocksThisWeek: number
origins: CrowdSecOrigin[]
recent: CrowdSecRecent[]
}
export interface SynologyInfoData {
model: string
dsmVersion: string
uptime: number
temperature: number | null
}
export interface KumaMonitor {
id: number
name: string
type: string
status: number
ping: number | null
beats: number[]
}
export interface KumaData {
total: number
up: number
down: number
monitors: KumaMonitor[]
}
export interface AuthentikLogin {
username: string
created: string
clientIp: string
success: boolean
}
export interface AuthentikData {
userCount: number
failedLast24h: number
recentLogins: AuthentikLogin[]
}
export interface VaultwardenUser {
email: string
name: string
enabled: boolean
lastActive: string | null
created: string | null
twoFa: boolean
}
export interface VaultwardenData {
version: string | null
userCount: number
users: VaultwardenUser[]
}
export interface QbtActiveTorrent { name: string; progress: number; dlSpeed: number; size: number; state: string }
export interface QbtData {
dlSpeed: number
ulSpeed: number
downloading: number
seeding: number
paused: number
total: number
active: QbtActiveTorrent[]
}
export interface FritzboxHistorySlot { rx: number; tx: number }
export interface FritzboxData {
connected: boolean
externalIp: string
rxMbps: number
txMbps: number
history: FritzboxHistorySlot[]
}
export interface HeadscaleNode {
id: string
name: string
ip: string
online: boolean
lastSeen: string
user: string
}
export interface ArrServiceStats { movies?: number; series?: number; artists?: number; missing: number; queue: number }
export interface ArrStatsData {
radarr: ArrServiceStats | null
sonarr: ArrServiceStats | null
lidarr: ArrServiceStats | null
}
export interface ArrCalendarItem {
date: string
title: string
subtitle: string
type: 'movie' | 'episode' | 'album'
downloaded: boolean
}
export interface ArrCalendarData {
items: ArrCalendarItem[]
}
export interface HeadscaleData {
total: number
online: number
nodes: HeadscaleNode[]
}
+27
View File
@@ -0,0 +1,27 @@
export function formatBytes(bytes: number, decimals = 1): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(decimals))} ${sizes[i]}`
}
export function formatUptime(seconds: number): string {
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const mins = Math.floor((seconds % 3600) / 60)
if (days > 0) return `${days}d ${hours}h ${mins}m`
if (hours > 0) return `${hours}h ${mins}m`
return `${mins}m`
}
export function pct(used: number, total: number): number {
if (!total) return 0
return Math.round((used / total) * 100)
}
export function barColor(p: number): string {
if (p >= 85) return 'red'
if (p >= 60) return 'yellow'
return 'green'
}