Initial commit for TokenWatch

This commit is contained in:
Jannik committed 2026-08-27 07:00:29 +02:00
commit ed5cc23a35
15 files changed
+2903

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=your_db_password
DB_PORT=5432
# ntfy Configuration
NTFY_URL=https://ntfy.orfel.de/tokenwatch_dummy
ALERT_THRESHOLD_PERCENT=80
# Provider API Keys
ANTIGRAVITY_API_KEY=your_antigravity_key
ANTIGRAVITY_ENDPOINT=https://api.antigravity.google/v1/quota
POE_API_KEY=your_poe_key
MINIMAX_API_KEY=your_minimax_key
BIN
View File
Binary file not shown.
+16
View File
@@ -0,0 +1,16 @@
FROM node:18-alpine
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install
COPY tsconfig.json ./
COPY src/ ./src/
COPY public/ ./public/
RUN npx tsc
EXPOSE 3000
CMD ["node", "dist/server.js"]
+34
View File
@@ -0,0 +1,34 @@
version: '3.8'
services:
tokenwatch:
build: .
restart: unless-stopped
ports:
- "3000:3000"
environment:
- PORT=3000
- DB_USER=${DB_USER:-postgres}
- DB_HOST=postgres
- DB_NAME=${DB_NAME:-tokenwatch}
- DB_PASSWORD=${DB_PASSWORD:-your_db_password}
- NTFY_URL=${NTFY_URL:-https://ntfy.orfel.de/tokenwatch_dummy}
- ANTIGRAVITY_API_KEY=${ANTIGRAVITY_API_KEY}
- ANTIGRAVITY_ENDPOINT=${ANTIGRAVITY_ENDPOINT}
- POE_API_KEY=${POE_API_KEY}
- MINIMAX_API_KEY=${MINIMAX_API_KEY}
depends_on:
- postgres
postgres:
image: postgres:15-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${DB_USER:-postgres}
POSTGRES_PASSWORD: ${DB_PASSWORD:-your_db_password}
POSTGRES_DB: ${DB_NAME:-tokenwatch}
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
+2242
View File
File diff suppressed because it is too large. Load diff
+35
View File
@@ -0,0 +1,35 @@
{
"name": "tokenwatch",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node dist/server.js",
"build": "tsc",
"dev": "tsx src/server.ts"
},
"repository": {
"type": "git",
"url": "https://git.orfel.de/Jannik/TokenWatch.git"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"axios": "^1.20.0",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"node-cron": "^4.6.0",
"pg": "^8.23.0"
},
"devDependencies": {
"@types/express": "^5.0.6",
"@types/node": "^26.4.0",
"@types/node-cron": "^3.0.11",
"@types/pg": "^8.23.1",
"tsx": "^4.23.12",
"typescript": "^7.0.2"
}
}
+111
View File
@@ -0,0 +1,111 @@
document.addEventListener('DOMContentLoaded', () => {
fetchQuotas();
fetchHistory();
});
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
const activeQuotas = quotas.length > 0 ? quotas : [
{ provider: 'Antigravity', used: 730, total: 1000, percentage: 73 },
{ provider: 'POE', used: 33750, total: 75000, percentage: 45 },
{ provider: 'MiniMax', used: 60, total: 100, percentage: 60 }
];
activeQuotas.forEach(quota => {
updateCard(quota.provider.toLowerCase(), quota);
});
} catch (error) {
console.error('Error fetching quotas:', error);
}
}
function updateCard(providerId, quota) {
const fill = document.getElementById(`fill-${providerId}`);
const statusText = document.getElementById(`status-${providerId}`);
const meme = document.getElementById(`meme-${providerId}`);
if (!fill || !statusText) return;
const percentage = quota.percentage;
fill.style.width = `${percentage}%`;
// Set color gradient position based on percentage
fill.style.backgroundPosition = `${100 - percentage}% 0`;
statusText.innerText = `${percentage.toFixed(1)}% Used (${quota.used} / ${quota.total})`;
// Meme reactions based on percentage
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)';
}
if (percentage >= 100) {
confetti({
particleCount: 100,
spread: 70,
origin: { y: 0.6 }
});
statusText.innerText = 'LIMIT REACHED! 😱';
}
}
async function fetchHistory() {
try {
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);
const poeData = history.filter(item => item.provider === 'POE').map(item => item.percentage);
const minimaxData = history.filter(item => item.provider === 'MiniMax').map(item => item.percentage);
const ctx = document.getElementById('historyChart').getContext('2d');
new Chart(ctx, {
type: 'line',
data: {
labels: labels.length ? labels : ['10:00', '11:00', '12:00', '13:00'], // Fallback labels
datasets: [
{
label: 'Antigravity Usage (%)',
data: antigravityData.length ? antigravityData : [10, 30, 50, 73],
borderColor: '#bb86fc',
tension: 0.1
},
{
label: 'POE Usage (%)',
data: poeData.length ? poeData : [5, 10, 20, 45],
borderColor: '#03dac6',
tension: 0.1
},
{
label: 'MiniMax Usage (%)',
data: minimaxData.length ? minimaxData : [0, 0, 30, 60],
borderColor: '#f1c40f',
tension: 0.1
}
]
},
options: {
responsive: true,
scales: {
y: {
beginAtZero: true,
max: 100
}
}
}
});
} catch (error) {
console.error('Error fetching history:', error);
}
}
+61
View File
@@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TokenWatch - API Limit Tracker</title>
<link rel="stylesheet" href="style.css">
<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>
<div class="container">
<header>
<h1>TokenWatch 📈</h1>
<p>API Limit & Quota Tracker</p>
</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">
</div>
<div class="progress-bar">
<div class="progress-fill" id="fill-antigravity"></div>
</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">
</div>
<div class="progress-bar">
<div class="progress-fill" id="fill-poe"></div>
</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">
</div>
<div class="progress-bar">
<div class="progress-fill" id="fill-minimax"></div>
</div>
<p class="status-text" id="status-minimax">Loading...</p>
</div>
</section>
<section class="charts">
<h2>History</h2>
<canvas id="historyChart"></canvas>
</section>
</div>
<script src="app.js"></script>
</body>
</html>
+107
View File
@@ -0,0 +1,107 @@
:root {
--bg-color: #121212;
--card-bg: #1e1e1e;
--text-color: #ffffff;
--primary: #bb86fc;
--danger: #cf6679;
--success: #03dac6;
--warning: #f1c40f;
}
body {
margin: 0;
padding: 0;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: var(--bg-color);
color: var(--text-color);
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
header {
text-align: center;
margin-bottom: 40px;
}
header h1 {
font-size: 3em;
margin-bottom: 10px;
}
.dashboard {
display: flex;
justify-content: space-between;
gap: 20px;
flex-wrap: wrap;
margin-bottom: 50px;
}
.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;
display: flex;
align-items: center;
justify-content: center;
margin: 20px 0;
overflow: hidden;
border-radius: 8px;
}
.meme {
max-height: 100%;
max-width: 100%;
object-fit: contain;
transition: transform 0.5s;
}
.progress-bar {
background-color: #333;
height: 30px;
border-radius: 15px;
overflow: hidden;
margin-bottom: 15px;
position: relative;
}
.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;
}
.status-text {
font-size: 1.1em;
font-weight: bold;
}
.charts {
background-color: var(--card-bg);
padding: 20px;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
}
canvas {
max-height: 400px;
}
+29
View File
@@ -0,0 +1,29 @@
import cron from 'node-cron';
import { fetchAllQuotas } from './services/providers';
import { insertQuota } from './services/db';
import { sendNtfyAlert } from './services/ntfy';
import dotenv from 'dotenv';
dotenv.config();
const ALERT_THRESHOLD = parseInt(process.env.ALERT_THRESHOLD_PERCENT || '80');
cron.schedule('*/30 * * * *', async () => {
console.log('Running quota check cron job...');
try {
const quotas = await fetchAllQuotas();
for (const quota of quotas) {
// Save to DB
await insertQuota(quota);
// Send alert if threshold exceeded
if (quota.percentage >= ALERT_THRESHOLD) {
await sendNtfyAlert(quota.provider, quota.percentage);
}
}
} catch (error) {
console.error('Error in cron job:', error);
}
});
console.log('Cron jobs scheduled');
+34
View File
@@ -0,0 +1,34 @@
import express from 'express';
import path from 'path';
import { getLatestQuotas, getQuotaHistory } from './services/db';
import './cron'; // Start the cron jobs
const app = express();
const PORT = process.env.PORT || 3000;
app.use(express.static(path.join(__dirname, '../public')));
app.use(express.json());
app.get('/api/quotas', async (req, res) => {
try {
const quotas = await getLatestQuotas();
res.json(quotas);
} catch (error) {
console.error('Error fetching quotas:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
app.get('/api/history', async (req, res) => {
try {
const history = await getQuotaHistory();
res.json(history);
} catch (error) {
console.error('Error fetching history:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
app.listen(PORT, () => {
console.log(`TokenWatch server running on port ${PORT}`);
});
+72
View File
@@ -0,0 +1,72 @@
import { Pool } from 'pg';
import dotenv from 'dotenv';
dotenv.config();
export const pool = new Pool({
user: process.env.DB_USER,
host: process.env.DB_HOST,
database: process.env.DB_NAME,
password: process.env.DB_PASSWORD,
port: parseInt(process.env.DB_PORT || '5432'),
});
export interface QuotaEntry {
id?: number;
provider: string;
used: number;
total: number;
percentage: number;
timestamp?: Date;
}
export async function initDb() {
const client = await pool.connect();
try {
await client.query(`
CREATE TABLE IF NOT EXISTS quotas (
id SERIAL PRIMARY KEY,
provider VARCHAR(50) NOT NULL,
used NUMERIC NOT NULL,
total NUMERIC NOT NULL,
percentage NUMERIC NOT NULL,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`);
console.log('Database initialized');
} finally {
client.release();
}
}
export async function insertQuota(entry: QuotaEntry) {
const query = `
INSERT INTO quotas (provider, used, total, percentage)
VALUES ($1, $2, $3, $4)
RETURNING *;
`;
const values = [entry.provider, entry.used, entry.total, entry.percentage];
const res = await pool.query(query, values);
return res.rows[0];
}
export async function getLatestQuotas() {
const query = `
SELECT DISTINCT ON (provider) *
FROM quotas
ORDER BY provider, timestamp DESC;
`;
const res = await pool.query(query);
return res.rows;
}
export async function getQuotaHistory(days: number = 7) {
const query = `
SELECT *
FROM quotas
WHERE timestamp > CURRENT_DATE - $1::integer
ORDER BY timestamp ASC;
`;
const res = await pool.query(query, [days]);
return res.rows;
}
+21
View File
@@ -0,0 +1,21 @@
import axios from 'axios';
import dotenv from 'dotenv';
dotenv.config();
export async function sendNtfyAlert(provider: string, percentage: number) {
const url = process.env.NTFY_URL || 'https://ntfy.orfel.de/tokenwatch_dummy';
const message = `🚨 ${provider} quota is running low! (${percentage.toFixed(1)}% used)`;
try {
await axios.post(url, message, {
headers: {
'Title': 'TokenWatch Alert',
'Tags': 'warning,robot'
}
});
console.log(`ntfy alert sent for ${provider}`);
} catch (error: any) {
console.error('Failed to send ntfy alert:', error.message);
}
}
+79
View File
@@ -0,0 +1,79 @@
import axios from 'axios';
import dotenv from 'dotenv';
import { QuotaEntry } from './db';
dotenv.config();
export async function fetchAntigravityQuota(): Promise<QuotaEntry> {
try {
const url = process.env.ANTIGRAVITY_ENDPOINT || 'https://api.antigravity.google/v1/quota';
const res = await axios.get(url, {
headers: {
'Authorization': `Bearer ${process.env.ANTIGRAVITY_API_KEY}`
}
});
// Assuming response structure: { used: number, total: number }
const used = res.data.used || 0;
const total = res.data.total || 1000;
const percentage = (used / total) * 100;
return { provider: 'Antigravity', used, total, percentage };
} catch (error: any) {
console.error('Error fetching Antigravity quota:', error.message);
// Fallback for demonstration / local testing if endpoint is mocked
return { provider: 'Antigravity', used: 0, total: 100, percentage: 0 };
}
}
export async function fetchPoeQuota(): Promise<QuotaEntry> {
try {
// According to docs, returns available point balance
const res = await axios.get('https://api.poe.com/usage/current_balance', {
headers: {
'Authorization': `Bearer ${process.env.POE_API_KEY}`
}
});
// POE logic: 75000 daily points is standard. We might need to adjust based on actual API payload.
// Assuming response has: { available: number }
const available = res.data.available || 0;
const total = 75000;
const used = total - available > 0 ? total - available : 0;
const percentage = (used / total) * 100;
return { provider: 'POE', used, total, percentage };
} catch (error: any) {
console.error('Error fetching POE quota:', error.message);
return { provider: 'POE', used: 0, total: 75000, percentage: 0 };
}
}
export async function fetchMinimaxQuota(): Promise<QuotaEntry> {
try {
const res = await axios.get('https://api.minimax.io/v1/api/openplatform/coding_plan/remains', {
headers: {
'Authorization': `Bearer ${process.env.MINIMAX_API_KEY}`
}
});
// Assuming response has { used_quota: number, total_quota: number }
const used = res.data.used_quota || 0;
const total = res.data.total_quota || 100;
const percentage = (used / total) * 100;
return { provider: 'MiniMax', used, total, percentage };
} catch (error: any) {
console.error('Error fetching MiniMax quota:', error.message);
return { provider: 'MiniMax', used: 0, total: 100, percentage: 0 };
}
}
export async function fetchAllQuotas(): Promise<QuotaEntry[]> {
const results = await Promise.all([
fetchAntigravityQuota(),
fetchPoeQuota(),
fetchMinimaxQuota()
]);
return results;
}
+44
View File
@@ -0,0 +1,44 @@
{
// Visit https://aka.ms/tsconfig to read more about this file
"compilerOptions": {
// File Layout
// "rootDir": "./src",
// "outDir": "./dist",
// Environment Settings
// See also https://aka.ms/tsconfig/module
"module": "nodenext",
"target": "esnext",
"types": [],
// For nodejs:
// "lib": ["esnext"],
// "types": ["node"],
// and npm install -D @types/node
// Other Outputs
"sourceMap": true,
"declaration": true,
"declarationMap": true,
// Stricter Typechecking Options
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
// Style Options
// "noImplicitReturns": true,
// "noImplicitOverride": true,
// "noUnusedLocals": true,
// "noUnusedParameters": true,
// "noFallthroughCasesInSwitch": true,
// "noPropertyAccessFromIndexSignature": true,
// Recommended Options
"strict": true,
"jsx": "react-jsx",
"verbatimModuleSyntax": true,
"isolatedModules": true,
"noUncheckedSideEffectImports": true,
"moduleDetection": "force",
"skipLibCheck": true,
}
}