From b285c7a596931aa57c8071b725829e5e363aea73 Mon Sep 17 00:00:00 2001 From: digitalgp <23147603+digitalgp@users.noreply.github.com> Date: Fri, 8 May 2026 10:15:20 +0200 Subject: [PATCH] Add Docker image support for self-hosted dragon dashboard --- .dockerignore | 7 +++ Dockerfile | 10 ++++ README.md | 55 +++++++++++++++++++ app.js | 146 ++++++++++++++++++++++++++++++++++++++++++++++++++ index.html | 60 +++++++++++++++++++++ server.js | 117 ++++++++++++++++++++++++++++++++++++++++ styles.css | 120 +++++++++++++++++++++++++++++++++++++++++ 7 files changed, 515 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app.js create mode 100644 index.html create mode 100644 server.js create mode 100644 styles.css diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8ffebde --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +.gitignore +Dockerfile +.dockerignore +node_modules +npm-debug.log* +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3904ad2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY . . + +ENV PORT=4173 +EXPOSE 4173 + +CMD ["node", "server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..348794e --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +# Dragon Bandwidth Buddy (Self-hosted SABnzbd Pet) + +A self-hosted dashboard where a dragon evolves from your **historical SABnzbd usage**. + +## What changed + +- Self-hosted app via `server.js` (no direct browser-to-SAB calls). +- Backend proxy reads SAB config from environment variables. +- Growth is based on historical download totals from SAB history. +- Dragon-themed stages and moods. + +## Run locally (Node) + +```bash +export SAB_BASE_URL='http://192.168.10.20:8188' +export SAB_API_KEY='your_api_key_here' +export PORT=4173 +node server.js +``` + +Then open: `http://:4173` + +## Run with Docker + +Build image: + +```bash +docker build -t dragon-bandwidth-buddy:latest . +``` + +Run container: + +```bash +docker run -d \ + --name dragon-bandwidth-buddy \ + -p 4173:4173 \ + -e PORT=4173 \ + -e SAB_BASE_URL='http://192.168.10.20:8188' \ + -e SAB_API_KEY='your_api_key_here' \ + --restart unless-stopped \ + dragon-bandwidth-buddy:latest +``` + +## API endpoint used by the UI + +- `GET /api/pet-stats` → proxies SAB queue + history, returns: + - `usageHistoricalMb` + - `speedMbps` + - `queueLeftMb` + - `generatedAt` + +## Notes + +- Keeping the API key server-side avoids exposing it in browser local storage. +- If SAB returns `Forbidden`, verify API key permissions and network ACL/auth settings in SABnzbd. diff --git a/app.js b/app.js new file mode 100644 index 0000000..5d09fd4 --- /dev/null +++ b/app.js @@ -0,0 +1,146 @@ +const STORAGE_KEY = 'dragon-bandwidth-buddy-v2'; + +const STAGES = [ + { name: 'Dragon Egg', icon: '🥚', thresholdMb: 0 }, + { name: 'Wyrmling', icon: '🐉', thresholdMb: 5000 }, + { name: 'Drake', icon: '🐲', thresholdMb: 25000 }, + { name: 'Ancient Dragon', icon: '🐲🔥', thresholdMb: 100000 }, +]; + +const nodes = { + pollInterval: document.querySelector('#pollInterval'), + saveConfig: document.querySelector('#saveConfig'), + pollNow: document.querySelector('#pollNow'), + status: document.querySelector('#status'), + petSprite: document.querySelector('#petSprite'), + stageLabel: document.querySelector('#stageLabel'), + usageLabel: document.querySelector('#usageLabel'), + speedLabel: document.querySelector('#speedLabel'), + moodLabel: document.querySelector('#moodLabel'), + growthMeter: document.querySelector('#growthMeter'), +}; + +let state = { + config: { pollInterval: 30 }, + usageHistoricalMb: 0, + speedMbps: 0, + lastUpdated: null, +}; + +let pollTimer = null; + +function loadState() { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return; + try { + const parsed = JSON.parse(raw); + state = { + ...state, + ...parsed, + config: { ...state.config, ...(parsed.config || {}) }, + }; + } catch { + setStatus('Saved data looked corrupted; reset to defaults.'); + } +} + +function saveState() { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); +} + +function setStatus(message) { + nodes.status.textContent = message; +} + +function stageForUsage(usageMb) { + return STAGES.reduce((acc, stage) => (usageMb >= stage.thresholdMb ? stage : acc), STAGES[0]); +} + +function growthPercent(usageMb) { + const current = stageForUsage(usageMb); + const idx = STAGES.findIndex((s) => s.name === current.name); + const next = STAGES[idx + 1]; + if (!next) return 100; + const progress = usageMb - current.thresholdMb; + const required = next.thresholdMb - current.thresholdMb; + return Math.max(0, Math.min(100, Math.round((progress / required) * 100))); +} + +function moodForSpeed(speedMbps) { + if (speedMbps < 0.1) return 'Dormant'; + if (speedMbps < 2) return 'Swooping'; + if (speedMbps < 10) return 'Hunting'; + return 'Inferno'; +} + +function formatMb(value) { + return `${value.toLocaleString(undefined, { maximumFractionDigits: value > 1000 ? 0 : 1 })} MB`; +} + +function render() { + const stage = stageForUsage(state.usageHistoricalMb); + const mood = moodForSpeed(state.speedMbps); + nodes.petSprite.textContent = stage.icon; + nodes.stageLabel.textContent = stage.name; + nodes.usageLabel.textContent = formatMb(state.usageHistoricalMb); + nodes.speedLabel.textContent = `${state.speedMbps.toFixed(2)} MB/s`; + nodes.moodLabel.textContent = mood; + nodes.growthMeter.value = growthPercent(state.usageHistoricalMb); + nodes.petSprite.classList.toggle('happy', mood === 'Hunting'); + nodes.petSprite.classList.toggle('alert', mood === 'Inferno'); +} + +async function fetchStats() { + const response = await fetch('/api/pet-stats'); + if (!response.ok) { + const body = await response.json().catch(() => ({})); + throw new Error(body.error || `HTTP ${response.status}`); + } + return response.json(); +} + +async function pollStats() { + try { + setStatus('Polling SABnzbd proxy...'); + const data = await fetchStats(); + state.usageHistoricalMb = Math.max(0, data.usageHistoricalMb || 0); + state.speedMbps = Math.max(0, data.speedMbps || 0); + state.lastUpdated = data.generatedAt || new Date().toISOString(); + render(); + saveState(); + setStatus(`Updated at ${new Date(state.lastUpdated).toLocaleTimeString()}`); + } catch (error) { + setStatus(`Proxy poll failed: ${error.message}`); + } +} + +function schedulePolling() { + if (pollTimer) clearInterval(pollTimer); + pollTimer = setInterval(pollStats, state.config.pollInterval * 1000); +} + +function bindUi() { + nodes.saveConfig.addEventListener('click', () => { + const interval = Number.parseInt(nodes.pollInterval.value, 10); + state.config.pollInterval = Number.isFinite(interval) ? Math.max(5, interval) : 30; + nodes.pollInterval.value = String(state.config.pollInterval); + saveState(); + schedulePolling(); + setStatus('Settings saved.'); + }); + nodes.pollNow.addEventListener('click', pollStats); +} + +function hydrateUi() { + nodes.pollInterval.value = String(state.config.pollInterval); +} + +function init() { + loadState(); + hydrateUi(); + bindUi(); + render(); + schedulePolling(); +} + +init(); diff --git a/index.html b/index.html new file mode 100644 index 0000000..ec56003 --- /dev/null +++ b/index.html @@ -0,0 +1,60 @@ + + + + + + Dragon Bandwidth Buddy + + + +
+
+

Dragon Bandwidth Buddy

+

Self-hosted dragon pet powered by SABnzbd historical usage

+
+ +
+

Polling

+
+ +
+
+ + +
+

Ready.

+
+ +
+

Dragon

+
+
🥚
+
+
+
Stage: Dragon Egg
+
Historical usage: 0 MB
+
Current speed: 0 MB/s
+
Mood: Dormant
+
+
+ + +
+
+ +
+

Self-hosting checklist

+
    +
  1. Run node server.js on your LAN-accessible host.
  2. +
  3. Set SAB_BASE_URL and SAB_API_KEY env vars if needed.
  4. +
  5. Open this dashboard and watch your dragon evolve from historical usage.
  6. +
+
+
+ + + + diff --git a/server.js b/server.js new file mode 100644 index 0000000..e943ef9 --- /dev/null +++ b/server.js @@ -0,0 +1,117 @@ +const http = require('http'); +const fs = require('fs'); +const path = require('path'); + +const PORT = Number.parseInt(process.env.PORT || '4173', 10); +const SAB_BASE_URL = process.env.SAB_BASE_URL || 'http://192.168.10.20:8188'; +const SAB_API_KEY = process.env.SAB_API_KEY || '25b10a690c674af0bbb8175552e1ba33'; + +const MIME_TYPES = { + '.html': 'text/html; charset=utf-8', + '.js': 'application/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.ico': 'image/x-icon', +}; + +function parseSizeToMb(value) { + if (typeof value === 'number') return value; + if (typeof value !== 'string') return 0; + const clean = value.trim().replace(/,/g, ''); + const match = clean.match(/^([0-9]*\.?[0-9]+)\s*([KMGT]?B)?$/i); + if (!match) return 0; + const amount = Number.parseFloat(match[1]); + const unit = (match[2] || 'MB').toUpperCase(); + const multipliers = { KB: 1 / 1024, MB: 1, GB: 1024, TB: 1024 * 1024 }; + return amount * (multipliers[unit] || 1); +} + +function parseNumeric(value) { + if (typeof value === 'number') return value; + if (typeof value !== 'string') return 0; + const parsed = Number.parseFloat(value.replace(/[^0-9.-]/g, '')); + return Number.isFinite(parsed) ? parsed : 0; +} + +async function sabRequest(mode) { + const endpoint = new URL('/api', SAB_BASE_URL); + endpoint.search = new URLSearchParams({ mode, apikey: SAB_API_KEY, output: 'json' }).toString(); + const response = await fetch(endpoint, { headers: { Accept: 'application/json' } }); + if (!response.ok) throw new Error(`SABnzbd ${mode} failed with HTTP ${response.status}`); + return response.json(); +} + +function historyToMb(historyPayload) { + const slots = historyPayload?.history?.slots; + if (!Array.isArray(slots)) return 0; + return slots.reduce((acc, slot) => { + const sizeMb = parseSizeToMb(slot.bytes || slot.size || slot.storage); + return acc + sizeMb; + }, 0); +} + +async function petStats() { + const [queuePayload, historyPayload] = await Promise.all([sabRequest('queue'), sabRequest('history')]); + const queue = queuePayload?.queue || {}; + const speedMbps = parseNumeric(queue.kbpersec) / 1024; + return { + source: 'sabnzbd', + generatedAt: new Date().toISOString(), + usageHistoricalMb: historyToMb(historyPayload), + queueLeftMb: parseNumeric(queue.mbleft), + speedMbps, + }; +} + +function sendJson(res, code, payload) { + res.writeHead(code, { 'Content-Type': MIME_TYPES['.json'] }); + res.end(JSON.stringify(payload)); +} + +function serveFile(reqPath, res) { + const resolved = reqPath === '/' ? '/index.html' : reqPath; + const filePath = path.join(__dirname, resolved); + if (!filePath.startsWith(__dirname)) { + sendJson(res, 403, { error: 'Forbidden path' }); + return; + } + + fs.readFile(filePath, (err, data) => { + if (err) { + if (err.code === 'ENOENT') { + sendJson(res, 404, { error: 'Not found' }); + return; + } + sendJson(res, 500, { error: 'Failed to read file' }); + return; + } + const ext = path.extname(filePath); + res.writeHead(200, { 'Content-Type': MIME_TYPES[ext] || 'application/octet-stream' }); + res.end(data); + }); +} + +const server = http.createServer(async (req, res) => { + if (!req.url) { + sendJson(res, 400, { error: 'Bad request' }); + return; + } + + const { pathname } = new URL(req.url, `http://localhost:${PORT}`); + + if (pathname === '/api/pet-stats') { + try { + const stats = await petStats(); + sendJson(res, 200, stats); + } catch (error) { + sendJson(res, 502, { error: error.message }); + } + return; + } + + serveFile(pathname, res); +}); + +server.listen(PORT, () => { + console.log(`Bandwidth Buddy listening at http://0.0.0.0:${PORT}`); +}); diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..cb97b49 --- /dev/null +++ b/styles.css @@ -0,0 +1,120 @@ +:root { + color-scheme: dark; + --bg: #0d1321; + --card: #1d2d44; + --accent: #3e5c76; + --accent-2: #748cab; + --text: #f0ebd8; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: Inter, system-ui, sans-serif; + background: radial-gradient(circle at top, #1d2d44, var(--bg)); + color: var(--text); +} + +.app { + max-width: 920px; + margin: 0 auto; + padding: 2rem 1rem 4rem; + display: grid; + gap: 1rem; +} + +header h1 { + margin-bottom: .25rem; +} + +header p { + margin: 0; + opacity: .8; +} + +.card { + background: color-mix(in srgb, var(--card), black 10%); + border: 1px solid color-mix(in srgb, var(--accent), white 15%); + border-radius: 14px; + padding: 1rem; +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: .75rem; +} + +label { + display: grid; + gap: .3rem; + font-size: .95rem; +} + +input { + background: #0b1b2f; + color: var(--text); + border: 1px solid var(--accent-2); + border-radius: 8px; + padding: .55rem .65rem; +} + +.actions { + margin-top: .75rem; + display: flex; + gap: .5rem; +} + +button { + border: 0; + border-radius: 10px; + padding: .6rem .9rem; + cursor: pointer; + background: var(--accent-2); + color: #031321; + font-weight: 600; +} + +button.secondary { + background: transparent; + color: var(--text); + border: 1px solid var(--accent-2); +} + +.status { + margin: .75rem 0 0; + font-size: .9rem; + opacity: .9; +} + +.pet-wrap { + display: grid; + place-items: center; + min-height: 120px; +} + +.pet { + font-size: 5rem; + transition: transform 200ms ease; + user-select: none; +} + +.pet.happy { transform: translateY(-4px) scale(1.04); } +.pet.alert { transform: scale(1.08); } + +.stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: .5rem; + margin: .5rem 0 1rem; +} + +progress { + width: 100%; + height: 16px; +} + +ol { + margin: .4rem 0 .2rem 1rem; +}