Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/stats-ios-palette.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw-webapp": patch
---

stats.ftw.energy now uses the phone app's colours, with a light theme that follows the device. The charts are ink, live is green and a failed collector is red.
11 changes: 6 additions & 5 deletions stats/src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export function dashboardDocument(privateView: boolean): DashboardDocument {
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="dark">
<meta name="color-scheme" content="dark light">
<title>FTW project stats</title>
<style nonce="${nonce}">${styles}</style>
</head>
Expand Down Expand Up @@ -230,7 +230,7 @@ ${privateView ? opsSection : ''} <section id="private-grid" class="two-col" h
for (const [key, label, maxAgeHours] of labels) {
const item = node('div', undefined, 'freshness-item');
const timestamp = freshness && freshness[key];
const dot = node('span', '', 'dot ' + (!timestamp ? 'idle' : Date.now() - Date.parse(timestamp) > maxAgeHours * 3600000 ? 'warn' : 'ok'));
const dot = node('span', '', 'dot ' + (!timestamp ? 'idle' : Date.now() - Date.parse(timestamp) > maxAgeHours * 3600000 ? 'stale' : 'ok'));
item.append(dot, node('span', label), node('strong', age(timestamp)));
host.append(item);
}
Expand Down Expand Up @@ -536,7 +536,7 @@ ${privateView ? opsScript : ''} function renderPrivate(data) {
collectors.replaceChildren();
for (const collector of data.collectors || []) {
const item = node('li');
item.append(node('span', '', 'dot ' + (collector.ok ? 'ok' : 'warn')), node('strong', collector.source), node('span', age(collector.finished_at), 'muted'));
item.append(node('span', '', 'dot ' + (collector.ok ? 'ok' : 'alarm')), node('strong', collector.source), node('span', age(collector.finished_at), 'muted'));
collectors.append(item);
}
if (!collectors.children.length) collectors.append(node('li', 'No collector runs yet.', 'empty'));
Expand Down Expand Up @@ -584,9 +584,10 @@ function randomNonce(): string {
}

const styles = `
:root{--bg:#0b0c0b;--surface:#111311;--raised:#171917;--line:#2a2d2a;--fg:#f2f1ec;--dim:#9c9f9a;--accent:#ffab2e;--accent-soft:#3b2b13;--ok:#72d69b;--warn:#ffbf5f;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--fg);background:var(--bg);font-synthesis:none}
:root{color-scheme:dark;--bg:#05080f;--surface:#0b111c;--raised:#111a29;--line:#1b2433;--fg:#eef3ff;--dim:#9aa5b8;--muted:#727d90;--live:#83f52c;--alarm:#ff453a;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--fg);background:var(--bg);font-synthesis:none}
@media(prefers-color-scheme:light){:root{color-scheme:light;--bg:#f5f6f8;--surface:#ffffff;--raised:#ffffff;--line:#dce0e7;--fg:#0a0f1a;--dim:#545e70;--muted:#677184;--live:#2f7a00;--alarm:#d70015}}
[hidden]{display:none!important}
*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 80% -20%,#252116 0,transparent 34rem),var(--bg);min-height:100vh}a{color:inherit}.topbar{height:58px;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;padding:0 clamp(18px,4vw,56px);position:sticky;top:0;background:color-mix(in srgb,var(--bg) 90%,transparent);backdrop-filter:blur(16px);z-index:5}.brand{display:flex;gap:10px;align-items:center;text-decoration:none;font:700 12px/1 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.18em}.brand-mark{color:var(--accent)}.brand-slash{color:#5e615d}.top-actions{display:flex;align-items:center;gap:16px;font:11px/1 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.08em}.mode{color:var(--accent);border:1px solid #59401c;padding:6px 8px;border-radius:2px}.muted{color:var(--dim)}main{width:min(1280px,calc(100% - 36px));margin:0 auto;padding:64px 0 72px}.hero{display:flex;justify-content:space-between;gap:48px;align-items:flex-end;margin-bottom:54px}.eyebrow{color:var(--accent);font:700 10px/1.2 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.2em;margin:0 0 12px}h1{font-size:clamp(36px,5vw,72px);line-height:.98;letter-spacing:-.055em;max-width:800px;margin:0}h2{font-size:20px;letter-spacing:-.025em;margin:0}h3{font-size:12px;color:var(--dim);text-transform:uppercase;letter-spacing:.08em;margin:0 0 10px}.lede{color:var(--dim);font-size:17px;max-width:660px;line-height:1.5;margin:20px 0 0}.freshness{min-width:230px;display:grid;gap:8px}.freshness-item{display:grid;grid-template-columns:10px 1fr auto;align-items:center;gap:10px;font-size:12px;color:var(--dim)}.freshness-item strong{font-weight:500;color:var(--fg)}.dot{width:7px;height:7px;border-radius:50%;background:#555;display:inline-block}.dot.ok{background:var(--ok);box-shadow:0 0 12px color-mix(in srgb,var(--ok) 50%,transparent)}.dot.warn{background:var(--warn)}.dot.idle{background:#555}.notice{border:1px solid #674a20;background:#21190e;color:#ffd48f;padding:14px 16px;margin-bottom:24px}.section-head,.panel-head{display:flex;justify-content:space-between;align-items:flex-end;gap:20px}.section-head{margin:0 0 16px}.section-copy{color:var(--dim);font-size:12px;line-height:1.5;max-width:560px;margin:0;text-align:right}.overview-grid,.metric-grid{display:grid;border-top:1px solid var(--line);border-left:1px solid var(--line);margin-bottom:28px}.overview-grid{grid-template-columns:repeat(5,minmax(0,1fr))}.metric-grid{grid-template-columns:repeat(4,minmax(0,1fr));margin-bottom:18px}.metric-card{background:color-mix(in srgb,var(--surface) 92%,transparent);min-height:154px;padding:22px;border-right:1px solid var(--line);border-bottom:1px solid var(--line);display:flex;flex-direction:column}.overview-card{min-height:142px}.metric-card>span,.mini-grid span{font:11px/1.2 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--dim);text-transform:uppercase;letter-spacing:.08em}.metric-card strong{font-size:42px;line-height:1;margin:auto 0 12px;letter-spacing:-.05em}.metric-card.status-card strong{font-size:30px;letter-spacing:-.035em}.metric-card small{color:var(--dim);font-size:12px;line-height:1.35}.two-col{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin:18px 0}.panel{background:color-mix(in srgb,var(--surface) 94%,transparent);border:1px solid var(--line);padding:22px;margin:18px 0}.two-col>.panel{margin:0}.chart-panel{min-height:330px}.chart{height:235px;margin-top:24px}.chart svg{display:block;width:100%;height:100%;overflow:visible}h2{scroll-margin-top:90px}.mini-grid.usage-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.chart-panel .muted{font-size:11px}.chart-area{fill:color-mix(in srgb,var(--accent) 9%,transparent)}.chart-line{fill:none;stroke:var(--accent);stroke-width:3;stroke-linejoin:round;stroke-linecap:round}.bars{height:230px;display:flex;gap:4px;align-items:flex-end;margin-top:24px;border-bottom:1px solid var(--line);padding:0 2px}.bars.compact{height:150px}.bar-wrap{height:100%;flex:1;min-width:3px;display:flex;flex-direction:column;justify-content:flex-end;align-items:center;gap:8px}.bar{width:100%;max-width:28px;background:linear-gradient(to top,#88520d,var(--accent));border-radius:2px 2px 0 0;min-height:2px}.bar.withheld{background:#4a4c49}.bar.h0{height:2%}.bar.h1{height:5%}.bar.h2{height:10%}.bar.h3{height:15%}.bar.h4{height:20%}.bar.h5{height:25%}.bar.h6{height:30%}.bar.h7{height:35%}.bar.h8{height:40%}.bar.h9{height:45%}.bar.h10{height:50%}.bar.h11{height:55%}.bar.h12{height:60%}.bar.h13{height:65%}.bar.h14{height:70%}.bar.h15{height:75%}.bar.h16{height:80%}.bar.h17{height:85%}.bar.h18{height:90%}.bar.h19{height:95%}.bar.h20{height:100%}.bar-wrap span{font-size:8px;color:var(--dim);height:12px;white-space:nowrap}.table-wrap{overflow-x:auto;margin-top:20px}table{width:100%;border-collapse:collapse;font-size:13px}th{text-align:left;color:var(--dim);font:10px/1 ui-monospace,SFMono-Regular,Menlo,monospace;text-transform:uppercase;letter-spacing:.08em;padding:12px 14px;border-bottom:1px solid var(--line)}td{padding:15px 14px;border-bottom:1px solid #232523;white-space:nowrap}td:first-child{font-weight:650}td a{text-decoration-color:#51544f;text-underline-offset:3px}.repo-role{display:block;color:var(--dim);font-size:11px;font-weight:400;margin-top:4px}.numeric{font-variant-numeric:tabular-nums}.empty{color:var(--dim);padding:30px 4px;text-align:center}.panel-copy,.footnote{color:var(--dim);font-size:13px;line-height:1.5}.big-inline{font-size:28px;font-weight:700;letter-spacing:-.04em}.dimension-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:20px;margin-top:26px}.dimension{border-top:1px solid var(--line);padding-top:14px}.chips{display:flex;gap:7px;flex-wrap:wrap}.chip{font:11px/1.2 ui-monospace,SFMono-Regular,Menlo,monospace;color:#d5d3cb;background:var(--raised);border:1px solid var(--line);padding:6px 8px;border-radius:2px}.mini-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:1px;background:var(--line);margin-top:22px}.mini-grid>div{background:var(--raised);padding:18px;display:flex;flex-direction:column;gap:12px}.mini-grid strong{font-size:26px}.ranked-list,.status-list{list-style:none;padding:0;margin:18px 0 0}.ranked-list li{display:flex;justify-content:space-between;gap:18px;padding:12px 0;border-bottom:1px solid var(--line);font-size:13px}.status-list li{display:grid;grid-template-columns:10px 1fr auto;align-items:center;gap:10px;padding:12px 0;border-bottom:1px solid var(--line);font-size:12px}footer{border-top:1px solid var(--line);padding:24px clamp(18px,4vw,56px);display:flex;justify-content:space-between;gap:20px;color:var(--dim);font-size:12px}footer a{color:var(--fg);text-underline-offset:3px}
*{box-sizing:border-box}body{margin:0;background:var(--bg);min-height:100vh}a{color:inherit}.topbar{height:58px;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;padding:0 clamp(18px,4vw,56px);position:sticky;top:0;background:color-mix(in srgb,var(--bg) 90%,transparent);backdrop-filter:blur(16px);z-index:5}.brand{display:flex;gap:10px;align-items:center;text-decoration:none;font:700 12px/1 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.18em}.brand-mark{color:var(--fg)}.brand-slash{color:var(--muted)}.top-actions{display:flex;align-items:center;gap:16px;font:11px/1 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.08em}.mode{color:var(--fg);border:1px solid var(--line);padding:6px 8px;border-radius:2px}.muted{color:var(--dim)}main{width:min(1280px,calc(100% - 36px));margin:0 auto;padding:64px 0 72px}.hero{display:flex;justify-content:space-between;gap:48px;align-items:flex-end;margin-bottom:54px}.eyebrow{color:var(--dim);font:700 10px/1.2 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.2em;margin:0 0 12px}h1{font-size:clamp(36px,5vw,72px);line-height:.98;letter-spacing:-.055em;max-width:800px;margin:0}h2{font-size:20px;letter-spacing:-.025em;margin:0}h3{font-size:12px;color:var(--dim);text-transform:uppercase;letter-spacing:.08em;margin:0 0 10px}.lede{color:var(--dim);font-size:17px;max-width:660px;line-height:1.5;margin:20px 0 0}.freshness{min-width:230px;display:grid;gap:8px}.freshness-item{display:grid;grid-template-columns:10px 1fr auto;align-items:center;gap:10px;font-size:12px;color:var(--dim)}.freshness-item strong{font-weight:500;color:var(--fg)}.dot{width:7px;height:7px;border-radius:50%;background:var(--muted);display:inline-block}.dot.ok{background:var(--live);box-shadow:0 0 12px color-mix(in srgb,var(--live) 50%,transparent)}.dot.stale{background:var(--dim)}.dot.alarm{background:var(--alarm)}.dot.idle{background:var(--muted)}.notice{border:1px solid color-mix(in srgb,var(--alarm) 45%,transparent);background:color-mix(in srgb,var(--alarm) 12%,transparent);color:var(--fg);padding:14px 16px;margin-bottom:24px}.section-head,.panel-head{display:flex;justify-content:space-between;align-items:flex-end;gap:20px}.section-head{margin:0 0 16px}.section-copy{color:var(--dim);font-size:12px;line-height:1.5;max-width:560px;margin:0;text-align:right}.overview-grid,.metric-grid{display:grid;border-top:1px solid var(--line);border-left:1px solid var(--line);margin-bottom:28px}.overview-grid{grid-template-columns:repeat(5,minmax(0,1fr))}.metric-grid{grid-template-columns:repeat(4,minmax(0,1fr));margin-bottom:18px}.metric-card{background:color-mix(in srgb,var(--surface) 92%,transparent);min-height:154px;padding:22px;border-right:1px solid var(--line);border-bottom:1px solid var(--line);display:flex;flex-direction:column}.overview-card{min-height:142px}.metric-card>span,.mini-grid span{font:11px/1.2 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--dim);text-transform:uppercase;letter-spacing:.08em}.metric-card strong{font-size:42px;line-height:1;margin:auto 0 12px;letter-spacing:-.05em}.metric-card.status-card strong{font-size:30px;letter-spacing:-.035em}.metric-card small{color:var(--dim);font-size:12px;line-height:1.35}.two-col{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin:18px 0}.panel{background:color-mix(in srgb,var(--surface) 94%,transparent);border:1px solid var(--line);padding:22px;margin:18px 0}.two-col>.panel{margin:0}.chart-panel{min-height:330px}.chart{height:235px;margin-top:24px}.chart svg{display:block;width:100%;height:100%;overflow:visible}h2{scroll-margin-top:90px}.mini-grid.usage-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.chart-panel .muted{font-size:11px}.chart-area{fill:color-mix(in srgb,var(--fg) 9%,transparent)}.chart-line{fill:none;stroke:var(--fg);stroke-width:3;stroke-linejoin:round;stroke-linecap:round}.bars{height:230px;display:flex;gap:4px;align-items:flex-end;margin-top:24px;border-bottom:1px solid var(--line);padding:0 2px}.bars.compact{height:150px}.bar-wrap{height:100%;flex:1;min-width:3px;display:flex;flex-direction:column;justify-content:flex-end;align-items:center;gap:8px}.bar{width:100%;max-width:28px;background:linear-gradient(to top,color-mix(in srgb,var(--fg) 32%,transparent),color-mix(in srgb,var(--fg) 80%,transparent));border-radius:2px 2px 0 0;min-height:2px}.bar.withheld{background:var(--muted)}.bar.h0{height:2%}.bar.h1{height:5%}.bar.h2{height:10%}.bar.h3{height:15%}.bar.h4{height:20%}.bar.h5{height:25%}.bar.h6{height:30%}.bar.h7{height:35%}.bar.h8{height:40%}.bar.h9{height:45%}.bar.h10{height:50%}.bar.h11{height:55%}.bar.h12{height:60%}.bar.h13{height:65%}.bar.h14{height:70%}.bar.h15{height:75%}.bar.h16{height:80%}.bar.h17{height:85%}.bar.h18{height:90%}.bar.h19{height:95%}.bar.h20{height:100%}.bar-wrap span{font-size:8px;color:var(--dim);height:12px;white-space:nowrap}.table-wrap{overflow-x:auto;margin-top:20px}table{width:100%;border-collapse:collapse;font-size:13px}th{text-align:left;color:var(--dim);font:10px/1 ui-monospace,SFMono-Regular,Menlo,monospace;text-transform:uppercase;letter-spacing:.08em;padding:12px 14px;border-bottom:1px solid var(--line)}td{padding:15px 14px;border-bottom:1px solid var(--line);white-space:nowrap}td:first-child{font-weight:650}td a{text-decoration-color:var(--muted);text-underline-offset:3px}.repo-role{display:block;color:var(--dim);font-size:11px;font-weight:400;margin-top:4px}.numeric{font-variant-numeric:tabular-nums}.empty{color:var(--dim);padding:30px 4px;text-align:center}.panel-copy,.footnote{color:var(--dim);font-size:13px;line-height:1.5}.big-inline{font-size:28px;font-weight:700;letter-spacing:-.04em}.dimension-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:20px;margin-top:26px}.dimension{border-top:1px solid var(--line);padding-top:14px}.chips{display:flex;gap:7px;flex-wrap:wrap}.chip{font:11px/1.2 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--fg);background:var(--raised);border:1px solid var(--line);padding:6px 8px;border-radius:2px}.mini-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:1px;background:var(--line);margin-top:22px}.mini-grid>div{background:var(--raised);padding:18px;display:flex;flex-direction:column;gap:12px}.mini-grid strong{font-size:26px}.ranked-list,.status-list{list-style:none;padding:0;margin:18px 0 0}.ranked-list li{display:flex;justify-content:space-between;gap:18px;padding:12px 0;border-bottom:1px solid var(--line);font-size:13px}.status-list li{display:grid;grid-template-columns:10px 1fr auto;align-items:center;gap:10px;padding:12px 0;border-bottom:1px solid var(--line);font-size:12px}footer{border-top:1px solid var(--line);padding:24px clamp(18px,4vw,56px);display:flex;justify-content:space-between;gap:20px;color:var(--dim);font-size:12px}footer a{color:var(--fg);text-underline-offset:3px}
.mini-grid.site-grid{grid-template-columns:repeat(3,1fr)}
.usage-subhead{margin:28px 0 0}
@media(max-width:1100px){.overview-grid{grid-template-columns:repeat(3,1fr)}}
Expand Down
11 changes: 10 additions & 1 deletion tests/stats-dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ describe('stats dashboard coverage', () => {
await render()
const items = [...document.querySelectorAll('#freshness .freshness-item')]
expect(items.find((item) => item.textContent!.startsWith('Repo traffic'))!.querySelector('.dot.ok')).not.toBeNull()
expect(items.find((item) => item.textContent!.startsWith('GitHub'))!.querySelector('.dot.warn')).not.toBeNull()
// A stale source falls to ink2, as freshness does in the app.
expect(items.find((item) => item.textContent!.startsWith('GitHub'))!.querySelector('.dot.stale')).not.toBeNull()
// The hourly usage feed and the daily energy feed each have their own dot.
expect(items.find((item) => item.textContent!.startsWith('Hourly energy'))!.querySelector('.dot.idle')).not.toBeNull()
expect(items.find((item) => item.textContent!.startsWith('Daily energy'))!.querySelector('.dot.idle')).not.toBeNull()
Expand All @@ -79,4 +80,12 @@ describe('stats dashboard coverage', () => {
expect(document.getElementById('relay-rooms')!.textContent).toBe('17')
expect(document.getElementById('relay-note')!.textContent).toContain('do not measure FTW Cloud')
})

it('marks a failed collector as an alarm, not as stale', async () => {
const at = new Date().toISOString()
await render(true, { collectors: [{ source: 'github', ok: true, finished_at: at }, { source: 'cloud_ops', ok: false, finished_at: at }] })
const rows = [...document.querySelectorAll('#collectors li')]
expect(rows.find((row) => row.textContent!.includes('github'))!.querySelector('.dot.ok')).not.toBeNull()
expect(rows.find((row) => row.textContent!.includes('cloud_ops'))!.querySelector('.dot.alarm')).not.toBeNull()
})
})
49 changes: 49 additions & 0 deletions tests/stats-palette.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { dashboardDocument } from '../stats/src/dashboard.ts'

// The stats Worker serves its own page and cannot load the app's
// stylesheet, so it carries a copy of the roles it uses. This holds the
// copy to src/styles/tokens.css in both themes.
const tokens = readFileSync('src/styles/tokens.css', 'utf8')
const [tokensDark = '', tokensLight = ''] = tokens.split("html[data-theme='light']")

function value(css: string, name: string): string | undefined {
return new RegExp(`--${name}:\\s*([^;}]+)[;}]`).exec(css)?.[1]?.trim()
}

const page = dashboardDocument(false).html
const styles = /<style[^>]*>([\s\S]*?)<\/style>/.exec(page)?.[1] ?? ''
const [statsDark = '', statsLight = ''] = styles.split('@media(prefers-color-scheme:light)')

// Stats name → the app's role.
const ROLES: Record<string, string> = {
bg: 'surface',
surface: 'surface-raised',
raised: 'surface-elevated',
line: 'line',
fg: 'fg',
dim: 'fg-dim',
muted: 'fg-muted',
live: 'live',
alarm: 'alarm',
}

describe('stats page palette', () => {
it('takes the app palette in both themes', () => {
for (const [name, role] of Object.entries(ROLES)) {
const dark = value(tokensDark, role)
const light = value(tokensLight, role) ?? dark
expect(value(statsDark, name), `${name} dark`).toBe(dark)
expect(value(statsLight.slice(0, statsLight.indexOf('}}') + 2), name), `${name} light`).toBe(light)
}
expect(page).toContain('<meta name="color-scheme" content="dark light">')
})

it('keeps raw colours in the two token blocks only', () => {
const rules = styles
.replace(/:root\{[^}]*\}/, '')
.replace(/@media\(prefers-color-scheme:light\)\{:root\{[^}]*\}\}/, '')
expect(rules.match(/#[0-9a-f]{3,8}\b|rgba?\(/gi) ?? []).toEqual([])
})
})
Loading