feat: add usage dashboard (GET /v1/usage endpoint + usage.html page)
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 10m59s

This commit is contained in:
Hoid 2026-02-25 14:06:07 +00:00
parent b2688c0cce
commit 5b59a7a010
6 changed files with 386 additions and 1 deletions

View file

@ -243,6 +243,7 @@ footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(
<a href="#pricing">Pricing</a>
<a href="#docs">API Docs</a>
<a href="/docs" target="_blank">Swagger</a>
<a href="/usage">Usage</a>
<a href="#pricing" class="btn btn-primary btn-sm nav-cta">Get API Key</a>
</div>
<button class="nav-mobile" onclick="document.querySelector('.nav-links').classList.toggle('show')" aria-label="Menu"></button>
@ -702,12 +703,14 @@ screenshot = snap.<span class="fn">capture</span>(
<a href="#pricing">Pricing</a>
<a href="#playground">Playground</a>
<a href="/docs">API Docs</a>
<a href="/usage">Check Usage</a>
</div>
<div class="footer-col">
<h5>Developers</h5>
<a href="/docs">Swagger / OpenAPI</a>
<a href="#docs">Quick Start</a>
<a href="/health">Status</a>
<a href="/usage">Usage Dashboard</a>
</div>
<div class="footer-col">
<h5>Legal</h5>

146
public/usage.html Normal file
View file

@ -0,0 +1,146 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Usage Dashboard — SnapAPI</title>
<meta name="description" content="Check your SnapAPI usage statistics, remaining quota, and plan details.">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📸</text></svg>">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#0a0e17;--bg2:#0f1420;--card:#141a28;
--border:#1e2a3f;--border-light:#2a3752;
--text:#f0f2f7;--text-secondary:#94a3c0;--muted:#6b7a96;
--primary:#4f8fff;--primary-light:#6da3ff;--primary-dark:#3a6fd8;
--accent:#10b981;--orange:#f59e0b;--red:#ef4444;
--gradient:linear-gradient(135deg,#4f8fff 0%,#a78bfa 50%,#ec4899 100%);
--radius:12px;
}
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.6;-webkit-font-smoothing:antialiased;min-height:100vh;display:flex;flex-direction:column}
a{color:var(--primary-light);text-decoration:none}a:hover{color:var(--primary)}
nav{background:rgba(10,14,23,0.85);backdrop-filter:blur(20px);border-bottom:1px solid rgba(30,42,63,0.5);padding:0 24px}
.nav-inner{max-width:1180px;margin:0 auto;display:flex;align-items:center;justify-content:space-between;height:64px}
.nav-logo{font-size:1.15rem;font-weight:800;display:flex;align-items:center;gap:8px;color:var(--text)}
.nav-logo span{background:var(--gradient);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
.nav-links{display:flex;gap:24px;align-items:center}
.nav-links a{color:var(--muted);font-size:.9rem;font-weight:500}
.nav-links a:hover{color:var(--text)}
main{flex:1;max-width:560px;margin:80px auto;padding:0 24px;width:100%}
h1{font-size:2rem;font-weight:800;margin-bottom:8px}
.subtitle{color:var(--text-secondary);margin-bottom:40px}
label{display:block;font-size:.85rem;font-weight:600;color:var(--text-secondary);margin-bottom:8px}
.input-row{display:flex;gap:12px;margin-bottom:32px}
input[type="text"]{flex:1;background:var(--card);border:1px solid var(--border);border-radius:10px;padding:12px 16px;color:var(--text);font-size:.95rem;font-family:inherit;outline:none;transition:border-color .2s}
input[type="text"]:focus{border-color:var(--primary)}
button{background:var(--primary);color:#fff;border:none;border-radius:10px;padding:12px 24px;font-weight:600;font-size:.95rem;cursor:pointer;font-family:inherit;transition:background .2s}
button:hover{background:var(--primary-dark)}
button:disabled{opacity:.5;cursor:not-allowed}
.result{display:none}
.stats{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:24px}
.stat{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:20px}
.stat-label{font-size:.75rem;font-weight:600;text-transform:uppercase;letter-spacing:1px;color:var(--muted);margin-bottom:4px}
.stat-value{font-size:1.8rem;font-weight:800;font-family:'JetBrains Mono',monospace}
.stat-value.plan{text-transform:capitalize;font-family:'Inter',sans-serif;font-size:1.4rem}
.progress-wrap{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:20px;margin-bottom:24px}
.progress-header{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:12px}
.progress-title{font-weight:600}
.progress-pct{font-family:'JetBrains Mono',monospace;font-size:1.1rem;font-weight:700}
.progress-bar{height:12px;background:rgba(255,255,255,0.06);border-radius:6px;overflow:hidden}
.progress-fill{height:100%;border-radius:6px;background:var(--primary);transition:width .6s ease}
.progress-fill.warning{background:var(--orange)}
.progress-fill.danger{background:var(--red)}
.progress-msg{margin-top:10px;font-size:.85rem;color:var(--muted)}
.progress-msg.warning{color:var(--orange)}
.progress-msg.danger{color:var(--red)}
.links{display:flex;gap:16px;flex-wrap:wrap;margin-top:8px}
.links a{font-size:.9rem;font-weight:500}
.error-msg{background:rgba(239,68,68,0.1);border:1px solid rgba(239,68,68,0.3);border-radius:10px;padding:14px 18px;color:var(--red);font-size:.9rem;margin-bottom:16px;display:none}
footer{border-top:1px solid var(--border);padding:24px;text-align:center;font-size:.8rem;color:var(--muted);margin-top:auto}
@media(max-width:480px){.stats{grid-template-columns:1fr}.input-row{flex-direction:column}}
</style>
</head>
<body>
<nav><div class="nav-inner">
<a href="/" class="nav-logo">📸 <span>SnapAPI</span></a>
<div class="nav-links">
<a href="/">Home</a>
<a href="/docs">API Docs</a>
</div>
</div></nav>
<main>
<h1>Usage Dashboard</h1>
<p class="subtitle">Check your API usage for the current billing month.</p>
<label for="apikey">API Key</label>
<div class="input-row">
<input type="text" id="apikey" placeholder="snap_..." autocomplete="off" spellcheck="false" aria-label="API Key">
<button id="checkBtn" type="button">Check Usage</button>
</div>
<div class="error-msg" id="error" role="alert"></div>
<div class="result" id="result">
<div class="stats">
<div class="stat">
<div class="stat-label">Used / Limit</div>
<div class="stat-value" id="usedLimit"></div>
</div>
<div class="stat">
<div class="stat-label">Plan</div>
<div class="stat-value plan" id="plan"></div>
</div>
</div>
<div class="progress-wrap">
<div class="progress-header">
<span class="progress-title">Monthly Usage</span>
<span class="progress-pct" id="pct">0%</span>
</div>
<div class="progress-bar"><div class="progress-fill" id="bar" style="width:0%"></div></div>
<div class="progress-msg" id="msg"></div>
</div>
<div class="links">
<a href="/">← Home</a>
<a href="/docs">API Docs</a>
</div>
</div>
</main>
<footer>© 2026 Cloonar Technologies GmbH · <a href="/privacy">Privacy</a> · <a href="/terms">Terms</a></footer>
<script>
const btn=document.getElementById('checkBtn'),input=document.getElementById('apikey'),
result=document.getElementById('result'),error=document.getElementById('error');
async function check(){
const key=input.value.trim();
if(!key){input.focus();return}
btn.disabled=true;btn.textContent='Checking...';
error.style.display='none';result.style.display='none';
try{
const r=await fetch('/v1/usage',{headers:{'Authorization':'Bearer '+key}});
if(!r.ok){const e=await r.json().catch(()=>({error:'Request failed'}));throw new Error(e.error||'Error '+r.status)}
const d=await r.json();
document.getElementById('usedLimit').textContent=d.used.toLocaleString()+' / '+d.limit.toLocaleString();
document.getElementById('plan').textContent=d.plan;
document.getElementById('pct').textContent=d.percentUsed+'%';
const bar=document.getElementById('bar'),msg=document.getElementById('msg');
bar.style.width=Math.min(d.percentUsed,100)+'%';
bar.className='progress-fill'+(d.percentUsed>95?' danger':d.percentUsed>80?' warning':'');
msg.className='progress-msg';
if(d.percentUsed>95){msg.textContent='⚠️ Critical — almost at your limit!';msg.classList.add('danger')}
else if(d.percentUsed>80){msg.textContent='⚠️ Approaching your monthly limit.';msg.classList.add('warning')}
else{msg.textContent=d.remaining.toLocaleString()+' screenshots remaining for '+d.month+'.'}
result.style.display='block';
}catch(e){error.textContent=e.message;error.style.display='block'}
finally{btn.disabled=false;btn.textContent='Check Usage'}
}
btn.addEventListener('click',check);
input.addEventListener('keydown',e=>{if(e.key==='Enter')check()});
</script>
</body>
</html>

View file

@ -34,6 +34,7 @@ const options: swaggerJsdoc.Options = {
{ name: "Playground", description: "Free demo (no auth, watermarked)" },
{ name: "Signup", description: "Account creation" },
{ name: "Billing", description: "Subscription and payment management" },
{ name: "Usage", description: "API usage tracking" },
{ name: "System", description: "Health and status endpoints" },
],
components: {

View file

@ -17,6 +17,7 @@ import { initDatabase, pool } from "./services/db.js";
import { billingRouter } from "./routes/billing.js";
import { statusRouter } from "./routes/status.js";
import { signupRouter } from "./routes/signup.js";
import { usageRouter } from "./routes/usage.js";
import { openapiSpec } from "./docs/openapi.js";
const app = express();
@ -98,6 +99,7 @@ app.use("/v1/playground", playgroundRouter);
app.use("/v1/signup", signupRouter);
// Authenticated routes
app.use("/v1/usage", usageRouter);
app.use("/v1/screenshot", authMiddleware, usageMiddleware, screenshotRouter);
// API info
@ -108,6 +110,7 @@ app.get("/api", (_req, res) => {
endpoints: [
"POST /v1/playground — Try the API (no auth, watermarked, 5 req/hr)",
"POST /v1/screenshot — Take a screenshot (requires API key)",
"GET /v1/usage — Usage statistics (requires API key)",
"GET /health — Health check",
],
});
@ -123,7 +126,7 @@ app.get("/docs", (_req, res) => {
res.sendFile(path.join(__dirname, "../public/docs.html"));
});
// Clean URLs for legal pages (redirect /privacy → /privacy.html, etc.)
for (const page of ["privacy", "terms", "impressum", "status"]) {
for (const page of ["privacy", "terms", "impressum", "status", "usage"]) {
app.get(`/${page}`, (_req, res) => res.redirect(301, `/${page}.html`));
}

View file

@ -0,0 +1,168 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
// Mock dependencies before imports
vi.mock('../../services/keys.js', () => ({
isValidKey: vi.fn(),
getKeyInfo: vi.fn(),
getTierLimit: vi.fn(),
}))
vi.mock('../../middleware/usage.js', () => ({
getUsageForKey: vi.fn(),
}))
const { isValidKey, getKeyInfo, getTierLimit } = await import('../../services/keys.js')
const { getUsageForKey } = await import('../../middleware/usage.js')
const mockIsValidKey = vi.mocked(isValidKey)
const mockGetKeyInfo = vi.mocked(getKeyInfo)
const mockGetTierLimit = vi.mocked(getTierLimit)
const mockGetUsageForKey = vi.mocked(getUsageForKey)
// Import after mocks
const { usageRouter } = await import('../usage.js')
function createMockRequest(overrides: any = {}): any {
return { method: 'GET', headers: {}, query: {}, ...overrides }
}
function createMockResponse(): any {
const res: any = {
status: vi.fn().mockReturnThis(),
json: vi.fn().mockReturnThis(),
}
return res
}
function getHandler() {
return usageRouter.stack.find((layer: any) =>
layer.route?.methods.get && layer.route.path === '/'
)?.route.stack
}
describe('GET /v1/usage', () => {
beforeEach(() => { vi.clearAllMocks() })
afterEach(() => { vi.restoreAllMocks() })
it('returns 401 without API key', async () => {
const req = createMockRequest()
const res = createMockResponse()
const stack = getHandler()!
// First handler is auth middleware
const authHandler = stack[0].handle
await authHandler(req, res, vi.fn())
expect(res.status).toHaveBeenCalledWith(401)
})
it('returns usage data with valid key', async () => {
const now = new Date()
const monthKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
mockIsValidKey.mockResolvedValue(true)
mockGetKeyInfo.mockResolvedValue({
key: 'snap_test123',
tier: 'starter',
email: 'test@example.com',
createdAt: '2026-01-01T00:00:00Z',
})
mockGetTierLimit.mockReturnValue(1000)
mockGetUsageForKey.mockReturnValue({ count: 42, monthKey })
const req = createMockRequest({ headers: { authorization: 'Bearer snap_test123' } })
const res = createMockResponse()
// Run through all handlers in the stack
const stack = getHandler()!
let idx = 0
const runNext = async () => {
if (idx < stack.length) {
const handler = stack[idx++].handle
await handler(req, res, runNext)
}
}
await runNext()
expect(res.json).toHaveBeenCalledWith({
used: 42,
limit: 1000,
plan: 'starter',
month: monthKey,
remaining: 958,
percentUsed: 4.2,
})
})
it('returns 0 usage for key with no records', async () => {
const now = new Date()
const monthKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
mockIsValidKey.mockResolvedValue(true)
mockGetKeyInfo.mockResolvedValue({
key: 'snap_newkey',
tier: 'free',
email: 'new@example.com',
createdAt: '2026-01-01T00:00:00Z',
})
mockGetTierLimit.mockReturnValue(100)
mockGetUsageForKey.mockReturnValue(undefined)
const req = createMockRequest({ headers: { authorization: 'Bearer snap_newkey' } })
const res = createMockResponse()
const stack = getHandler()!
let idx = 0
const runNext = async () => {
if (idx < stack.length) {
const handler = stack[idx++].handle
await handler(req, res, runNext)
}
}
await runNext()
expect(res.json).toHaveBeenCalledWith({
used: 0,
limit: 100,
plan: 'free',
month: monthKey,
remaining: 100,
percentUsed: 0,
})
})
it('includes all required fields', async () => {
const now = new Date()
const monthKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
mockIsValidKey.mockResolvedValue(true)
mockGetKeyInfo.mockResolvedValue({
key: 'snap_abc',
tier: 'pro',
email: 'pro@example.com',
createdAt: '2026-01-01T00:00:00Z',
})
mockGetTierLimit.mockReturnValue(5000)
mockGetUsageForKey.mockReturnValue({ count: 2500, monthKey })
const req = createMockRequest({ headers: { authorization: 'Bearer snap_abc' } })
const res = createMockResponse()
const stack = getHandler()!
let idx = 0
const runNext = async () => {
if (idx < stack.length) {
const handler = stack[idx++].handle
await handler(req, res, runNext)
}
}
await runNext()
const data = res.json.mock.calls[0][0]
expect(data).toHaveProperty('used')
expect(data).toHaveProperty('limit')
expect(data).toHaveProperty('plan')
expect(data).toHaveProperty('month')
expect(data).toHaveProperty('remaining')
expect(data).toHaveProperty('percentUsed')
expect(typeof data.used).toBe('number')
expect(typeof data.percentUsed).toBe('number')
})
})

64
src/routes/usage.ts Normal file
View file

@ -0,0 +1,64 @@
import { Router } from "express";
import { authMiddleware } from "../middleware/auth.js";
import { getUsageForKey } from "../middleware/usage.js";
import { getTierLimit } from "../services/keys.js";
export const usageRouter = Router();
/**
* @openapi
* /v1/usage:
* get:
* tags: [Usage]
* summary: Get current usage for your API key
* description: Returns usage statistics for the authenticated API key in the current billing month.
* operationId: getUsage
* security:
* - BearerAuth: []
* - ApiKeyAuth: []
* - QueryKeyAuth: []
* responses:
* 200:
* description: Usage statistics
* content:
* application/json:
* schema:
* type: object
* properties:
* used:
* type: integer
* description: Screenshots used this month
* limit:
* type: integer
* description: Monthly screenshot limit for your plan
* plan:
* type: string
* description: Current plan name
* month:
* type: string
* description: Current billing month (YYYY-MM)
* remaining:
* type: integer
* description: Screenshots remaining this month
* percentUsed:
* type: number
* description: Percentage of limit used
* 401:
* description: Missing API key
* 403:
* description: Invalid API key
*/
usageRouter.get("/", authMiddleware, (req, res) => {
const keyInfo = (req as any).apiKeyInfo;
const limit = getTierLimit(keyInfo.tier);
const now = new Date();
const monthKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
const record = getUsageForKey(keyInfo.key);
const used = record && record.monthKey === monthKey ? record.count : 0;
const remaining = Math.max(0, limit - used);
const percentUsed = limit > 0 ? Math.round((used / limit) * 1000) / 10 : 0;
res.json({ used, limit, plan: keyInfo.tier, month: monthKey, remaining, percentUsed });
});