From b312ac38ef44c9508cf78e3acccc0293f178f984 Mon Sep 17 00:00:00 2001 From: orfelorfel23 Date: Fri, 28 Aug 2026 18:45:51 +0200 Subject: [PATCH] Fix database connection, add postgres healthcheck, update deployment templates --- .env | 18 ++ deployment-template/docker-compose.yml | 2 +- deployment-template/generate-env.sh | 8 +- deployment-template/tokenwatch.caddy | 3 + docker-compose.yml | 10 +- public/app.js | 148 +++++++++--- public/index.html | 83 +++++-- public/style.css | 304 ++++++++++++++++++++----- src/server.ts | 5 +- 9 files changed, 450 insertions(+), 131 deletions(-) create mode 100644 .env create mode 100644 deployment-template/tokenwatch.caddy diff --git a/.env b/.env new file mode 100644 index 0000000..985f415 --- /dev/null +++ b/.env @@ -0,0 +1,18 @@ +PORT=3000 + +# Database Configuration +DB_USER=postgres +DB_HOST=postgres +DB_NAME=tokenwatch +DB_PASSWORD=(H-?T5cNdSzQjOcA0wu(XjhBFuXh)6_2k0rCM,}v5c +DB_PORT=5432 + +# ntfy Configuration +NTFY_URL=https://ntfy.orfel.de/Token-Watch +ALERT_THRESHOLD_PERCENT=80 + +# Provider API Keys +ANTIGRAVITY_API_KEY=your_antigravity_key +ANTIGRAVITY_ENDPOINT=https://api.antigravity.google/v1/quota +POE_API_KEY=sk-poe-kYNW4JAHC5vPDPXEKoQZBRIh9kzjpI1aStcBfrG8NLw +MINIMAX_API_KEY=sk-api-_uWU8wuYW49aO5UwxYojPxBx0XPVlnwPzD2UybaBFgwsy9nxU9FsqeFn9ihIsXWOerUvTz3X4bpQs9cN7BfLBmcedrGhI5t5bpX6rEfpeARg_8YNl8ePGJI diff --git a/deployment-template/docker-compose.yml b/deployment-template/docker-compose.yml index 0ebc6d7..6895f78 100644 --- a/deployment-template/docker-compose.yml +++ b/deployment-template/docker-compose.yml @@ -8,7 +8,7 @@ services: env_file: - .env environment: - # TokenWatch liest DATABASE_URL und NTFY_URL aus der .env + # TokenWatch liest DB_USER, DB_PASSWORD, etc. und NTFY_URL aus der .env NODE_ENV: production networks: - jannik-cloud-net diff --git a/deployment-template/generate-env.sh b/deployment-template/generate-env.sh index 94e1b70..993372b 100644 --- a/deployment-template/generate-env.sh +++ b/deployment-template/generate-env.sh @@ -15,18 +15,14 @@ cat > "${SCRIPT_DIR}/.env" < "${SCRIPT_DIR}/tokenwatch.caddy" <<'EOF' -tokenwatch.orfel.de { - reverse_proxy 127.0.0.1:3500 -} -EOF + if [[ -f "${AGE_PUB_KEY}" ]]; then age -r "$(cat "${AGE_PUB_KEY}")" -o "${SCRIPT_DIR}/.env.age" "${SCRIPT_DIR}/.env" diff --git a/deployment-template/tokenwatch.caddy b/deployment-template/tokenwatch.caddy new file mode 100644 index 0000000..b5d2112 --- /dev/null +++ b/deployment-template/tokenwatch.caddy @@ -0,0 +1,3 @@ +tokenwatch.orfel.de { + reverse_proxy 127.0.0.1:3500 +} diff --git a/docker-compose.yml b/docker-compose.yml index f44ca27..8575182 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: tokenwatch: build: . @@ -18,7 +16,8 @@ services: - POE_API_KEY=${POE_API_KEY} - MINIMAX_API_KEY=${MINIMAX_API_KEY} depends_on: - - postgres + postgres: + condition: service_healthy postgres: image: postgres:15-alpine @@ -27,6 +26,11 @@ services: POSTGRES_USER: ${DB_USER:-postgres} POSTGRES_PASSWORD: ${DB_PASSWORD:-your_db_password} POSTGRES_DB: ${DB_NAME:-tokenwatch} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres}"] + interval: 5s + timeout: 5s + retries: 5 volumes: - pgdata:/var/lib/postgresql/data diff --git a/public/app.js b/public/app.js index 48402b1..5a15825 100644 --- a/public/app.js +++ b/public/app.js @@ -1,60 +1,75 @@ +let historyChart = null; + document.addEventListener('DOMContentLoaded', () => { fetchQuotas(); fetchHistory(); + setupThemeDropdown(); }); +function setupThemeDropdown() { + const dropdown = document.getElementById('theme-dropdown'); + dropdown.addEventListener('change', (e) => { + // Change body class + document.body.className = e.target.value; + + // Update chart colors if chart exists + if (historyChart) { + updateChartTheme(e.target.value); + } + }); +} + async function fetchQuotas() { try { const response = await fetch('/api/quotas'); - const data = await fetch('/api/history'); // Using history endpoint as mockup for latest if DB is empty? Wait, no, quotas endpoint should return latest. const quotas = await response.json(); - // If DB is empty, mock data for UI testing if quotas is empty + // Mock data if DB is empty const activeQuotas = quotas.length > 0 ? quotas : [ - { provider: 'Antigravity', used: 730, total: 1000, percentage: 73 }, + { provider: 'Antigravity', used: 68240, total: 100000, percentage: 68.24 }, { provider: 'POE', used: 33750, total: 75000, percentage: 45 }, { provider: 'MiniMax', used: 60, total: 100, percentage: 60 } ]; activeQuotas.forEach(quota => { - updateCard(quota.provider.toLowerCase(), quota); + updateRow(quota.provider.toLowerCase(), quota); }); } catch (error) { console.error('Error fetching quotas:', error); } } -function updateCard(providerId, quota) { +function updateRow(providerId, quota) { const fill = document.getElementById(`fill-${providerId}`); const statusText = document.getElementById(`status-${providerId}`); - const meme = document.getElementById(`meme-${providerId}`); + const percentText = document.getElementById(`percentage-${providerId}`); - if (!fill || !statusText) return; + if (!fill || !statusText || !percentText) return; const percentage = quota.percentage; fill.style.width = `${percentage}%`; - // Set color gradient position based on percentage - fill.style.backgroundPosition = `${100 - percentage}% 0`; + // Update percentage text + percentText.innerText = `${Math.round(percentage)}%`; - // Meme reactions based on percentage + // Status coloring + percentText.className = 'percentage'; // reset if (percentage > 90) { - meme.style.transform = 'scale(1.2) rotate(5deg)'; - statusText.style.color = 'var(--danger)'; - } else if (percentage < 20) { - statusText.style.color = 'var(--success)'; + percentText.classList.add('status-danger'); + } else if (percentage > 70) { + percentText.classList.add('status-warn'); + } else { + percentText.classList.add('status-good'); } + // Detail text if (quota.provider.toLowerCase() === 'minimax' && quota.meta) { const reset5h = new Date(quota.meta.intervalReset).toLocaleTimeString(); - const resetWeekly = new Date(quota.meta.weeklyReset).toLocaleDateString(); - statusText.innerHTML = `5h Limit: ${quota.meta.intervalUsed}% (Resets: ${reset5h})
Weekly: ${quota.meta.weeklyUsed}% (Resets: ${resetWeekly})`; + statusText.innerHTML = `5h: ${quota.meta.intervalUsed}% (Resets: ${reset5h})`; } else if (quota.provider.toLowerCase() === 'antigravity' && quota.meta) { - statusText.innerHTML = `5h Limit: ${quota.meta.intervalUsed}% (Resets: ${quota.meta.intervalResetStr})
Weekly: ${quota.meta.weeklyUsed}% (Resets: ${quota.meta.weeklyResetStr})`; - } else if (quota.provider.toLowerCase() === 'poe') { - statusText.innerText = `${percentage.toFixed(1)}% Used (${(quota.used).toLocaleString()} / ${(quota.total).toLocaleString()})`; + statusText.innerHTML = `5h: ${quota.meta.intervalUsed}% (Resets: ${quota.meta.intervalResetStr})`; } else { - statusText.innerText = `${percentage.toFixed(1)}% Used (${quota.used} / ${quota.total})`; + statusText.innerText = `${(quota.used).toLocaleString()} / ${(quota.total).toLocaleString()}`; } if (percentage >= 100) { @@ -63,7 +78,8 @@ function updateCard(providerId, quota) { spread: 70, origin: { y: 0.6 } }); - statusText.innerText = 'LIMIT REACHED! 😱'; + statusText.innerText = 'LIMIT REACHED!'; + statusText.style.color = 'var(--danger)'; } } @@ -72,7 +88,6 @@ async function fetchHistory() { const response = await fetch('/api/history'); const history = await response.json(); - // Process data for Chart.js const labels = [...new Set(history.map(item => new Date(item.timestamp).toLocaleTimeString()))].sort(); const antigravityData = history.filter(item => item.provider === 'Antigravity').map(item => item.percentage); @@ -80,42 +95,105 @@ async function fetchHistory() { const minimaxData = history.filter(item => item.provider === 'MiniMax').map(item => item.percentage); const ctx = document.getElementById('historyChart').getContext('2d'); - new Chart(ctx, { + + historyChart = new Chart(ctx, { type: 'line', data: { - labels: labels.length ? labels : ['10:00', '11:00', '12:00', '13:00'], // Fallback labels + labels: labels.length ? labels : ['10:00', '11:00', '12:00', '13:00'], datasets: [ { - label: 'Antigravity Usage (%)', - data: antigravityData.length ? antigravityData : [10, 30, 50, 73], - borderColor: '#bb86fc', - tension: 0.1 + label: 'Antigravity (%)', + data: antigravityData.length ? antigravityData : [10, 30, 50, 68], + borderColor: '#58a6ff', + backgroundColor: 'rgba(88, 166, 255, 0.1)', + borderWidth: 2, + tension: 0.3, + fill: true }, { - label: 'POE Usage (%)', + label: 'POE (%)', data: poeData.length ? poeData : [5, 10, 20, 45], - borderColor: '#03dac6', - tension: 0.1 + borderColor: '#3fb950', + backgroundColor: 'rgba(63, 185, 80, 0.1)', + borderWidth: 2, + tension: 0.3, + fill: true }, { - label: 'MiniMax Usage (%)', + label: 'MiniMax (%)', data: minimaxData.length ? minimaxData : [0, 0, 30, 60], - borderColor: '#f1c40f', - tension: 0.1 + borderColor: '#d29922', + backgroundColor: 'rgba(210, 153, 34, 0.1)', + borderWidth: 2, + tension: 0.3, + fill: true } ] }, options: { responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + labels: { color: '#c9d1d9' } + } + }, scales: { y: { beginAtZero: true, - max: 100 + max: 100, + grid: { color: '#30363d' }, + ticks: { color: '#8b949e' } + }, + x: { + grid: { color: '#30363d' }, + ticks: { color: '#8b949e' } } } } }); + + // Apply initial theme to chart + updateChartTheme(document.body.className); } catch (error) { console.error('Error fetching history:', error); } } + +function updateChartTheme(themeClass) { + if (!historyChart) return; + + let colors = ['#58a6ff', '#3fb950', '#d29922']; // Default + + switch (themeClass) { + case 'style-pacman': + colors = ['#f1c40f', '#f39c12', '#e67e22']; + break; + case 'style-nyancat': + colors = ['#ff0000', '#33ff00', '#0099ff']; + break; + case 'style-matrix': + colors = ['#0f0', '#0a0', '#050']; + break; + case 'style-lava': + colors = ['#ff4e00', '#ec9f05', '#bf3a00']; + break; + } + + historyChart.data.datasets.forEach((dataset, index) => { + dataset.borderColor = colors[index % colors.length]; + + // Convert hex to rgba for background + let hex = colors[index % colors.length].replace('#', ''); + if (hex.length === 3) { + hex = hex.split('').map(char => char + char).join(''); + } + const r = parseInt(hex.substring(0, 2), 16); + const g = parseInt(hex.substring(2, 4), 16); + const b = parseInt(hex.substring(4, 6), 16); + dataset.backgroundColor = `rgba(${r}, ${g}, ${b}, 0.1)`; + }); + + historyChart.update(); +} + diff --git a/public/index.html b/public/index.html index dcc594b..8e2c6de 100644 --- a/public/index.html +++ b/public/index.html @@ -8,45 +8,78 @@ - +
-

TokenWatch 📈

-

API Limit & Quota Tracker

+
+
+

TokenWatch

+

API Limit & Quota Tracker

+
+
+ + +
+
-
-
-

Antigravity (Gemini)

-
- Antigravity Meme +
+ +
+
+ 🤖 + Antigravity (Gemini)
-
-
+
+
+
+
+
+
+ 0% + Loading...
-

Loading...

-
-

POE

-
- POE Meme + +
+
+ 🧠 + POE
-
-
+
+
+
+
+
+
+ 0% + Loading...
-

Loading...

-
-

MiniMax (M3)

-
- MiniMax Meme + +
+
+ ⚡ + MiniMax (M3)
-
-
+
+
+
+
+
+
+ 0% + Loading...
-

Loading...

diff --git a/public/style.css b/public/style.css index 2110261..fc36bae 100644 --- a/public/style.css +++ b/public/style.css @@ -1,107 +1,293 @@ :root { - --bg-color: #121212; - --card-bg: #1e1e1e; - --text-color: #ffffff; - --primary: #bb86fc; - --danger: #cf6679; - --success: #03dac6; - --warning: #f1c40f; + --bg-color: #0d1117; + --row-bg: #161b22; + --row-border: #30363d; + --text-color: #c9d1d9; + --text-muted: #8b949e; + --primary: #58a6ff; + --danger: #f85149; + --success: #3fb950; + --warning: #d29922; + + --bar-bg: #21262d; + --bar-height: 24px; } body { margin: 0; padding: 0; - font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; background-color: var(--bg-color); color: var(--text-color); } .container { - max-width: 1200px; + max-width: 900px; margin: 0 auto; padding: 20px; } header { - text-align: center; - margin-bottom: 40px; + margin-bottom: 30px; + border-bottom: 1px solid var(--row-border); + padding-bottom: 20px; +} + +.header-content { + display: flex; + justify-content: space-between; + align-items: center; } header h1 { - font-size: 3em; - margin-bottom: 10px; + font-size: 2em; + margin: 0 0 5px 0; + color: #ffffff; } -.dashboard { +header p { + margin: 0; + color: var(--text-muted); +} + +.style-selector label { + margin-right: 10px; + font-weight: bold; +} + +.style-selector select { + background: var(--row-bg); + color: var(--text-color); + border: 1px solid var(--row-border); + padding: 8px 12px; + border-radius: 6px; + font-size: 1em; + cursor: pointer; + outline: none; +} + +.dashboard-list { display: flex; - justify-content: space-between; - gap: 20px; - flex-wrap: wrap; - margin-bottom: 50px; + flex-direction: column; + gap: 12px; + margin-bottom: 40px; } -.card { - background-color: var(--card-bg); - border-radius: 10px; - padding: 20px; - flex: 1; - min-width: 300px; - text-align: center; - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); - transition: transform 0.2s; -} - -.card:hover { - transform: translateY(-5px); -} - -.meme-container { - height: 150px; +.provider-row { + background-color: var(--row-bg); + border: 1px solid var(--row-border); + border-radius: 8px; + padding: 16px 20px; display: flex; align-items: center; - justify-content: center; - margin: 20px 0; - overflow: hidden; - border-radius: 8px; + gap: 20px; + transition: transform 0.2s, border-color 0.2s; } -.meme { - max-height: 100%; - max-width: 100%; - object-fit: contain; - transition: transform 0.5s; +.provider-row:hover { + border-color: var(--text-muted); +} + +.provider-info { + display: flex; + align-items: center; + gap: 12px; + flex: 0 0 200px; +} + +.provider-icon { + font-size: 1.5em; +} + +.provider-name { + font-weight: 600; + font-size: 1.1em; + color: #fff; +} + +.progress-container { + flex: 1; + display: flex; + align-items: center; } .progress-bar { - background-color: #333; - height: 30px; - border-radius: 15px; - overflow: hidden; - margin-bottom: 15px; + background-color: var(--bar-bg); + height: var(--bar-height); + width: 100%; + border-radius: 12px; position: relative; + overflow: hidden; } .progress-fill { height: 100%; width: 0%; - background: linear-gradient(90deg, var(--success), var(--warning), var(--danger)); - background-size: 200% 100%; - transition: width 1s ease-in-out, background-position 0.5s; - border-radius: 15px; + transition: width 0.8s ease-out; + border-radius: 12px; + position: relative; } -.status-text { - font-size: 1.1em; +.provider-stats { + flex: 0 0 150px; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; +} + +.percentage { font-weight: bold; + font-size: 1.2em; + color: var(--primary); +} + +.raw-value { + font-size: 0.85em; + color: var(--text-muted); } .charts { - background-color: var(--card-bg); + background-color: var(--row-bg); + border: 1px solid var(--row-border); padding: 20px; - border-radius: 10px; - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); + border-radius: 8px; } canvas { - max-height: 400px; + max-height: 350px; } + + +/* ========================================= + STYLE 1: STANDARD + ========================================= */ +.style-standard .progress-fill { + background: linear-gradient(90deg, #58a6ff, #bb86fc); +} + +/* ========================================= + STYLE 2: PAC-MAN + ========================================= */ +.style-pacman .progress-bar { + background-image: radial-gradient(#fff 15%, transparent 16%); + background-size: 20px 20px; + background-position: 0 center; +} +.style-pacman .progress-fill { + background-color: var(--bg-color); + border-radius: 0; +} +.style-pacman .progress-fill::after { + content: "ᗧ"; + color: #f1c40f; + font-size: 24px; + line-height: var(--bar-height); + position: absolute; + right: -10px; + top: -2px; + text-shadow: 0 0 5px rgba(241,196,15,0.5); + animation: chomp 0.5s infinite; +} +@keyframes chomp { + 0%, 100% { transform: scaleY(1); } + 50% { transform: scaleY(0.6); } +} + +/* ========================================= + STYLE 3: BATTERY + ========================================= */ +.style-battery .progress-bar { + border-radius: 4px; + border: 2px solid #555; + padding: 2px; + background: transparent; + height: calc(var(--bar-height) - 4px); + overflow: visible; +} +.style-battery .progress-bar::after { + content: ''; + position: absolute; + right: -6px; + top: 5px; + width: 4px; + height: calc(100% - 10px); + background: #555; + border-radius: 0 2px 2px 0; +} +.style-battery .progress-fill { + background-color: #3fb950; + border-radius: 2px; + background-image: linear-gradient(90deg, + transparent 0%, transparent 90%, + var(--bg-color) 90%, var(--bg-color) 100% + ); + background-size: 20px 100%; +} + +/* ========================================= + STYLE 4: NYAN CAT + ========================================= */ +.style-nyancat .progress-fill { + background: linear-gradient(to bottom, + #ff0000 16%, #ff9900 16% 32%, #ffff00 32% 48%, + #33ff00 48% 64%, #0099ff 64% 80%, #6633ff 80% + ); + border-radius: 0; +} +.style-nyancat .progress-fill::after { + content: "🐈"; + font-size: 20px; + position: absolute; + right: -15px; + top: -2px; +} + +/* ========================================= + STYLE 5: MATRIX + ========================================= */ +.style-matrix .progress-bar { + background-color: #000; + border-radius: 0; + border: 1px solid #0f0; +} +.style-matrix .progress-fill { + background-color: #000; + border-radius: 0; + overflow: hidden; +} +.style-matrix .progress-fill::before { + content: '0101100101011101000101110010101010110010101110100010111001010'; + color: #0f0; + font-family: monospace; + font-size: 14px; + letter-spacing: 2px; + white-space: nowrap; + position: absolute; + top: 2px; + left: 0; + text-shadow: 0 0 5px #0f0; +} + +/* ========================================= + STYLE 6: LAVA + ========================================= */ +.style-lava .progress-bar { + background-color: #2b110a; + border: 2px solid #5a2c1f; +} +.style-lava .progress-fill { + background: linear-gradient(90deg, #ff4e00, #ec9f05, #ff4e00); + background-size: 200% 100%; + animation: lavaFlow 2s linear infinite; + box-shadow: 0 0 10px #ff4e00 inset; +} +@keyframes lavaFlow { + 0% { background-position: 100% 0; } + 100% { background-position: 0 0; } +} + +/* Colors for percentages based on status */ +.status-good { color: var(--success); } +.status-warn { color: var(--warning); } +.status-danger { color: var(--danger); } diff --git a/src/server.ts b/src/server.ts index 56e750c..f17baac 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,6 +1,6 @@ import express from 'express'; import path from 'path'; -import { getQuotaHistory } from './services/db'; +import { getQuotaHistory, initDb } from './services/db'; import { fetchAllQuotas } from './services/providers'; import './cron'; // Start the cron jobs @@ -41,6 +41,7 @@ app.post('/api/webhook/antigravity', (req, res) => { } }); -app.listen(PORT, () => { +app.listen(PORT, async () => { + await initDb(); console.log(`TokenWatch server running on port ${PORT}`); });