syco.me Homelab Dashboard
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user