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
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 10m59s
This commit is contained in:
parent
b2688c0cce
commit
5b59a7a010
6 changed files with 386 additions and 1 deletions
|
|
@ -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
146
public/usage.html
Normal 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>
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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`));
|
||||
}
|
||||
|
||||
|
|
|
|||
168
src/routes/__tests__/usage.test.ts
Normal file
168
src/routes/__tests__/usage.test.ts
Normal 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
64
src/routes/usage.ts
Normal 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 });
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue