mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-06 21:55:39 -04:00
feat(webui): migrate Home dashboard
This commit is contained in:
12 files changed
+1232
-17
No files matched your search
@@ -100,6 +100,7 @@ jobs:
|
||||
working-directory: webui
|
||||
run: |
|
||||
npm run check
|
||||
npm test
|
||||
npm run build
|
||||
|
||||
- name: Test
|
||||
|
||||
@@ -117,6 +117,7 @@ jobs:
|
||||
working-directory: webui
|
||||
run: |
|
||||
npm run check
|
||||
npm test
|
||||
npm run build
|
||||
|
||||
- name: Test
|
||||
|
||||
Generated
+349
-2
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
@@ -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}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
Reference in new issue
Block a user