feat(webui): migrate Home dashboard

This commit is contained in:
joshpatra committed 2026-07-27 00:00:32 -04:00
1 parent 1bb4dda57b
commit 9ca6111a97
12 files changed
+1232 -17

No files matched your search

+1
View File
@@ -100,6 +100,7 @@ jobs:
working-directory: webui
run: |
npm run check
npm test
npm run build
- name: Test
+1
View File
@@ -117,6 +117,7 @@ jobs:
working-directory: webui
run: |
npm run check
npm test
npm run build
- name: Test
+349 -2
View File
@@ -17,7 +17,8 @@
"svelte-check": "^4.7.3",
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3",
"vite": "^8.1.5"
"vite": "^8.1.5",
"vitest": "^4.1.10"
},
"engines": {
"node": ">=22.0.0"
@@ -829,6 +830,17 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
@@ -836,6 +848,13 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -858,6 +877,119 @@
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT"
},
"node_modules/@vitest/expect": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
"integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
"integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.10",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
"integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
"integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.10",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
"integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.10",
"@vitest/utils": "4.1.10",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
"integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
"integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.10",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/acorn": {
"version": "8.17.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
@@ -879,6 +1011,16 @@
"node": ">= 0.4"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/axobject-query": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@@ -912,6 +1054,16 @@
"svelte": "^5.33.0"
}
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
@@ -937,6 +1089,13 @@
"node": ">=6"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
@@ -997,6 +1156,13 @@
"node": ">=10.13.0"
}
},
"node_modules/es-module-lexer": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
"integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
"dev": true,
"license": "MIT"
},
"node_modules/esm-env": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
@@ -1020,6 +1186,26 @@
}
}
},
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -1429,11 +1615,17 @@
"https://opencollective.com/debug"
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1575,6 +1767,13 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/sirv": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
@@ -1600,6 +1799,20 @@
"node": ">=0.10.0"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
"integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
"dev": true,
"license": "MIT"
},
"node_modules/style-to-object": {
"version": "1.0.14",
"resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz",
@@ -1708,6 +1921,23 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
"integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -1725,6 +1955,16 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyrainbow": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/totalist": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
@@ -1861,6 +2101,113 @@
}
}
},
"node_modules/vitest": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
"integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.10",
"@vitest/mocker": "4.1.10",
"@vitest/pretty-format": "4.1.10",
"@vitest/runner": "4.1.10",
"@vitest/snapshot": "4.1.10",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^4.0.0-rc.1",
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.1.0",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.10",
"@vitest/browser-preview": "4.1.10",
"@vitest/browser-webdriverio": "4.1.10",
"@vitest/coverage-istanbul": "4.1.10",
"@vitest/coverage-v8": "4.1.10",
"@vitest/ui": "4.1.10",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
},
"vite": {
"optional": false
}
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/zimmerframe": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
+3 -1
View File
@@ -8,6 +8,7 @@
"scripts": {
"dev": "vite --host 0.0.0.0",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test": "vitest run",
"build": "vite build"
},
"dependencies": {
@@ -22,6 +23,7 @@
"svelte-check": "^4.7.3",
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3",
"vite": "^8.1.5"
"vite": "^8.1.5",
"vitest": "^4.1.10"
}
}
+364
View File
@@ -123,6 +123,17 @@
filter: brightness(1.06);
}
.button-secondary {
min-height: 2.75rem;
border: 1px solid var(--color-edge);
border-radius: 0.8rem;
background: var(--color-panel-raised);
color: var(--color-ink);
cursor: pointer;
padding: 0 1rem;
font-weight: 750;
}
.notice-error {
border-radius: 0.75rem;
background: rgb(255 107 122 / 12%);
@@ -337,6 +348,331 @@
color: var(--color-signal);
font-size: 1.4rem;
}
.home-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.9rem;
}
.metric-card {
display: grid;
min-width: 0;
min-height: 10.5rem;
grid-template-columns: auto minmax(0, 1fr);
align-content: start;
gap: 0.9rem;
border: 1px solid var(--color-edge);
border-radius: 1.15rem;
background: linear-gradient(145deg, rgb(24 30 40 / 88%), rgb(18 22 30 / 72%));
padding: 1.15rem;
}
.metric-icon {
display: grid;
width: 2.5rem;
height: 2.5rem;
place-items: center;
border-radius: 0.75rem;
background: rgb(168 255 62 / 11%);
color: var(--color-signal);
}
.metric-icon.playlists {
background: rgb(106 151 255 / 11%);
color: #8eb0ff;
}
.metric-icon.playable {
background: rgb(196 117 255 / 11%);
color: #d49aff;
}
.metric-icon.jobs {
background: rgb(255 185 80 / 11%);
color: #ffc870;
}
.metric-card p {
margin: 0.15rem 0 0.25rem;
color: var(--color-ink-muted);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.metric-card strong {
font-family: var(--font-display);
font-size: 1.65rem;
letter-spacing: -0.04em;
}
.metric-card > small {
grid-column: 1 / -1;
align-self: end;
color: #86d52d;
font-size: 0.73rem;
}
.metric-card > small.attention,
.provider-result.attention {
color: #ffc870;
}
.split-metric {
display: grid;
grid-template-columns: 1fr 1fr;
}
.split-metric a {
color: inherit;
text-decoration: none;
}
.split-metric a + a {
border-left: 1px solid var(--color-edge);
padding-left: 0.8rem;
}
.home-columns {
display: grid;
grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr);
gap: 0.9rem;
margin-top: 0.9rem;
}
.home-panel {
min-width: 0;
padding: 1.2rem;
}
.home-panel header {
display: flex;
align-items: end;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.home-panel h2,
.route-error h2 {
margin: 0.25rem 0 0;
font-family: var(--font-display);
font-size: 1.1rem;
}
.home-panel header > a,
.compact-empty a {
color: var(--color-signal);
font-size: 0.78rem;
font-weight: 750;
text-decoration: none;
}
.provider-list,
.activity-list {
display: grid;
}
.provider-line {
display: grid;
min-width: 0;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.7rem;
border-top: 1px solid var(--color-edge);
padding: 0.7rem 0;
}
.provider-artwork {
display: grid;
width: 2.2rem;
height: 2.2rem;
place-items: center;
overflow: hidden;
border-radius: 0.65rem;
background: var(--color-panel-raised);
color: var(--color-ink-muted);
font-weight: 800;
}
.provider-artwork img {
grid-area: 1 / 1;
width: 1.35rem;
height: 1.35rem;
object-fit: contain;
}
.provider-artwork img:not([hidden]) + span {
display: none;
}
.provider-line strong,
.provider-line small,
.activity-line strong,
.activity-line small {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.provider-line strong,
.activity-line strong {
font-size: 0.82rem;
}
.provider-line small,
.activity-line small,
.provider-result small {
margin-top: 0.2rem;
color: var(--color-ink-muted);
font-size: 0.68rem;
}
.provider-result {
color: #86d52d;
font-size: 0.76rem;
font-weight: 750;
text-align: right;
}
.activity-line {
display: grid;
min-width: 0;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.7rem;
border-top: 1px solid var(--color-edge);
color: inherit;
padding: 0.72rem 0;
text-decoration: none;
}
.activity-line > span:first-child {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--color-signal);
}
.activity-line > span:first-child.failed {
background: var(--color-danger);
}
.activity-line time {
color: var(--color-ink-muted);
font-size: 0.67rem;
}
.compact-empty {
display: grid;
min-height: 11rem;
place-items: center;
align-content: center;
color: var(--color-ink-muted);
padding: 1rem;
text-align: center;
}
.compact-empty strong {
color: var(--color-ink);
}
.compact-empty p {
max-width: 23rem;
margin: 0.45rem 0 0.9rem;
font-size: 0.8rem;
line-height: 1.55;
}
.degraded-banner {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.7rem;
margin-bottom: 0.9rem;
border: 1px solid rgb(255 185 80 / 25%);
border-radius: 0.85rem;
background: rgb(255 185 80 / 8%);
color: #ffd493;
padding: 0.75rem 0.9rem;
font-size: 0.75rem;
}
.degraded-banner p {
margin: 0;
}
.degraded-banner button {
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
font-weight: 800;
}
.route-error {
display: grid;
min-height: 20rem;
grid-template-columns: auto minmax(0, 1fr) auto;
place-items: center start;
gap: 1rem;
padding: 2rem;
}
.route-error > span {
display: grid;
width: 2.5rem;
height: 2.5rem;
place-items: center;
border-radius: 50%;
background: rgb(255 107 122 / 12%);
color: var(--color-danger);
font-weight: 800;
}
.route-error p:last-child {
color: var(--color-ink-muted);
}
.skeleton-card,
.skeleton-panel {
overflow: hidden;
background: var(--color-panel);
}
.skeleton-card::after,
.skeleton-panel::after {
display: block;
width: 45%;
height: 100%;
background: linear-gradient(90deg, transparent, rgb(255 255 255 / 5%), transparent);
content: "";
animation: skeleton-sweep 1.3s ease-in-out infinite;
}
.skeleton-panel {
min-height: 20rem;
grid-column: 1 / -1;
}
}
@keyframes skeleton-sweep {
from {
transform: translateX(-100%);
}
to {
transform: translateX(320%);
}
}
@media (max-width: 1100px) {
.home-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
@@ -388,6 +724,34 @@
.workspace-header {
align-items: center;
}
.home-columns,
.home-grid {
grid-template-columns: 1fr;
}
.metric-card {
min-height: 8.7rem;
}
.route-error {
grid-template-columns: auto minmax(0, 1fr);
}
.route-error .button-secondary {
grid-column: 1 / -1;
width: 100%;
}
.degraded-banner {
grid-template-columns: auto minmax(0, 1fr);
}
.degraded-banner button {
grid-column: 1 / -1;
min-height: 2.5rem;
border-top: 1px solid rgb(255 185 80 / 18%);
}
}
@media (prefers-reduced-motion: reduce) {
+71
View File
@@ -9,6 +9,68 @@ export type Session = {
};
};
export type RuntimeStatus = {
version: string;
backendType: string;
durableStorage?: {
provider: string;
readiness: string;
errorCode?: string | null;
checkedAt?: string;
};
};
export type PlaylistSummary = {
id: string;
name: string;
trackCount: number;
localTracks: number;
externalTracks: number;
unmatchedTracks: number;
artworkUrl?: string | null;
sourceProvider?: string;
};
export type PlaylistResponse = {
playlists: PlaylistSummary[];
inventory: {
managed: number;
unmanaged: number;
};
};
export type Job = {
id: string;
type: string;
state: string;
updatedAt: string;
};
export type ActivityItem = {
id: string;
source: string;
label: string;
state: string;
detail: string;
occurredAt: string;
};
export type ProviderSummary = {
providerId: string;
connectedAccountName?: string | null;
enabledAccountCount: number;
capabilityTotal: number;
healthyCapabilityCount: number;
failedCapabilityCount: number;
lastCheckedAt?: string | null;
};
export type ProviderDefinition = {
id: string;
name: string;
logoUrl?: string | null;
};
async function json<T>(input: RequestInfo | URL, init?: RequestInit): Promise<T> {
const response = await fetch(input, {
cache: "no-store",
@@ -34,3 +96,12 @@ export const auth = {
}),
logout: () => json<{ success: boolean }>("/api/admin/auth/logout", { method: "POST" }),
};
export const home = {
schema: () => json<{ providers: ProviderDefinition[] }>("/api/admin/ui/schema"),
status: () => json<RuntimeStatus>("/api/admin/status"),
playlists: () => json<PlaylistResponse>("/api/admin/playlists"),
jobs: () => json<{ jobs: Job[] }>("/api/admin/jobs?limit=100"),
activity: () => json<{ items: ActivityItem[] }>("/api/admin/ui/activity?limit=8"),
providers: () => json<{ providers: ProviderSummary[] }>("/api/admin/ui/provider-summaries"),
};
+261
View File
@@ -0,0 +1,261 @@
<script lang="ts">
import { onMount } from "svelte";
import { home } from "$lib/api";
import { summarizeHome, type HomeSnapshot } from "$lib/home";
import { liveUpdates } from "$lib/live-updates.svelte";
let { administrator }: { administrator: boolean } = $props();
let snapshot = $state<HomeSnapshot | null>(null);
let loading = $state(true);
let refreshing = $state(false);
let pendingRefresh = false;
let refreshTimer: ReturnType<typeof setTimeout> | null = null;
const summary = $derived(snapshot ? summarizeHome(snapshot) : null);
const completelyUnavailable = $derived(
snapshot !== null &&
!snapshot.status &&
!snapshot.playlists &&
!snapshot.jobs &&
!snapshot.activity &&
!snapshot.providers,
);
async function refresh() {
if (refreshing) {
pendingRefresh = true;
return;
}
refreshing = true;
const requests = [
["Provider catalog", home.schema()],
["Runtime status", home.status()],
["Playlists", home.playlists()],
["Jobs", home.jobs()],
...(administrator
? [
["Recent activity", home.activity()],
["Provider health", home.providers()],
]
: []),
] as const;
const results = await Promise.allSettled(requests.map((request) => request[1]));
const next: HomeSnapshot = { failures: [] };
results.forEach((result, index) => {
const label = requests[index][0];
if (result.status === "rejected") {
next.failures.push(`${label}: ${result.reason instanceof Error ? result.reason.message : "Unavailable"}`);
return;
}
if (label === "Provider catalog") next.providerCatalog = (result.value as Awaited<ReturnType<typeof home.schema>>).providers;
if (label === "Runtime status") next.status = result.value as Awaited<ReturnType<typeof home.status>>;
if (label === "Playlists") next.playlists = result.value as Awaited<ReturnType<typeof home.playlists>>;
if (label === "Jobs") next.jobs = (result.value as Awaited<ReturnType<typeof home.jobs>>).jobs;
if (label === "Recent activity") next.activity = (result.value as Awaited<ReturnType<typeof home.activity>>).items;
if (label === "Provider health") next.providers = (result.value as Awaited<ReturnType<typeof home.providers>>).providers;
});
snapshot = next;
loading = false;
refreshing = false;
if (pendingRefresh) {
pendingRefresh = false;
scheduleRefresh();
}
}
function scheduleRefresh() {
if (refreshTimer) return;
refreshTimer = setTimeout(() => {
refreshTimer = null;
void refresh();
}, 250);
}
function relativeTime(value?: string | null) {
if (!value) return "Not checked";
const seconds = Math.round((new Date(value).getTime() - Date.now()) / 1_000);
const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
if (Math.abs(seconds) < 60) return formatter.format(seconds, "second");
const minutes = Math.round(seconds / 60);
if (Math.abs(minutes) < 60) return formatter.format(minutes, "minute");
const hours = Math.round(minutes / 60);
if (Math.abs(hours) < 24) return formatter.format(hours, "hour");
return formatter.format(Math.round(hours / 24), "day");
}
function providerDefinition(providerId: string) {
return snapshot?.providerCatalog?.find(
(provider) => provider.id.toLowerCase() === providerId.toLowerCase(),
);
}
function providerName(providerId: string) {
return providerDefinition(providerId)?.name ?? providerId;
}
onMount(() => {
void refresh();
const unsubscribe = liveUpdates.subscribe(scheduleRefresh);
return () => {
unsubscribe();
if (refreshTimer) clearTimeout(refreshTimer);
};
});
</script>
{#if loading}
<section class="home-grid" aria-busy="true" aria-label="Loading Home">
{#each Array(4) as _}
<div class="metric-card skeleton-card"></div>
{/each}
<div class="panel skeleton-panel"></div>
</section>
{:else if completelyUnavailable}
<section class="panel route-error" role="alert">
<span aria-hidden="true">!</span>
<div>
<p class="eyebrow">Home unavailable</p>
<h2>Allstarr could not load its current state.</h2>
<p>{snapshot?.failures[0] ?? "The server did not return a usable response."}</p>
</div>
<button class="button-secondary" type="button" onclick={() => void refresh()}>Try again</button>
</section>
{:else if snapshot && summary}
{#if snapshot.failures.length}
<div class="degraded-banner" role="status">
<span aria-hidden="true">!</span>
<p><strong>Some live data is unavailable.</strong> {snapshot.failures.join(" · ")}</p>
<button type="button" onclick={() => void refresh()}>Retry</button>
</div>
{/if}
<section class="home-grid" aria-label="Allstarr overview" aria-busy={refreshing}>
<article class="metric-card">
<span class="metric-icon backend" aria-hidden="true">◇</span>
<div>
<p>Backend</p>
<strong>{snapshot.status?.backendType ?? "Unknown"}</strong>
</div>
<small class:attention={snapshot.status?.durableStorage?.readiness !== "Ready"}>
{snapshot.status?.durableStorage?.readiness ?? "Unavailable"}
</small>
</article>
<article class="metric-card">
<span class="metric-icon playlists" aria-hidden="true">♫</span>
<div class="split-metric">
<a href="#/library/playlists"><p>Managed</p><strong>{summary.managed}</strong></a>
<a href="#/library/playlists"><p>Unmanaged</p><strong>{summary.unmanaged}</strong></a>
</div>
<small>{summary.managed + summary.unmanaged} playlists in the library</small>
</article>
<article class="metric-card">
<span class="metric-icon playable" aria-hidden="true">▶</span>
<div>
<p>Playable tracks</p>
<strong>{summary.playable.toLocaleString()}</strong>
</div>
<small class:attention={summary.unresolved > 0}>
{summary.unresolved
? `${summary.unresolved.toLocaleString()} awaiting a match`
: "Every indexed track has a route"}
</small>
</article>
<article class="metric-card">
<span class="metric-icon jobs" aria-hidden="true">↻</span>
<div>
<p>Active work</p>
<strong>{summary.activeJobs}</strong>
</div>
<small class:attention={summary.activeJobs > 0}>
{summary.activeJobs ? "Operations are running" : "Queue is clear"}
</small>
</article>
</section>
<section class="home-columns">
<article class="panel home-panel">
<header>
<div>
<p class="eyebrow">Sources</p>
<h2>Provider readiness</h2>
</div>
<a href="#/sources">Manage</a>
</header>
{#if snapshot.providers?.length}
<div class="provider-list">
{#each snapshot.providers.slice(0, 6) as provider}
<div class="provider-line">
<span class="provider-artwork">
{#if providerDefinition(provider.providerId)?.logoUrl}
<img
src={providerDefinition(provider.providerId)?.logoUrl ?? ""}
alt=""
onerror={(event) => {
(event.currentTarget as HTMLImageElement).hidden = true;
}}
/>
{/if}
<span>{provider.providerId[0]?.toUpperCase()}</span>
</span>
<span>
<strong>{provider.connectedAccountName || providerName(provider.providerId)}</strong>
<small>{provider.enabledAccountCount} connected account{provider.enabledAccountCount === 1 ? "" : "s"}</small>
</span>
<span class:attention={provider.failedCapabilityCount > 0} class="provider-result">
{provider.capabilityTotal
? `${provider.healthyCapabilityCount}/${provider.capabilityTotal}`
: "—"}
<small>{relativeTime(provider.lastCheckedAt)}</small>
</span>
</div>
{/each}
</div>
{:else}
<div class="compact-empty">
<strong>No provider checks yet</strong>
<p>Connect a Source to see its capability health here.</p>
<a href="#/sources">Open Sources</a>
</div>
{/if}
</article>
<article class="panel home-panel">
<header>
<div>
<p class="eyebrow">Now</p>
<h2>Recent activity</h2>
</div>
<a href="#/activity">View all</a>
</header>
{#if snapshot.activity?.length}
<div class="activity-list">
{#each snapshot.activity as item}
<a href="#/activity" class="activity-line">
<span class:failed={["failed", "degraded", "unavailable"].includes(item.state.toLowerCase())}></span>
<span>
<strong>{item.label}</strong>
<small>{providerName(item.source)} · {item.detail}</small>
</span>
<time datetime={item.occurredAt}>{relativeTime(item.occurredAt)}</time>
</a>
{/each}
</div>
{:else}
<div class="compact-empty">
<strong>No recent activity</strong>
<p>Provider checks, matches, and background work will appear here.</p>
</div>
{/if}
</article>
</section>
{/if}
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { summarizeHome, type HomeSnapshot } from "./home";
describe("summarizeHome", () => {
it("keeps playlist counts and routes separate", () => {
const snapshot: HomeSnapshot = {
failures: [],
jobs: [
{ id: "1", type: "match", state: "Running", updatedAt: "" },
{ id: "2", type: "sync", state: "Succeeded", updatedAt: "" },
],
playlists: {
inventory: { managed: 2, unmanaged: 3 },
playlists: [
{
id: "a",
name: "A",
trackCount: 10,
localTracks: 6,
externalTracks: 3,
unmatchedTracks: 1,
},
{
id: "b",
name: "B",
trackCount: 5,
localTracks: 2,
externalTracks: 2,
unmatchedTracks: 1,
},
],
},
};
expect(summarizeHome(snapshot)).toEqual({
activeJobs: 1,
managed: 2,
unmanaged: 3,
playable: 13,
unresolved: 2,
});
});
});
+36
View File
@@ -0,0 +1,36 @@
import type {
ActivityItem,
Job,
PlaylistResponse,
ProviderDefinition,
ProviderSummary,
RuntimeStatus,
} from "./api";
export type HomeSnapshot = {
providerCatalog?: ProviderDefinition[];
status?: RuntimeStatus;
playlists?: PlaylistResponse;
jobs?: Job[];
activity?: ActivityItem[];
providers?: ProviderSummary[];
failures: string[];
};
export function summarizeHome(snapshot: HomeSnapshot) {
const playlists = snapshot.playlists?.playlists ?? [];
const activeJobs = (snapshot.jobs ?? []).filter(
(job) => !["Succeeded", "Failed", "Cancelled"].includes(job.state),
).length;
return {
activeJobs,
managed: snapshot.playlists?.inventory.managed ?? playlists.length,
unmanaged: snapshot.playlists?.inventory.unmanaged ?? 0,
playable: playlists.reduce(
(total, playlist) => total + playlist.localTracks + playlist.externalTracks,
0,
),
unresolved: playlists.reduce((total, playlist) => total + playlist.unmatchedTracks, 0),
};
}
+47 -5
View File
@@ -1,8 +1,10 @@
export type LiveState = "connecting" | "live" | "reconnecting" | "stale";
type UpdateEvent = {
eventId?: string;
revision?: number;
resource?: string;
resourceId?: string;
};
const state = $state({
@@ -13,6 +15,38 @@ const state = $state({
let source: EventSource | null = null;
let staleTimer: ReturnType<typeof setTimeout> | null = null;
const listeners = new Set<(event: UpdateEvent) => void>();
const revisions = new Map<string, number>();
const seenEventIds = new Set<string>();
export function acceptUpdate(
update: UpdateEvent,
eventId: string,
knownRevisions: Map<string, number>,
knownEventIds: Set<string>,
) {
if (eventId && knownEventIds.has(eventId)) return false;
const key = update.resource && update.resourceId
? `${update.resource}:${update.resourceId}`
: "";
if (key && update.revision !== undefined) {
const known = knownRevisions.get(key);
if (known !== undefined && update.revision < known) return false;
knownRevisions.set(key, update.revision);
if (knownRevisions.size > 1_000) {
knownRevisions.delete(knownRevisions.keys().next().value!);
}
}
if (eventId) {
knownEventIds.add(eventId);
if (knownEventIds.size > 1_000) {
knownEventIds.delete(knownEventIds.values().next().value!);
}
}
return true;
}
function scheduleStale() {
if (staleTimer) clearTimeout(staleTimer);
@@ -23,7 +57,7 @@ function scheduleStale() {
export const liveUpdates = {
state,
connect(onUpdate?: (event: UpdateEvent) => void) {
connect() {
if (source) return;
state.status = "connecting";
@@ -34,22 +68,30 @@ export const liveUpdates = {
state.lastEventAt = new Date();
});
source.addEventListener("update", (message) => {
const update = JSON.parse((message as MessageEvent<string>).data) as UpdateEvent;
if (update.revision && update.revision <= state.revision) return;
state.revision = update.revision ?? state.revision;
const event = message as MessageEvent<string>;
const update = JSON.parse(event.data) as UpdateEvent;
const eventId = event.lastEventId || update.eventId || "";
if (!acceptUpdate(update, eventId, revisions, seenEventIds)) return;
state.revision += 1;
state.lastEventAt = new Date();
onUpdate?.(update);
for (const listener of listeners) listener(update);
});
source.onerror = () => {
state.status = "reconnecting";
scheduleStale();
};
},
subscribe(listener: (event: UpdateEvent) => void) {
listeners.add(listener);
return () => listeners.delete(listener);
},
close() {
source?.close();
source = null;
if (staleTimer) clearTimeout(staleTimer);
staleTimer = null;
revisions.clear();
seenEventIds.clear();
state.status = "connecting";
},
};
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { acceptUpdate } from "./live-updates.svelte";
describe("acceptUpdate", () => {
it("deduplicates event IDs without comparing unrelated resource revisions", () => {
const revisions = new Map<string, number>();
const eventIds = new Set<string>();
expect(
acceptUpdate(
{ resource: "job", resourceId: "job-1", revision: 5 },
"event-1",
revisions,
eventIds,
),
).toBe(true);
expect(
acceptUpdate(
{ resource: "track-match", resourceId: "match-1", revision: 1 },
"event-2",
revisions,
eventIds,
),
).toBe(true);
expect(
acceptUpdate(
{ resource: "job", resourceId: "job-1", revision: 4 },
"event-3",
revisions,
eventIds,
),
).toBe(false);
expect(
acceptUpdate(
{ resource: "job", resourceId: "job-1", revision: 5 },
"event-1",
revisions,
eventIds,
),
).toBe(false);
});
});
+14 -9
View File
@@ -2,6 +2,7 @@
import { onMount } from "svelte";
import { page } from "$app/state";
import { auth, type Session } from "$lib/api";
import HomeView from "$lib/components/HomeView.svelte";
import { liveUpdates } from "$lib/live-updates.svelte";
const destinations = [
@@ -170,15 +171,19 @@
</div>
</header>
<section class="panel empty-state">
<span class="empty-orbit" aria-hidden="true">✦</span>
<p class="eyebrow">Svelte migration preview</p>
<h2>{activeDestination.label} is next in line.</h2>
<p>
This opt-in shell is isolated at <code>/next/#/</code>. The current WebUI remains
available while complete routes move over one at a time.
</p>
</section>
{#if route === "/"}
<HomeView administrator={session.user?.isAdministrator ?? false} />
{:else}
<section class="panel empty-state">
<span class="empty-orbit" aria-hidden="true">✦</span>
<p class="eyebrow">Svelte migration preview</p>
<h2>{activeDestination.label} is next in line.</h2>
<p>
This opt-in shell is isolated at <code>/next/#/</code>. The current WebUI remains
available while complete routes move over one at a time.
</p>
</section>
{/if}
</main>
</div>
{/if}