syco.me Homelab Dashboard
This commit is contained in:
+59
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PlaceholderWidget } from '../PlaceholderWidget'
|
||||
|
||||
export function LokiWidget() {
|
||||
return <PlaceholderWidget name="Loki" envVars={['LOKI_HOST']} />
|
||||
}
|
||||
@@ -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']} />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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; }
|
||||
@@ -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
@@ -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[]
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
Reference in New Issue
Block a user