Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.git
.gitignore
Dockerfile
.dockerignore
node_modules
npm-debug.log*
.DS_Store
10 changes: 10 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
FROM node:20-alpine

WORKDIR /app

COPY . .

ENV PORT=4173
EXPOSE 4173

CMD ["node", "server.js"]
55 changes: 55 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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://<your-host>: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.
146 changes: 146 additions & 0 deletions app.js
Original file line number Diff line number Diff line change
@@ -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();
60 changes: 60 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dragon Bandwidth Buddy</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<main class="app">
<header>
<h1>Dragon Bandwidth Buddy</h1>
<p>Self-hosted dragon pet powered by SABnzbd historical usage</p>
</header>

<section class="card config">
<h2>Polling</h2>
<div class="grid">
<label>
Poll interval (seconds)
<input id="pollInterval" type="number" min="5" max="600" value="30" />
</label>
</div>
<div class="actions">
<button id="saveConfig">Save</button>
<button id="pollNow" class="secondary">Poll now</button>
</div>
<p id="status" class="status">Ready.</p>
</section>

<section class="card pet-zone">
<h2>Dragon</h2>
<div class="pet-wrap">
<div id="petSprite" class="pet">🥚</div>
</div>
<div class="stats">
<div><strong>Stage:</strong> <span id="stageLabel">Dragon Egg</span></div>
<div><strong>Historical usage:</strong> <span id="usageLabel">0 MB</span></div>
<div><strong>Current speed:</strong> <span id="speedLabel">0 MB/s</span></div>
<div><strong>Mood:</strong> <span id="moodLabel">Dormant</span></div>
</div>
<div class="meter-wrap">
<label for="growthMeter">Growth to next stage</label>
<progress id="growthMeter" max="100" value="0"></progress>
</div>
</section>

<section class="card checklist">
<h2>Self-hosting checklist</h2>
<ol>
<li>Run <code>node server.js</code> on your LAN-accessible host.</li>
<li>Set <code>SAB_BASE_URL</code> and <code>SAB_API_KEY</code> env vars if needed.</li>
<li>Open this dashboard and watch your dragon evolve from historical usage.</li>
</ol>
</section>
</main>

<script src="app.js" defer></script>
</body>
</html>
Loading