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
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
dist-server
.env
+2
View File
@@ -0,0 +1,2 @@
node_modules
.env
+17
View File
@@ -0,0 +1,17 @@
# Stage 1: build frontend + compile server
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: runtime (only prod deps + build artifacts)
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/dist-server ./dist-server
EXPOSE 3001
CMD ["node", "dist-server/index.js"]
+7
View File
@@ -0,0 +1,7 @@
services:
dashboard:
build: .
ports:
- "3001:3001"
env_file: .env
restart: unless-stopped
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>syco.me — Dashboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600&family=Syne:wght@400;600;700;800&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3887
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
{
"name": "homelab-dashboard",
"version": "0.1.0",
"scripts": {
"dev": "concurrently -n client,server -c cyan,yellow \"vite\" \"tsx watch server/index.ts\"",
"dev:client": "vite",
"dev:server": "tsx watch server/index.ts",
"build": "vite build && tsc -p tsconfig.server.json",
"start": "node dist-server/index.js"
},
"dependencies": {
"axios": "^1.7.2",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"socket.io-client": "^4.8.3"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^20.14.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"concurrently": "^8.2.2",
"tsx": "^4.15.6",
"typescript": "^5.4.5",
"vite": "^5.3.1"
}
}
+42
View File
@@ -0,0 +1,42 @@
import 'dotenv/config'
import express from 'express'
import cors from 'cors'
import path from 'path'
import proxmox from './routes/proxmox'
import synology from './routes/synology'
import adguard from './routes/adguard'
import crowdsec from './routes/crowdsec'
import headscale from './routes/headscale'
import fritzbox from './routes/fritzbox'
import authentik from './routes/authentik'
import vaultwarden from './routes/vaultwarden'
import kuma from './routes/kuma'
import arr from './routes/arr'
import qbt from './routes/qbittorrent'
const app = express()
const PORT = Number(process.env.PORT ?? 3001)
app.use(cors())
app.use(express.json())
app.use('/api/proxmox', proxmox)
app.use('/api/synology', synology)
app.use('/api/adguard', adguard)
app.use('/api/crowdsec', crowdsec)
app.use('/api/headscale', headscale)
app.use('/api/fritzbox', fritzbox)
app.use('/api/authentik', authentik)
app.use('/api/vaultwarden', vaultwarden)
app.use('/api/kuma', kuma)
app.use('/api/arr', arr)
app.use('/api/qbt', qbt)
// Serve built frontend in production only
if (process.env.NODE_ENV === 'production') {
const distPath = path.join(__dirname, '..', 'dist')
app.use(express.static(distPath))
app.get('*', (_req, res) => res.sendFile(path.join(distPath, 'index.html')))
}
app.listen(PORT, () => console.log(`Dashboard server running on http://localhost:${PORT}`))
+47
View File
@@ -0,0 +1,47 @@
import { Router } from 'express'
import axios from 'axios'
const router = Router()
router.get('/stats', async (_req, res) => {
try {
const host = process.env.ADGUARD_HOST
const user = process.env.ADGUARD_USER
const pass = process.env.ADGUARD_PASSWORD
if (!host) {
res.status(503).json({ error: 'ADGUARD_HOST not configured' })
return
}
const u = user?.trim() ?? ''
const p = pass?.trim() ?? ''
const b64 = Buffer.from(`${u}:${p}`).toString('base64')
const statsRes = await axios.get(`${host}/control/stats`, {
headers: {
Authorization: `Basic ${b64}`,
'User-Agent': 'Mozilla/5.0',
},
maxRedirects: 0,
})
const d = statsRes.data
const queries: number[] = d.dns_queries ?? []
const blocked: number[] = d.blocked_filtering ?? []
const total: number = d.num_dns_queries ?? 0
const totalBlocked: number = d.num_blocked_filtering ?? 0
const timeSlots = queries.map((q, i) => ({ queries: q, blocked: blocked[i] ?? 0 }))
res.json({
totalQueries: total,
blockedQueries: totalBlocked,
blockedPercent: total > 0 ? ((totalBlocked / total) * 100).toFixed(1) : '0.0',
timeSlots,
})
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Unknown error'
res.status(500).json({ error: msg })
}
})
export default router
+133
View File
@@ -0,0 +1,133 @@
import { Router } from 'express'
import axios from 'axios'
const router = Router()
router.get('/calendar', async (req, res) => {
const today = new Date()
const defaultStart = new Date(today.getFullYear(), today.getMonth(), 1).toISOString().slice(0, 10)
const defaultEnd = new Date(today.getFullYear(), today.getMonth() + 1, 0).toISOString().slice(0, 10)
const start = (req.query.start as string) || defaultStart
const end = (req.query.end as string) || defaultEnd
const calParams = { start, end, unmonitored: false }
const sonarrHeaders = process.env.SONARR_API_KEY ? { 'X-Api-Key': process.env.SONARR_API_KEY } : null
const results = await Promise.allSettled([
process.env.RADARR_HOST && process.env.RADARR_API_KEY
? axios.get(`${process.env.RADARR_HOST}/api/v3/calendar`, {
headers: { 'X-Api-Key': process.env.RADARR_API_KEY },
params: calParams,
})
: Promise.resolve(null),
process.env.SONARR_HOST && sonarrHeaders
? axios.get(`${process.env.SONARR_HOST}/api/v3/calendar`, {
headers: sonarrHeaders,
params: calParams,
})
: Promise.resolve(null),
process.env.SONARR_HOST && sonarrHeaders
? axios.get(`${process.env.SONARR_HOST}/api/v3/series`, {
headers: sonarrHeaders,
})
: Promise.resolve(null),
process.env.LIDARR_HOST && process.env.LIDARR_API_KEY
? axios.get(`${process.env.LIDARR_HOST}/api/v1/calendar`, {
headers: { 'X-Api-Key': process.env.LIDARR_API_KEY },
params: calParams,
})
: Promise.resolve(null),
])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const radarrData: any[] = results[0].status === 'fulfilled' && results[0].value?.data ? results[0].value.data : []
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sonarrData: any[] = results[1].status === 'fulfilled' && results[1].value?.data ? results[1].value.data : []
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const seriesList: any[] = results[2].status === 'fulfilled' && results[2].value?.data ? results[2].value.data : []
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const lidarrData: any[] = results[3].status === 'fulfilled' && results[3].value?.data ? results[3].value.data : []
const seriesById: Record<number, string> = {}
for (const s of seriesList) seriesById[s.id] = s.title
const items = [
...radarrData.map(m => ({
date: (m.digitalRelease ?? m.physicalRelease ?? m.inCinemas ?? '').slice(0, 10),
title: m.title,
subtitle: m.year ? String(m.year) : '',
type: 'movie' as const,
downloaded: m.hasFile ?? false,
})).filter(m => m.date),
...sonarrData.map(e => ({
date: (e.airDate ?? '').slice(0, 10),
title: seriesById[e.seriesId] ?? e.title,
subtitle: `S${String(e.seasonNumber).padStart(2,'0')}E${String(e.episodeNumber).padStart(2,'0')}${e.title && e.title !== 'TBA' ? ` · ${e.title}` : ''}`,
type: 'episode' as const,
downloaded: e.hasFile ?? false,
})).filter(e => e.date),
...lidarrData.map(a => ({
date: (a.releaseDate ?? '').slice(0, 10),
title: a.artist?.artistName ?? '',
subtitle: a.title,
type: 'album' as const,
downloaded: a.statistics?.trackFileCount > 0,
})).filter(a => a.date),
].sort((a, b) => a.date.localeCompare(b.date))
res.json({ items })
})
router.get('/stats', async (_req, res) => {
const results = await Promise.allSettled([
process.env.RADARR_HOST && process.env.RADARR_API_KEY ? Promise.all([
axios.get(`${process.env.RADARR_HOST}/api/v3/movie`, { headers: { 'X-Api-Key': process.env.RADARR_API_KEY } }),
axios.get(`${process.env.RADARR_HOST}/api/v3/queue/status`, { headers: { 'X-Api-Key': process.env.RADARR_API_KEY } }),
]) : Promise.resolve(null),
process.env.SONARR_HOST && process.env.SONARR_API_KEY ? Promise.all([
axios.get(`${process.env.SONARR_HOST}/api/v3/series`, { headers: { 'X-Api-Key': process.env.SONARR_API_KEY } }),
axios.get(`${process.env.SONARR_HOST}/api/v3/wanted/missing?pageSize=1`, { headers: { 'X-Api-Key': process.env.SONARR_API_KEY } }),
axios.get(`${process.env.SONARR_HOST}/api/v3/queue/status`, { headers: { 'X-Api-Key': process.env.SONARR_API_KEY } }),
]) : Promise.resolve(null),
process.env.LIDARR_HOST && process.env.LIDARR_API_KEY ? Promise.all([
axios.get(`${process.env.LIDARR_HOST}/api/v1/artist`, { headers: { 'X-Api-Key': process.env.LIDARR_API_KEY } }),
axios.get(`${process.env.LIDARR_HOST}/api/v1/wanted/missing?pageSize=1`, { headers: { 'X-Api-Key': process.env.LIDARR_API_KEY } }),
axios.get(`${process.env.LIDARR_HOST}/api/v1/queue/status`, { headers: { 'X-Api-Key': process.env.LIDARR_API_KEY } }),
]) : Promise.resolve(null),
])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const radarr = results[0].status === 'fulfilled' && results[0].value ? results[0].value as any[] : null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sonarr = results[1].status === 'fulfilled' && results[1].value ? results[1].value as any[] : null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const lidarr = results[2].status === 'fulfilled' && results[2].value ? results[2].value as any[] : null
res.json({
radarr: radarr ? {
movies: radarr[0].data.length,
missing: radarr[0].data.filter((m: { hasFile: boolean; monitored: boolean }) => !m.hasFile && m.monitored).length,
queue: radarr[1].data.totalCount ?? 0,
} : null,
sonarr: sonarr ? {
series: sonarr[0].data.length,
missing: sonarr[1].data.totalRecords ?? 0,
queue: sonarr[2].data.totalCount ?? 0,
} : null,
lidarr: lidarr ? {
artists: lidarr[0].data.length,
missing: lidarr[1].data.totalRecords ?? 0,
queue: lidarr[2].data.totalCount ?? 0,
} : null,
})
})
export default router
+62
View File
@@ -0,0 +1,62 @@
import { Router } from 'express'
import axios from 'axios'
const router = Router()
router.get('/stats', async (_req, res) => {
try {
const host = process.env.AUTHENTIK_HOST
const token = process.env.AUTHENTIK_TOKEN
if (!host || !token) {
res.status(503).json({ error: 'AUTHENTIK_HOST / AUTHENTIK_TOKEN not configured' })
return
}
const headers = { Authorization: `Bearer ${token.trim()}` }
const since24h = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()
const [usersRes, loginsRes, failedRes, failedCountRes] = await Promise.all([
axios.get(`${host}/api/v3/core/users/`, { headers, params: { page_size: 1, attributes: '' } }),
axios.get(`${host}/api/v3/events/events/`, { headers, params: { action: 'login', ordering: '-created', page_size: 5 } }),
axios.get(`${host}/api/v3/events/events/`, { headers, params: { action: 'login_failed', ordering: '-created', page_size: 5 } }),
axios.get(`${host}/api/v3/events/events/`, { headers, params: { action: 'login_failed', created__gte: since24h, page_size: 1 } }),
])
type AuthentikEvent = {
created: string
user: { username: string }
client_ip: string
}
const toEntry = (e: AuthentikEvent, success: boolean) => ({
username: e.user?.username ?? 'unknown',
created: e.created,
clientIp: e.client_ip ?? '',
success,
})
const combined = [
...(loginsRes.data.results ?? []).map((e: AuthentikEvent) => toEntry(e, true)),
...(failedRes.data.results ?? []).map((e: AuthentikEvent) => toEntry(e, false)),
]
.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime())
.reduce<ReturnType<typeof toEntry>[]>((acc, entry) => {
const dupe = acc.find(x => x.created === entry.created && x.username === entry.username)
if (dupe) { if (!entry.success) dupe.success = false }
else acc.push(entry)
return acc
}, [])
.slice(0, 5)
res.json({
userCount: usersRes.data.pagination?.count ?? 0,
failedLast24h: failedCountRes.data.pagination?.count ?? 0,
recentLogins: combined,
})
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Unknown error'
res.status(500).json({ error: msg })
}
})
export default router
+68
View File
@@ -0,0 +1,68 @@
import { Router } from 'express'
import axios from 'axios'
const router = Router()
router.get('/stats', async (_req, res) => {
try {
const host = process.env.CROWDSEC_HOST
const key = process.env.CROWDSEC_API_KEY
if (!host || !key) {
res.status(503).json({ error: 'CROWDSEC_HOST / CROWDSEC_API_KEY not configured' })
return
}
const headers = { 'X-Api-Key': key.trim() }
// Bouncers can only access /v1/decisions — alerts require machine/agent auth
const [activeRes, weekRes] = await Promise.all([
axios.get(`${host}/v1/decisions`, { headers, params: { limit: 500000 } }),
axios.get(`${host}/v1/decisions`, { headers, params: { limit: 500000, since: '168h' } }),
])
type Decision = {
id?: number
origin?: string
type?: string
scope?: string
value?: string
scenario?: string
created_at?: string
until?: string
}
const active: Decision[] = Array.isArray(activeRes.data) ? activeRes.data : []
const week: Decision[] = Array.isArray(weekRes.data) ? weekRes.data : []
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000)
const alertsLast24h = active.filter(d => d.created_at && new Date(d.created_at) > oneDayAgo).length
const originCount: Record<string, number> = {}
for (const d of active) {
const o = d.origin ?? 'unknown'
originCount[o] = (originCount[o] ?? 0) + 1
}
const origins = Object.entries(originCount)
.sort((a, b) => b[1] - a[1])
.map(([name, count]) => ({ name, count }))
const recent = [...active]
.filter(d => d.created_at)
.sort((a, b) => new Date(b.created_at!).getTime() - new Date(a.created_at!).getTime())
.slice(0, 6)
.map(d => ({ value: d.value ?? '?', scenario: d.scenario ?? d.origin ?? '?', created_at: d.created_at! }))
res.json({
activeBans: active.length,
alertsLast24h,
blocksThisWeek: week.length,
origins,
recent,
})
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Unknown error'
res.status(500).json({ error: msg })
}
})
export default router
+66
View File
@@ -0,0 +1,66 @@
import { Router } from 'express'
import axios from 'axios'
const router = Router()
const HISTORY_SIZE = 20
const history: { ts: number; rx: number; tx: number }[] = []
function soap(action: string, service: string, body = ''): string {
return `<?xml version="1.0" encoding="utf-8"?><s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body><u:${action} xmlns:u="${service}">${body}</u:${action}></s:Body></s:Envelope>`
}
function tag(xml: string, name: string): string {
const m = xml.match(new RegExp(`<(?:[^:>]*:)?${name}>([^<]*)<`))
return m?.[1]?.trim() ?? ''
}
async function soapReq(host: string, path: string, service: string, action: string): Promise<string> {
const res = await axios.post(`${host}:49000${path}`, soap(action, service), {
headers: {
'Content-Type': 'text/xml; charset="utf-8"',
SOAPAction: `"${service}#${action}"`,
},
timeout: 5000,
})
return res.data as string
}
router.get('/status', async (_req, res) => {
try {
const host = process.env.FRITZBOX_HOST
if (!host) {
res.status(503).json({ error: 'FRITZBOX_HOST not configured' })
return
}
const [addonXml, ipXml, statusXml] = await Promise.all([
soapReq(host, '/igdupnp/control/WANCommonIFC1',
'urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1', 'GetAddonInfos'),
soapReq(host, '/igdupnp/control/WANIPConn1',
'urn:schemas-upnp-org:service:WANIPConnection:1', 'GetExternalIPAddress'),
soapReq(host, '/igdupnp/control/WANIPConn1',
'urn:schemas-upnp-org:service:WANIPConnection:1', 'GetStatusInfo'),
])
// Bytes/sec — FritzBox provides instantaneous rates
const rxRate = Number(tag(addonXml, 'NewByteReceiveRate') || tag(addonXml, 'NewBytesReceiveRate') || '0')
const txRate = Number(tag(addonXml, 'NewByteSendRate') || tag(addonXml, 'NewBytesSendRate') || '0')
history.push({ ts: Date.now(), rx: rxRate, tx: txRate })
if (history.length > HISTORY_SIZE) history.shift()
res.json({
connected: tag(statusXml, 'NewConnectionStatus') === 'Connected',
externalIp: tag(ipXml, 'NewExternalIPAddress'),
rxMbps: parseFloat((rxRate * 8 / 1_000_000).toFixed(2)),
txMbps: parseFloat((txRate * 8 / 1_000_000).toFixed(2)),
history: history.map(h => ({ rx: h.rx, tx: h.tx })),
})
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Unknown error'
res.status(500).json({ error: msg })
}
})
export default router
+48
View File
@@ -0,0 +1,48 @@
import { Router } from 'express'
import axios from 'axios'
const router = Router()
router.get('/nodes', async (_req, res) => {
try {
const host = process.env.HEADSCALE_HOST
const key = process.env.HEADSCALE_API_KEY
if (!host || !key) {
res.status(503).json({ error: 'HEADSCALE_HOST / HEADSCALE_API_KEY not configured' })
return
}
const response = await axios.get(`${host}/api/v1/node`, {
headers: { Authorization: `Bearer ${key.trim()}` },
})
type HsNode = {
id: string
name: string
ipAddresses: string[]
online: boolean
lastSeen: string
user?: { name: string }
}
const nodes: HsNode[] = response.data?.nodes ?? []
res.json({
total: nodes.length,
online: nodes.filter(n => n.online).length,
nodes: nodes.map(n => ({
id: n.id,
name: n.name,
ip: n.ipAddresses?.[0] ?? '',
online: n.online,
lastSeen: n.lastSeen,
user: n.user?.name ?? '',
})),
})
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Unknown error'
res.status(500).json({ error: msg })
}
})
export default router
+99
View File
@@ -0,0 +1,99 @@
import { Router } from 'express'
import { io, Socket } from 'socket.io-client'
const router = Router()
interface MonitorInfo { id: number; name: string; type: string; active: boolean }
interface HeartbeatInfo { status: number; ping: number | null; beats: number[] }
let socket: Socket | null = null
let monitors: Record<number, MonitorInfo> = {}
let heartbeats: Record<number, HeartbeatInfo> = {}
let ready = false
const BEAT_HISTORY = 30
function startSocket() {
const host = process.env.KUMA_HOST
const user = process.env.KUMA_USER
const pass = process.env.KUMA_PASSWORD
if (!host || !user || !pass) return
socket = io(host, { transports: ['websocket'], reconnection: true })
socket.on('connect', () => {
socket!.emit('login', { username: user, password: pass, token: '' }, (res: { ok: boolean }) => {
if (!res.ok) console.error('[kuma] login failed')
})
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
socket.on('monitorList', (data: Record<string, any>) => {
monitors = {}
for (const m of Object.values(data)) {
monitors[m.id] = { id: m.id, name: m.name, type: m.type, active: m.active === true || m.active === 1 }
}
ready = true
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
socket.on('heartbeatList', (monitorId: number, beats: any[]) => {
if (!Array.isArray(beats) || beats.length === 0) return
const last = beats.at(-1)
const slice = beats.slice(-BEAT_HISTORY).map(b => b.status)
heartbeats[monitorId] = { status: last.status, ping: last.ping ?? null, beats: slice }
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
socket.on('heartbeat', (beat: any) => {
if (beat?.monitorID == null) return
const prev = heartbeats[beat.monitorID]
const beats = [...(prev?.beats ?? []), beat.status].slice(-BEAT_HISTORY)
heartbeats[beat.monitorID] = { status: beat.status, ping: beat.ping ?? null, beats }
})
socket.on('disconnect', () => { ready = false })
socket.on('connect_error', (err) => console.error('[kuma] connect error:', err.message))
}
function waitReady(timeoutMs = 6000): Promise<void> {
if (ready) return Promise.resolve()
return new Promise((resolve, reject) => {
const deadline = Date.now() + timeoutMs
const poll = setInterval(() => {
if (ready) { clearInterval(poll); resolve() }
else if (Date.now() > deadline) { clearInterval(poll); reject(new Error('Kuma not ready')) }
}, 100)
})
}
router.get('/monitors', async (_req, res) => {
try {
if (!socket) startSocket()
await waitReady()
const list = Object.values(monitors)
.filter(m => m.active)
.map(m => ({
id: m.id,
name: m.name,
type: m.type,
status: heartbeats[m.id]?.status ?? -1,
ping: heartbeats[m.id]?.ping ?? null,
beats: heartbeats[m.id]?.beats ?? [],
}))
.sort((a, b) => a.status - b.status)
res.json({
total: list.length,
up: list.filter(m => m.status === 1).length,
down: list.filter(m => m.status === 0).length,
monitors: list,
})
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Unknown error'
res.status(500).json({ error: msg })
}
})
export default router
+51
View File
@@ -0,0 +1,51 @@
import { Router } from 'express'
import axios from 'axios'
import https from 'https'
const router = Router()
const agent = new https.Agent({ rejectUnauthorized: false })
router.get('/status', async (_req, res) => {
try {
const host = process.env.PROXMOX_HOST
const token = process.env.PROXMOX_TOKEN
if (!host || !token) {
res.status(503).json({ error: 'PROXMOX_HOST / PROXMOX_TOKEN not configured' })
return
}
const headers = { Authorization: token }
const nodesRes = await axios.get(`${host}/api2/json/nodes`, { headers, httpsAgent: agent })
const node: string = nodesRes.data.data[0].node
const [statusRes, lxcRes, qemuRes, storageRes] = await Promise.all([
axios.get(`${host}/api2/json/nodes/${node}/status`, { headers, httpsAgent: agent }),
axios.get(`${host}/api2/json/nodes/${node}/lxc`, { headers, httpsAgent: agent }),
axios.get(`${host}/api2/json/nodes/${node}/qemu`, { headers, httpsAgent: agent }),
axios.get(`${host}/api2/json/nodes/${node}/storage`, { headers, httpsAgent: agent }),
])
const s = statusRes.data.data
type StorageEntry = { storage: string; type: string; active: number; enabled: number; used: number; total: number }
const storages = (storageRes.data.data as StorageEntry[])
.filter(st => st.active && st.enabled && st.total > 0 && st.storage !== 'nas')
.map(st => ({ name: st.storage, type: st.type, used: st.used, total: st.total }))
res.json({
node,
uptime: s.uptime,
cpu: s.cpu,
memory: { used: s.memory.used, total: s.memory.total },
storages,
lxcCount: (lxcRes.data.data as { status: string }[]).filter(c => c.status === 'running').length,
vmCount: (qemuRes.data.data as { status: string }[]).filter(v => v.status === 'running').length,
})
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Unknown error'
res.status(500).json({ error: msg })
}
})
export default router
+74
View File
@@ -0,0 +1,74 @@
import { Router } from 'express'
import axios from 'axios'
const router = Router()
let sid: string | null = null
let sidExpiry = 0
async function getCookie(): Promise<string> {
if (sid && Date.now() < sidExpiry) return sid
const host = process.env.QBT_HOST
const user = process.env.QBT_USER
const pass = process.env.QBT_PASSWORD
if (!host || !user || !pass) throw new Error('QBT_HOST / QBT_USER / QBT_PASSWORD not configured')
const res = await axios.post(
`${host}/api/v2/auth/login`,
new URLSearchParams({ username: user, password: pass }),
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Referer': host, 'Origin': host } }
)
const cookie = (res.headers['set-cookie'] ?? []).find((c: string) => c.startsWith('SID='))
if (!cookie || res.data === 'Fails.') throw new Error('qBittorrent login failed')
sid = cookie.split(';')[0]
sidExpiry = Date.now() + 55 * 60 * 1000
return sid
}
router.get('/stats', async (_req, res) => {
try {
const host = process.env.QBT_HOST
if (!host) { res.status(503).json({ error: 'QBT_HOST not configured' }); return }
const cookie = await getCookie()
const headers = { Cookie: cookie, Referer: host, Origin: host }
const [infoRes, torrentsRes] = await Promise.all([
axios.get(`${host}/api/v2/transfer/info`, { headers }),
axios.get(`${host}/api/v2/torrents/info`, { headers }),
])
const info = infoRes.data
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const torrents: any[] = torrentsRes.data ?? []
const downloading = torrents.filter(t => ['downloading', 'stalledDL', 'metaDL', 'forcedDL'].includes(t.state))
const seeding = torrents.filter(t => ['uploading', 'stalledUP', 'forcedUP'].includes(t.state))
const paused = torrents.filter(t => ['pausedDL', 'pausedUP'].includes(t.state))
res.json({
dlSpeed: info.dl_info_speed ?? 0,
ulSpeed: info.ul_info_speed ?? 0,
downloading: downloading.length,
seeding: seeding.length,
paused: paused.length,
total: torrents.length,
active: downloading.slice(0, 5).map(t => ({
name: t.name,
progress: t.progress,
dlSpeed: t.dlspeed,
size: t.size,
state: t.state,
})),
})
} catch (err: unknown) {
if (err instanceof Error && err.message.includes('login failed')) sid = null
const msg = err instanceof Error ? err.message : 'Unknown error'
res.status(500).json({ error: msg })
}
})
export default router
+103
View File
@@ -0,0 +1,103 @@
import { Router } from 'express'
import axios from 'axios'
const router = Router()
let cachedSid: string | null = null
let sidExpiry = 0
async function getSid(): Promise<string> {
if (cachedSid && Date.now() < sidExpiry) return cachedSid
const host = process.env.SYNOLOGY_HOST
const res = await axios.get(`${host}/webapi/auth.cgi`, {
params: {
api: 'SYNO.API.Auth',
version: 3,
method: 'login',
account: process.env.SYNOLOGY_USER,
passwd: process.env.SYNOLOGY_PASSWORD,
session: 'dashboard',
format: 'sid',
},
})
if (!res.data.success) throw new Error(`Synology login failed: ${JSON.stringify(res.data.error)}`)
cachedSid = res.data.data.sid as string
sidExpiry = Date.now() + 20 * 60 * 1000
return cachedSid
}
router.get('/storage', async (_req, res) => {
try {
const host = process.env.SYNOLOGY_HOST
if (!host) {
res.status(503).json({ error: 'SYNOLOGY_HOST not configured' })
return
}
const sid = await getSid()
const storageRes = await axios.get(`${host}/webapi/entry.cgi`, {
params: {
api: 'SYNO.Storage.CGI.Storage',
version: 1,
method: 'load_info',
_sid: sid,
},
})
if (!storageRes.data.success) {
cachedSid = null
throw new Error(`Synology storage error: ${JSON.stringify(storageRes.data.error)}`)
}
const d = storageRes.data.data
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const volumes: any[] = d?.volumes ?? d?.vol_info ?? d?.storage?.volumes ?? []
res.json({
volumes: volumes.map(v => ({
id: v.vol_path ?? v.volume_path ?? v.id,
label: v.vol_path ?? v.display_name ?? v.id,
// DSM 7: sizes live under v.size.{total,used}
used: Number(v.size?.used ?? v.size_used ?? 0),
total: Number(v.size?.total ?? v.size_total ?? 0),
})),
})
} catch (err: unknown) {
if (err instanceof Error && err.message.includes('login failed')) cachedSid = null
const msg = err instanceof Error ? err.message : 'Unknown error'
res.status(500).json({ error: msg })
}
})
router.get('/info', async (_req, res) => {
try {
const host = process.env.SYNOLOGY_HOST
if (!host) { res.status(503).json({ error: 'SYNOLOGY_HOST not configured' }); return }
const sid = await getSid()
const r = await axios.get(`${host}/webapi/entry.cgi`, {
params: { api: 'SYNO.DSM.Info', version: 2, method: 'getinfo', _sid: sid },
})
if (!r.data.success) throw new Error(`DSM info error: ${JSON.stringify(r.data.error)}`)
const d = r.data.data ?? {}
res.json({
model: d.model ?? '',
dsmVersion: d.version ?? '',
uptime: Number(d.uptime ?? 0),
temperature: d.temperature != null ? Number(d.temperature) : null,
})
} catch (err: unknown) {
if (err instanceof Error && err.message.includes('login failed')) cachedSid = null
const msg = err instanceof Error ? err.message : 'Unknown error'
res.status(500).json({ error: msg })
}
})
export default router
+84
View File
@@ -0,0 +1,84 @@
import { Router } from 'express'
import axios from 'axios'
const router = Router()
let vwCookie: string | null = null
let vwCookieExpiry = 0
async function getCookie(): Promise<string> {
if (vwCookie && Date.now() < vwCookieExpiry) return vwCookie
const host = process.env.VAULTWARDEN_HOST
const token = process.env.VAULTWARDEN_ADMIN_TOKEN
if (!host || !token) throw new Error('VAULTWARDEN_HOST / VAULTWARDEN_ADMIN_TOKEN not configured')
const res = await axios.post(
`${host}/admin`,
new URLSearchParams({ token: token.trim() }),
{
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
maxRedirects: 0,
validateStatus: s => s === 303 || s === 200,
}
)
const setCookieHeader = res.headers['set-cookie']
const match = (Array.isArray(setCookieHeader) ? setCookieHeader : [setCookieHeader ?? ''])
.find(c => c.startsWith('VW_ADMIN='))
if (!match) throw new Error('Admin login failed — check VAULTWARDEN_ADMIN_TOKEN')
vwCookie = match.split(';')[0]
vwCookieExpiry = Date.now() + 18 * 60 * 1000
return vwCookie
}
router.get('/stats', async (_req, res) => {
try {
const host = process.env.VAULTWARDEN_HOST
if (!host) { res.status(503).json({ error: 'VAULTWARDEN_HOST not configured' }); return }
const cookie = await getCookie()
const [usersRes, diagRes] = await Promise.all([
axios.get(`${host}/admin/users`, { headers: { Cookie: cookie, Accept: 'application/json' } }),
axios.get(`${host}/admin/diagnostics`, { headers: { Cookie: cookie, Accept: 'application/json' } }),
])
type VwUser = {
id: string
email: string
name?: string
lastActive?: string
creationDate?: string
userEnabled?: boolean
twoFactorEnabled?: boolean
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const users: any[] = usersRes.data ?? []
const diag = diagRes.data ?? {}
res.json({
version: diag.version ?? null,
signupsAllowed: diag.config?.signups_allowed ?? null,
userCount: users.length,
users: users.map(u => ({
email: u.email,
name: u.name || u.email,
enabled: u.userEnabled ?? true,
lastActive: u.lastActive ?? null,
created: u.creationDate ?? null,
twoFa: u.twoFactorEnabled ?? false,
cipherCount: u.cipherCount ?? u.cipher_count ?? null,
attachCount: u.attachmentCount ?? u.attachment_count ?? null,
})),
})
} catch (err: unknown) {
if (err instanceof Error && err.message.includes('login failed')) vwCookie = null
const msg = err instanceof Error ? err.message : 'Unknown error'
res.status(500).json({ error: msg })
}
})
export default router
+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'
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true
},
"include": ["src"]
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"moduleResolution": "node",
"outDir": "dist-server",
"rootDir": "server",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["server/**/*"]
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
},
},
})