--- /dev/null
+// See https://observablehq.com/framework/config for documentation.
+export default {
+ title: "Fontspector Dashboard",
+ root: "src",
+ footer: "",
+ sidebar: false, // whether to show the sidebar
+ output: "../../fontspector-dashboard", // path to the output root for build
+ preserveExtension: true
+};
--- /dev/null
+{
+ "type": "module",
+ "private": true,
+ "scripts": {
+ "clean": "rimraf src/.observablehq/cache",
+ "build": "observable build",
+ "dev": "observable preview",
+ "deploy": "observable deploy",
+ "observable": "observable"
+ },
+ "dependencies": {
+ "@duckdb/node-api": "^1.2.1-alpha.16",
+ "@observablehq/framework": "^1.13.2",
+ "@observablehq/stdlib": "^5.8.8"
+ },
+ "devDependencies": {
+ "rimraf": "^5.0.5"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+}
--- /dev/null
+/.observablehq/cache/
--- /dev/null
+---
+toc: false
+---
+
+```js
+const allResults = FileAttachment("./results.json").json();
+
+const categoricals = {
+ type: "categorical",
+ domain: ['INFO', 'WARN', 'FAIL', 'ERROR'],
+ range: ["#2182bf", "#bdae4f", "#cf4f2b", "#ff0000"],
+ legend: true
+ };
+```
+
+<div class="hero">
+ <h1> Google Fonts QA </h1>
+ <h2> WARNs last run: <span class="huge warn">${ allResults.headline.WARN }</h2>
+ <h2> FAILs last run: <span class="huge fail">${ allResults.headline.FAIL }</h2>
+</div>
+
+
+<div class="card">
+
+## Overall failures
+
+```js
+Plot.plot({
+ marks: [
+ Plot.ruleY([0]),
+ Plot.line(
+ allResults.fails_by_run,
+ Plot.stackY2({ y: "count", x: (d) => new Date(d.run), stroke: "status" })
+ ),
+ Plot.dot(
+ allResults.fails_by_run,
+ Plot.stackY2(
+ { y: "count", x: "run", fill: "status", "tip": true }
+ )
+ )
+ ],
+ color: categoricals,
+ width
+})
+```
+
+</div>
+
+
+<div>
+
+<hr>
+
+<div class="runslider">
+<p>Select run:</p>
+
+```js
+const runSlider = view(html`<input type=range step=1 min=0 max=${allResults.allRuns.length-1} value=${allResults.allRuns.length-1}>`)
+```
+
+```js
+const selectedRun = allResults.allRuns[allResults.allRuns.length-(1+runSlider)]
+```
+
+<span class="when">${(new Date(selectedRun)).toISOString().replace("T", " ").replace(/\.\d+Z$/, "") }</span>
+</div>
+
+
+<div class="grid grid-cols-2">
+ <div class="card">
+ <h2>Most failing checks</h2>
+
+```js
+Plot.plot({
+ marginBottom: 90,
+ marginLeft: 90,
+ x: {
+ tickRotate: -30,
+ label: null,
+ },
+ color: categoricals,
+ marks: [
+ Plot.ruleY([0]),
+ Plot.rectY(allResults.most_failing_checks[selectedRun],
+
+ { y: "count", x: "check_id", sort: {x: "y", reverse: "true"}, tip: true, fill: "status" },
+ )
+]})
+```
+
+ </div>
+
+ <div class="card">
+
+## Most failing families
+
+
+```js
+Plot.plot({
+ x: {
+ tickRotate: -30,
+ label: null,
+
+ },
+ color: categoricals,
+ marks: [
+
+ Plot.ruleY([0]),
+ Plot.barY(allResults.most_failing_families[selectedRun],
+ { y: "count", x: "family", tip: true, fill: "status", order: "status", sort: {x: "y", reverse: true} },
+ ),
+],
+})
+```
+
+ </div>
+</div>
+
+</div>
+
+
+
+<style>
+
+.card {
+ height: 450px;
+}
+
+.hero {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ font-family: var(--sans-serif);
+ text-wrap: balance;
+ text-align: center;
+}
+
+.runslider {
+ height: 50px;
+}
+
+.runslider div { display: inline-block}
+.runslider div:first-child { display: inline-block; width: 50% ; }
+.runslider p { display: inline-block; font-family: sans-serif; }
+
+.hero h1 {
+ margin: 1rem 0;
+ padding: 1rem 0;
+ max-width: none;
+ font-size: 14vw;
+ font-weight: 900;
+ line-height: 1;
+ background: linear-gradient(30deg, var(--theme-foreground-focus), currentColor);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+}
+
+.hero h2 {
+ margin: 0;
+ max-width: 34em;
+ font-size: 40px;
+ font-style: initial;
+ font-weight: 500;
+ line-height: 1.5;
+ color: var(--theme-foreground-muted);
+ vertical-align: middle;
+ display: inline-block;
+}
+
+.hero h2 .huge {
+ font-size: 60px;
+ font-weight: 700;
+ vertical-align: middle;
+}
+
+.warn { color: #bdae4f; }
+.fail { color: #cf4f2b; }
+
+
+@media (min-width: 640px) {
+ .hero h1 {
+ font-size: 90px;
+ }
+}
+
+</style>
--- /dev/null
+import { DuckDBInstance } from "@duckdb/node-api";
+
+const instance = await DuckDBInstance.create("./src/fontspector.db");
+const db = await instance.connect();
+
+const reader = await db.runAndReadAll(
+ "SELECT distinct run FROM results ORDER BY run DESC"
+);
+const rows = reader.getRows();
+let allRuns = rows.map((c) => Number(c[0].micros) / 1000);
+let latestRun = allRuns[0];
+
+let headline = await db.runAndReadAll(`
+select status, count(status) as count
+ FROM (SELECT * FROM fontspector.results WHERE epoch_ms(run) == ${latestRun})
+ where status == 'WARN' or status == 'FAIL'
+ group by status`);
+const headlineRows = headline.getRows();
+
+let fails_by_run = (
+ await db.runAndReadAll(`
+select run, status, count(status) as count from fontspector.results
+where status == 'WARN' or status == 'FAIL'
+group by status, run
+order by run, status;
+`)
+).getRows();
+fails_by_run = fails_by_run.map((c) => {
+ return {
+ run: Number(c[0].micros) / 1000,
+ status: c[1],
+ count: Number(c[2]),
+ };
+});
+
+let most_failing_checks = (
+ await db.runAndReadAll(`
+select run, check_id, status, count(status) as count
+ FROM fontspector.results
+ where status == 'FAIL' or status == 'ERROR' or status == 'WARN'
+ group by run, check_id, status order by count desc;
+`)
+).getRows();
+let mfc = {};
+for (var row of most_failing_checks) {
+ let key = Number(row[0].micros) / 1000;
+ if (!mfc[key]) {
+ mfc[key] = [];
+ }
+ if (mfc[key].length < 10) {
+ mfc[key].push({
+ check_id: row[1],
+ status: row[2],
+ count: Number(row[3]),
+ });
+ }
+}
+
+/*
+
+```sql id=most_failing_families
+select directory as family, status, count(status) as count
+ FROM (SELECT * FROM fontspector.results WHERE epoch_ms(run) == ${allRuns[runSlider]})
+ where (status == 'FAIL' or status == 'ERROR' or status == 'WARN')
+ AND family in (SELECT directory as family FROM fontspector.results WHERE status == 'FAIL' or status == 'ERROR' or status == 'WARN' group by family order by count(status) desc limit 20)
+ group by directory, status;
+```
+*/
+
+
+let mff = {};
+for (var run of allRuns) {
+ let most_failing_families = (
+ await db.runAndReadAll(`
+select directory as family, status, count(status) as count
+ FROM (SELECT * FROM fontspector.results WHERE epoch_ms(run) == ${run})
+ where (status == 'FAIL' or status == 'ERROR' or status == 'WARN')
+ AND family in (SELECT directory as family FROM fontspector.results WHERE status == 'FAIL' or status == 'ERROR' or status == 'WARN' group by family order by count(status) desc limit 20)
+ group by directory, status;
+ `)
+ ).getRows();
+ mff[run] = [];
+ for (var row of most_failing_families) {
+ mff[run].push({
+ family: row[0],
+ status: row[1],
+ count: Number(row[2]),
+ });
+ }
+}
+const results = {
+ headline: {
+ [headlineRows[0][0]]: Number(headlineRows[0][1]),
+ [headlineRows[1][0]]: Number(headlineRows[1][1]),
+ },
+ allRuns,
+ fails_by_run,
+ most_failing_checks: mfc,
+ most_failing_families: mff,
+};
+
+process.stdout.write(JSON.stringify(results));
- name: Build fontspector
run: cargo install --git https://github.com/fonttools/fontspector --features duckdb
- name: Test all the things
- run: fontspector --profile googlefonts ofl/*/*{.ttf,.pb,*html,*svg,*jpg,*gif} --skip-network --succinct --duckdb .ci/fontspector.db || true
+ run: fontspector --profile googlefonts ofl/*/*{.ttf,.pb,*html,*svg,*jpg,*gif} --skip-network --succinct --duckdb .ci/fontspector-dashboard/src/fontspector.db || true
- name: Upload results
uses: stefanzweifel/git-auto-commit-action@v5
with:
- file_pattern: .ci/fontspector.db
+ file_pattern: .ci/fontspector-dashboard/src/fontspector.db
+ - name: Rebuild dashboard
+ run: rm -rf fontspector-dashboard && cd .ci/fontspector-dashboard && npm install && npm run build
+ - name: Upload results
+ uses: stefanzweifel/git-auto-commit-action@v5
+ with:
+ branch: gh-pages
+ file_pattern: fontspector-dashboard