syco.me Homelab Dashboard
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-server
|
||||||
|
.env
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
.env
|
||||||
+17
@@ -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"]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
services:
|
||||||
|
dashboard:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "3001:3001"
|
||||||
|
env_file: .env
|
||||||
|
restart: unless-stopped
|
||||||
+15
@@ -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>
|
||||||
Generated
+3887
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`))
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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'
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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/**/*"]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user