Fix database connection, add postgres healthcheck, update deployment templates
Build and Push Docker Image / build-and-push (push) Canceled after 0s

This commit is contained in:
Jannik committed 2026-08-28 18:45:51 +02:00
1 parent 516a498f75
commit b312ac38ef
9 files changed
+450 -131

No files matched your search

+18
View File
@@ -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
+1 -1
View File
@@ -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
+2 -6
View File
@@ -15,18 +15,14 @@ cat > "${SCRIPT_DIR}/.env" <<EOF
DB_USER=${DB_USER}
DB_PASSWORD=${DB_PASSWORD}
DB_NAME=${DB_NAME}
DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@postgres:5432/${DB_NAME}?sslmode=disable
DB_HOST=postgres
NTFY_URL=https://ntfy.orfel.de/Token-Watch
EOF
chmod 600 "${SCRIPT_DIR}/.env"
cat > "${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"
+3
View File
@@ -0,0 +1,3 @@
tokenwatch.orfel.de {
reverse_proxy 127.0.0.1:3500
}
+7 -3
View File
@@ -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
+113 -35
View File
@@ -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})<br>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})<br>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();
}
+58 -25
View File
@@ -8,45 +8,78 @@
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/canvas-confetti@1.6.0/dist/confetti.browser.min.js"></script>
</head>
<body>
<body class="style-standard">
<div class="container">
<header>
<h1>TokenWatch 📈</h1>
<p>API Limit & Quota Tracker</p>
<div class="header-content">
<div>
<h1>TokenWatch</h1>
<p>API Limit & Quota Tracker</p>
</div>
<div class="style-selector">
<label for="theme-dropdown">Progress Bar Style:</label>
<select id="theme-dropdown">
<option value="style-standard">1. Standard</option>
<option value="style-pacman">2. Pac-Man</option>
<option value="style-battery">3. Battery</option>
<option value="style-nyancat">4. Nyan Cat</option>
<option value="style-matrix">5. Matrix</option>
<option value="style-lava">6. Lava</option>
</select>
</div>
</div>
</header>
<section class="dashboard">
<div class="card" id="card-antigravity">
<h2>Antigravity (Gemini)</h2>
<div class="meme-container">
<img id="meme-antigravity" class="meme" src="https://i.imgflip.com/1g8my4.jpg" alt="Antigravity Meme">
<section class="dashboard-list">
<!-- Antigravity Row -->
<div class="provider-row" id="row-antigravity">
<div class="provider-info">
<span class="provider-icon">🤖</span>
<span class="provider-name">Antigravity (Gemini)</span>
</div>
<div class="progress-bar">
<div class="progress-fill" id="fill-antigravity"></div>
<div class="progress-container">
<div class="progress-bar">
<div class="progress-fill" id="fill-antigravity"></div>
</div>
</div>
<div class="provider-stats">
<span class="percentage" id="percentage-antigravity">0%</span>
<span class="raw-value" id="status-antigravity">Loading...</span>
</div>
<p class="status-text" id="status-antigravity">Loading...</p>
</div>
<div class="card" id="card-poe">
<h2>POE</h2>
<div class="meme-container">
<img id="meme-poe" class="meme" src="https://i.kym-cdn.com/entries/icons/original/000/028/021/work.jpg" alt="POE Meme">
<!-- POE Row -->
<div class="provider-row" id="row-poe">
<div class="provider-info">
<span class="provider-icon">🧠</span>
<span class="provider-name">POE</span>
</div>
<div class="progress-bar">
<div class="progress-fill" id="fill-poe"></div>
<div class="progress-container">
<div class="progress-bar">
<div class="progress-fill" id="fill-poe"></div>
</div>
</div>
<div class="provider-stats">
<span class="percentage" id="percentage-poe">0%</span>
<span class="raw-value" id="status-poe">Loading...</span>
</div>
<p class="status-text" id="status-poe">Loading...</p>
</div>
<div class="card" id="card-minimax">
<h2>MiniMax (M3)</h2>
<div class="meme-container">
<img id="meme-minimax" class="meme" src="https://i.kym-cdn.com/entries/icons/original/000/018/012/this_is_fine.jpeg" alt="MiniMax Meme">
<!-- MiniMax Row -->
<div class="provider-row" id="row-minimax">
<div class="provider-info">
<span class="provider-icon">⚡</span>
<span class="provider-name">MiniMax (M3)</span>
</div>
<div class="progress-bar">
<div class="progress-fill" id="fill-minimax"></div>
<div class="progress-container">
<div class="progress-bar">
<div class="progress-fill" id="fill-minimax"></div>
</div>
</div>
<div class="provider-stats">
<span class="percentage" id="percentage-minimax">0%</span>
<span class="raw-value" id="status-minimax">Loading...</span>
</div>
<p class="status-text" id="status-minimax">Loading...</p>
</div>
</section>
+245 -59
View File
@@ -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); }
+3 -2
View File
@@ -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}`);
});