Fix what a review of the whole app found, end to end (0.67.1)
A pass over every feature against a real Docker daemon, then clicking through the UI as admin and read-only user, on a desktop and a phone. Security. /ws/deploy ran compose up for the read-only role; it is admin-only now. A container's inspect view, a stack's compose file (POSTGRES_PASSWORD: hunter2) and the viewer's .env tab no longer show credentials to that role. Git branch and URL values that git would read as options (-x, ext::) are refused. SFTP host keys are checked: pinned on first use or entered by hand (key line or SHA256 fingerprint), a different key is refused before the password is sent. A backup name from the API can no longer reach outside the destination folder. The file browser refuses to move or rename a browse root, as delete did. Docker lifecycle. Closing the deploy console released the stack lock while compose was still running and logged rc=None as success; deploys and updates are now background runs that hold the lock to the end, report the real exit code and can be re-attached to. Every closed log view left a `compose logs -f` behind. A dozen open tabs exhausted the default thread pool and hung the API; event and exec streams have their own threads. Auto-update and the update endpoint ignored failed pulls. A deleted git stack was recreated and started by the next webhook or poll; deleting a stack now drops its git source, auto-update policy and schedule entries. Deleting no longer walks into mounted shares, is refused while a backup runs, and removes the containers even when the compose file is broken. Stack folders compose has to normalise (Nextcloud -> nextcloud) work everywhere and group correctly on the volumes, networks and images pages. Detection. Host port conflicts read the container's own network namespace; /proc/1/net is read instead. Images referenced as docker.io/… were never update-checked; local builds no longer report "needs credentials". NVIDIA GPUs are read from the driver's procfs. The backup dialog's "path mismatch" warning came from mountinfo's filesystem-relative root and fired for any stacks folder on its own disk, ZFS dataset or btrfs subvolume; the host path now comes from Docker's mounts. Backups and destinations. The download ignored the folder/volume selection (axios sent binds[]). Restoring under a new name kept the old one. A report header past nginx's buffer turned a good backup into a 502. SFTP/S3 errors were bare 500s and "Test" did not try to write. A destination used by a schedule could be deleted. Backup file names are in the schedule's or the browser's time zone instead of UTC. Editor and converter. `docker run` conversion produced invalid compose for --network host, named volumes and -dit; --gpus and --mount were dropped; a multi-line command pasted into the one-line field became `image: ' '` (now a textarea, and flattened continuations are read). Validation failed for env_file stacks. The editor overwrote unsaved edits on every container event, lost them on a stray click (now asks), threw when closing the diff, and its helper panel listed the starter service "app" for every existing stack. Monaco is bundled instead of loaded from jsdelivr, so the editor works offline (and monaco-editor 0.57 drops the dompurify advisory; axios and react-router updated). UI. All times were two hours off: offset-less UTC from SQLite was read as local time, and "next run" always said "just now". A token refresh mid-session duplicated deploys, reset terminals and doubled logs. A backend restart (update, self-update) signed everyone out on reload; the page now waits for it. The editor was unusable on a phone, tables and tabs overflowed, dialog text was black on the dark theme, and the file browser opened on an error with the default roots. Read-only users get "Admin only" pages instead of 403s; in-use networks offer no delete; "Scan all" covers StackPilot's own stacks only and runs in the background instead of timing out at 600s. Deployment. nginx passes the port in Host (the OIDC redirect URI lost :5009) and keeps an outer proxy's X-Forwarded-Proto. The frontend waits for a healthy backend (healthcheck probes every 2s while starting). The frontend image builds with npm ci. Secrets are 0644 in a 0700 folder so non-root containers can read them; existing files are fixed at startup. The test suite no longer starts real containers on a host with Docker. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fx7gcuy8d6nPd95g43rGHu
This commit is contained in:
1 parent
270ddda51d
commit
d6cebb3371
88 files changed
+4752
-925
No files matched your search
+5
-2
@@ -1,7 +1,10 @@
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
# The lockfile, and `npm ci`: `npm install` against package.json alone
|
||||
# resolved whatever the caret ranges allowed on the day of the build, so the
|
||||
# image could ship dependency versions CI never typechecked or tested.
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
|
||||
+21
-3
@@ -1,3 +1,11 @@
|
||||
# Behind a TLS-terminating proxy (Traefik, NPM, Caddy) this nginx itself only
|
||||
# sees plain http: passing $scheme on would tell the backend "http" for an
|
||||
# https site. Keep what the outer proxy said, and fall back to our own scheme.
|
||||
map $http_x_forwarded_proto $sp_forwarded_proto {
|
||||
default $http_x_forwarded_proto;
|
||||
"" $scheme;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
@@ -12,17 +20,24 @@ server {
|
||||
# API proxy
|
||||
location /api/ {
|
||||
proxy_pass http://backend:5008;
|
||||
proxy_set_header Host $host;
|
||||
# $http_host, not $host: $host drops the port, so with the default
|
||||
# "5009:80" mapping the OIDC redirect URI the backend derives from it
|
||||
# pointed at port 80.
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# Without this uvicorn sees plain http even behind TLS, which would make
|
||||
# the OIDC redirect URI it derives wrong and the refresh cookie miss its
|
||||
# Secure flag.
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $sp_forwarded_proto;
|
||||
proxy_read_timeout 600s;
|
||||
# Stack backups (incl. volume data) can be large in both directions.
|
||||
client_max_body_size 0;
|
||||
proxy_request_buffering off;
|
||||
# A backup download carries its report in a response header; past the
|
||||
# default 4-8k header buffer nginx answers 502 for a backup that worked.
|
||||
proxy_buffer_size 16k;
|
||||
proxy_buffers 8 16k;
|
||||
}
|
||||
|
||||
# WebSocket proxy
|
||||
@@ -31,7 +46,10 @@ server {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $sp_forwarded_proto;
|
||||
proxy_read_timeout 86400s;
|
||||
}
|
||||
|
||||
|
||||
Generated
+109
-138
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "stackpilot-frontend",
|
||||
"version": "0.49.0",
|
||||
"version": "0.67.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "stackpilot-frontend",
|
||||
"version": "0.49.0",
|
||||
"version": "0.67.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/schibsted-grotesk": "^5.2.8",
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
@@ -16,6 +16,7 @@
|
||||
"axios": "^1.7.9",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.468.0",
|
||||
"monaco-editor": "^0.57.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0",
|
||||
@@ -618,9 +619,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.23.3",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
|
||||
"integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==",
|
||||
"version": "1.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.4.tgz",
|
||||
"integrity": "sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
@@ -1145,8 +1146,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "4.7.0",
|
||||
@@ -1170,14 +1170,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "2.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.8.tgz",
|
||||
"integrity": "sha512-8ytZ/fFHq2g4PJVAtDX57mayemKgDR6X3Oa2Foro+EygiOJHUXhCqBAAKQYYajZpFoIfvBCF1j6R6IYRSIUFuw==",
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz",
|
||||
"integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "2.1.8",
|
||||
"@vitest/utils": "2.1.8",
|
||||
"@vitest/spy": "2.1.9",
|
||||
"@vitest/utils": "2.1.9",
|
||||
"chai": "^5.1.2",
|
||||
"tinyrainbow": "^1.2.0"
|
||||
},
|
||||
@@ -1186,13 +1186,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "2.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.8.tgz",
|
||||
"integrity": "sha512-7guJ/47I6uqfttp33mgo6ga5Gr1VnL58rcqYKyShoRK9ebu8T5Rs6HN3s1NABiBeVTdWNrwUMcHH54uXZBN4zA==",
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz",
|
||||
"integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "2.1.8",
|
||||
"@vitest/spy": "2.1.9",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.12"
|
||||
},
|
||||
@@ -1226,13 +1226,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "2.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.8.tgz",
|
||||
"integrity": "sha512-17ub8vQstRnRlIU5k50bG+QOMLHRhYPAna5tw8tYbj+jzjcspnwnwtPtiOlkuKC4+ixDPTuLZiqiWWQ2PSXHVg==",
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz",
|
||||
"integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "2.1.8",
|
||||
"@vitest/utils": "2.1.9",
|
||||
"pathe": "^1.1.2"
|
||||
},
|
||||
"funding": {
|
||||
@@ -1240,13 +1240,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "2.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.8.tgz",
|
||||
"integrity": "sha512-20T7xRFbmnkfcmgVEz+z3AU/3b0cEzZOt/zmnvZEctg64/QZbSDJEVm9fLnnlSi74KibmRsO9/Qabi+t0vCRPg==",
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz",
|
||||
"integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "2.1.8",
|
||||
"@vitest/pretty-format": "2.1.9",
|
||||
"magic-string": "^0.30.12",
|
||||
"pathe": "^1.1.2"
|
||||
},
|
||||
@@ -1254,23 +1254,10 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": {
|
||||
"version": "2.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.8.tgz",
|
||||
"integrity": "sha512-9HiSZ9zpqNLKlbIDRWOnAWqgcA7xu+8YxXSekhr0Ykab7PAYFkhkwoqVArPOtJhPmYeE2YHgKZlj3CP36z2AJQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyrainbow": "^1.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "2.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.8.tgz",
|
||||
"integrity": "sha512-5swjf2q95gXeYPevtW0BLk6H8+bPlMb4Vw/9Em4hFxDcaOxS+e0LOX4yqNxoHzMR2akEB2xfpnWUzkZokmgWDg==",
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz",
|
||||
"integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1281,13 +1268,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "2.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.8.tgz",
|
||||
"integrity": "sha512-dwSoui6djdwbfFmIgbIjX2ZhIoG7Ex/+xpxyiEgIGzjliY8xGkcpITKTlp6B4MgtGkF2ilvm97cPM96XZaAgcA==",
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz",
|
||||
"integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "2.1.8",
|
||||
"@vitest/pretty-format": "2.1.9",
|
||||
"loupe": "^3.1.2",
|
||||
"tinyrainbow": "^1.2.0"
|
||||
},
|
||||
@@ -1295,19 +1282,6 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils/node_modules/@vitest/pretty-format": {
|
||||
"version": "2.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.8.tgz",
|
||||
"integrity": "sha512-9HiSZ9zpqNLKlbIDRWOnAWqgcA7xu+8YxXSekhr0Ykab7PAYFkhkwoqVArPOtJhPmYeE2YHgKZlj3CP36z2AJQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyrainbow": "^1.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
|
||||
@@ -1453,21 +1427,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.17.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz",
|
||||
"integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==",
|
||||
"version": "1.20.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz",
|
||||
"integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.16.0",
|
||||
"form-data": "^4.0.5",
|
||||
"form-data": "^4.0.6",
|
||||
"https-proxy-agent": "^5.0.1",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.34",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.34.tgz",
|
||||
"integrity": "sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw==",
|
||||
"version": "2.11.28",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.28.tgz",
|
||||
"integrity": "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -1504,9 +1478,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.2",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
|
||||
"integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
|
||||
"version": "4.29.3",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz",
|
||||
"integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1524,11 +1498,11 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.12",
|
||||
"caniuse-lite": "^1.0.30001782",
|
||||
"electron-to-chromium": "^1.5.328",
|
||||
"node-releases": "^2.0.36",
|
||||
"update-browserslist-db": "^1.2.3"
|
||||
"baseline-browser-mapping": "^2.11.26",
|
||||
"caniuse-lite": "^1.0.30001813",
|
||||
"electron-to-chromium": "^1.5.439",
|
||||
"node-releases": "^2.0.57",
|
||||
"update-browserslist-db": "^1.3.3"
|
||||
},
|
||||
"bin": {
|
||||
"browserslist": "cli.js"
|
||||
@@ -1571,9 +1545,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001797",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz",
|
||||
"integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==",
|
||||
"version": "1.0.30001816",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001816.tgz",
|
||||
"integrity": "sha512-+8Yx7BXCoqk6IF8yTicjfUR6D/ldJzdlsodlEOiOSPmhK844WIgk67/zIQaGAOxNkZyyvBlrRFnLvqKIoqbAFw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1861,11 +1835,10 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
|
||||
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
|
||||
"version": "3.4.15",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz",
|
||||
"integrity": "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"peer": true,
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
@@ -1885,9 +1858,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.368",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.368.tgz",
|
||||
"integrity": "sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw==",
|
||||
"version": "1.5.456",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.456.tgz",
|
||||
"integrity": "sha512-8hboqI6xesozteFb8aa7OGknvxUtTeRJLz0FoePysUEh/oWbddykIGdgpEJRbPRg/nIMfBf2DFos+H2MtvJl2A==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@@ -2099,16 +2072,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
"hasown": "^2.0.4",
|
||||
"mime-types": "^2.1.35"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@@ -2595,7 +2568,6 @@
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
|
||||
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
@@ -2658,13 +2630,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/monaco-editor": {
|
||||
"version": "0.55.1",
|
||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
|
||||
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
|
||||
"version": "0.57.0",
|
||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.57.0.tgz",
|
||||
"integrity": "sha512-5BkI9KGoqrNvBGUe15/QlZq3OooZ8WLg1AxTpaqHRCP3HNpzPPZKE2EDz8M7c+VRmCeUw1Brp4cx/PWm3kI/5A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"dompurify": "3.2.7",
|
||||
"dompurify": "3.4.15",
|
||||
"marked": "14.0.0"
|
||||
}
|
||||
},
|
||||
@@ -2687,9 +2658,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"version": "3.3.20",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
|
||||
"integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2706,9 +2677,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.47",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz",
|
||||
"integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==",
|
||||
"version": "2.0.59",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.59.tgz",
|
||||
"integrity": "sha512-5qr2okC4ItAI0hG5SaV0HoYMpnM/Y6+Oqr7AmPYy/2uEoGvS+GbSKLeRxSS1uAdu59yz0ouzcuXievSgg///WA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2830,9 +2801,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"version": "8.5.30",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz",
|
||||
"integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2850,9 +2821,9 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"nanoid": "^3.3.20",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
"source-map-js": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
@@ -2972,9 +2943,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-selector-parser": {
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
|
||||
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
|
||||
"version": "6.1.4",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz",
|
||||
"integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -3103,12 +3074,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "6.30.4",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz",
|
||||
"integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==",
|
||||
"version": "6.30.6",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz",
|
||||
"integrity": "sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.3"
|
||||
"@remix-run/router": "1.23.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
@@ -3118,13 +3089,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "6.30.4",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz",
|
||||
"integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==",
|
||||
"version": "6.30.6",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.6.tgz",
|
||||
"integrity": "sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.3",
|
||||
"react-router": "6.30.4"
|
||||
"@remix-run/router": "1.23.4",
|
||||
"react-router": "6.30.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
@@ -3337,9 +3308,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
|
||||
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
@@ -3673,9 +3644,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz",
|
||||
"integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -3771,9 +3742,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite-node": {
|
||||
"version": "2.1.8",
|
||||
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.8.tgz",
|
||||
"integrity": "sha512-uPAwSr57kYjAUux+8E2j0q0Fxpn8M9VoyfGiRI8Kfktz9NcYMCenwY5RnZxnF1WTu3TGiYipirIzacLL3VVGFg==",
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz",
|
||||
"integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -3794,19 +3765,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "2.1.8",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.8.tgz",
|
||||
"integrity": "sha512-1vBKTZskHw/aosXqQUlVWWlGUxSJR8YtiyZDJAFeW2kPAeX6S3Sool0mjspO+kXLuxVWlEDDowBAeqeAQefqLQ==",
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz",
|
||||
"integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "2.1.8",
|
||||
"@vitest/mocker": "2.1.8",
|
||||
"@vitest/pretty-format": "^2.1.8",
|
||||
"@vitest/runner": "2.1.8",
|
||||
"@vitest/snapshot": "2.1.8",
|
||||
"@vitest/spy": "2.1.8",
|
||||
"@vitest/utils": "2.1.8",
|
||||
"@vitest/expect": "2.1.9",
|
||||
"@vitest/mocker": "2.1.9",
|
||||
"@vitest/pretty-format": "^2.1.9",
|
||||
"@vitest/runner": "2.1.9",
|
||||
"@vitest/snapshot": "2.1.9",
|
||||
"@vitest/spy": "2.1.9",
|
||||
"@vitest/utils": "2.1.9",
|
||||
"chai": "^5.1.2",
|
||||
"debug": "^4.3.7",
|
||||
"expect-type": "^1.1.0",
|
||||
@@ -3818,7 +3789,7 @@
|
||||
"tinypool": "^1.0.1",
|
||||
"tinyrainbow": "^1.2.0",
|
||||
"vite": "^5.0.0",
|
||||
"vite-node": "2.1.8",
|
||||
"vite-node": "2.1.9",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -3833,8 +3804,8 @@
|
||||
"peerDependencies": {
|
||||
"@edge-runtime/vm": "*",
|
||||
"@types/node": "^18.0.0 || >=20.0.0",
|
||||
"@vitest/browser": "2.1.8",
|
||||
"@vitest/ui": "2.1.8",
|
||||
"@vitest/browser": "2.1.9",
|
||||
"@vitest/ui": "2.1.9",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "stackpilot-frontend",
|
||||
"private": true,
|
||||
"version": "0.67.0",
|
||||
"version": "0.67.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -18,6 +18,7 @@
|
||||
"axios": "^1.7.9",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.468.0",
|
||||
"monaco-editor": "^0.57.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0",
|
||||
|
||||
+45
-26
@@ -1,5 +1,12 @@
|
||||
import { lazy, useEffect } from "react";
|
||||
import { BrowserRouter, Navigate, Outlet, Route, Routes } from "react-router-dom";
|
||||
import {
|
||||
Navigate,
|
||||
Outlet,
|
||||
Route,
|
||||
RouterProvider,
|
||||
createBrowserRouter,
|
||||
createRoutesFromElements,
|
||||
} from "react-router-dom";
|
||||
import { AppShell } from "@/components/layout/AppShell";
|
||||
import { Login } from "@/pages/Login";
|
||||
import { Dashboard } from "@/pages/Dashboard";
|
||||
@@ -46,17 +53,52 @@ function RequireAuth() {
|
||||
}
|
||||
|
||||
function BootSplash() {
|
||||
// Set while the backend does not answer yet (starting, restarting after an
|
||||
// update) — the session is kept and the restore keeps trying.
|
||||
const waiting = useAuthStore((s) => s.waiting);
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-bg dark:bg-bg-dark">
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-bg dark:bg-bg-dark">
|
||||
<span className="sr-only">Restoring your session…</span>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="h-8 w-8 animate-spin rounded-full border-2 border-slate-300 border-t-accent dark:border-slate-600 dark:border-t-accent-dark"
|
||||
/>
|
||||
{waiting && (
|
||||
<p className="text-sm text-slate-500" role="status">
|
||||
Waiting for the StackPilot backend to start…
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A data router rather than <BrowserRouter>: only it supports useBlocker, which
|
||||
// is what keeps the stack editor from dropping unsaved edits on a stray click.
|
||||
const router = createBrowserRouter(
|
||||
createRoutesFromElements(
|
||||
<>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route element={<AppShell />}>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/stacks" element={<Stacks />} />
|
||||
<Route path="/stacks/new" element={<StackEditor />} />
|
||||
<Route path="/stacks/:id" element={<StackDetail />} />
|
||||
<Route path="/stacks/:id/edit" element={<StackEditor />} />
|
||||
<Route path="/networks" element={<Networks />} />
|
||||
<Route path="/images" element={<Images />} />
|
||||
<Route path="/volumes" element={<Volumes />} />
|
||||
<Route path="/files" element={<Files />} />
|
||||
<Route path="/templates" element={<Templates />} />
|
||||
<Route path="/audit" element={<Audit />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</>
|
||||
)
|
||||
);
|
||||
|
||||
export default function App() {
|
||||
const applyTheme = useThemeStore((s) => s.apply);
|
||||
const restore = useAuthStore((s) => s.restore);
|
||||
@@ -70,28 +112,5 @@ export default function App() {
|
||||
restore();
|
||||
}, [restore]);
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route element={<AppShell />}>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/stacks" element={<Stacks />} />
|
||||
<Route path="/stacks/new" element={<StackEditor />} />
|
||||
<Route path="/stacks/:id" element={<StackDetail />} />
|
||||
<Route path="/stacks/:id/edit" element={<StackEditor />} />
|
||||
<Route path="/networks" element={<Networks />} />
|
||||
<Route path="/images" element={<Images />} />
|
||||
<Route path="/volumes" element={<Volumes />} />
|
||||
<Route path="/files" element={<Files />} />
|
||||
<Route path="/templates" element={<Templates />} />
|
||||
<Route path="/audit" element={<Audit />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import api from "./client";
|
||||
import api, { withBlobError } from "./client";
|
||||
|
||||
export interface BackupDestination {
|
||||
id: number;
|
||||
@@ -83,7 +83,17 @@ export interface BackupOptions {
|
||||
}
|
||||
|
||||
/** Query params for the backup endpoints. */
|
||||
export function backupParams(opts: BackupOptions) {
|
||||
export /** The browser's time zone: the server names backup files in it, so the time
|
||||
* in the name is the one on the user's clock rather than UTC. */
|
||||
function browserZone(): string | undefined {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function backupParams(opts: BackupOptions) {
|
||||
return {
|
||||
include_volumes: opts.includeVolumes,
|
||||
include_binds: opts.includeBinds ?? true,
|
||||
@@ -128,10 +138,12 @@ export const backupsApi = {
|
||||
.then((r) => r.data),
|
||||
|
||||
download: async (stackId: string, opts: BackupOptions) => {
|
||||
const res = await api.get(`/api/stacks/${stackId}/backup`, {
|
||||
params: backupParams(opts),
|
||||
responseType: "blob",
|
||||
});
|
||||
const res = await withBlobError(
|
||||
api.get(`/api/stacks/${stackId}/backup`, {
|
||||
params: { ...backupParams(opts), tz: browserZone() },
|
||||
responseType: "blob",
|
||||
})
|
||||
);
|
||||
const cd = res.headers["content-disposition"] as string | undefined;
|
||||
const match = cd?.match(/filename="?([^"]+)"?/);
|
||||
const name = match?.[1] ?? `backup-${stackId}.tar.gz`;
|
||||
@@ -175,7 +187,7 @@ export const backupsApi = {
|
||||
destination: string;
|
||||
name: string;
|
||||
report?: BackupReport;
|
||||
}>(`/api/stacks/${stackId}/backup/push`, body)
|
||||
}>(`/api/stacks/${stackId}/backup/push`, { ...body, tz: browserZone() })
|
||||
.then((r) => r.data),
|
||||
|
||||
restoreFrom: (body: {
|
||||
@@ -199,7 +211,8 @@ export const destinationsApi = {
|
||||
api.delete(`/api/backups/destinations/${id}`).then((r) => r.data),
|
||||
test: (id: number) =>
|
||||
api
|
||||
.post<{ ok: boolean; error?: string }>(`/api/backups/destinations/${id}/test`)
|
||||
// host_key: the SFTP server's pinned key, as a SHA256 fingerprint.
|
||||
.post<{ ok: boolean; error?: string; host_key?: string }>(`/api/backups/destinations/${id}/test`)
|
||||
.then((r) => r.data),
|
||||
backups: (id: number) =>
|
||||
api.get<RemoteBackup[]>(`/api/backups/destinations/${id}/backups`).then((r) => r.data),
|
||||
|
||||
@@ -2,7 +2,14 @@ import axios, { AxiosError } from "axios";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
|
||||
// withCredentials so the httpOnly refresh cookie rides along on /api/auth/refresh.
|
||||
const api = axios.create({ baseURL: "/", withCredentials: true });
|
||||
const api = axios.create({
|
||||
baseURL: "/",
|
||||
withCredentials: true,
|
||||
// Arrays as `binds=a&binds=b`, which is what FastAPI reads as a list. Axios'
|
||||
// default `binds[]=a` is a different parameter name to it, so the folders and
|
||||
// volumes picked in the backup dialog were silently ignored on download.
|
||||
paramsSerializer: { indexes: null },
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = useAuthStore.getState().accessToken;
|
||||
@@ -40,6 +47,29 @@ api.interceptors.response.use(
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* The server's JSON error body behind a `responseType: "blob"` request (a
|
||||
* backup download), which axios hands back as a Blob. Without this such a
|
||||
* failure read "Request failed with status code 409" instead of saying the
|
||||
* stack was busy.
|
||||
*/
|
||||
export async function withBlobError<T>(request: Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await request;
|
||||
} catch (err) {
|
||||
const e = err as AxiosError<any>;
|
||||
const data = e?.response?.data;
|
||||
if (e?.response && typeof Blob !== "undefined" && data instanceof Blob) {
|
||||
try {
|
||||
e.response.data = JSON.parse(await data.text());
|
||||
} catch {
|
||||
/* not JSON — keep the generic message */
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export function apiErrorMessage(err: unknown): string {
|
||||
const e = err as AxiosError<any>;
|
||||
const detail = e?.response?.data?.detail;
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import api from "./client";
|
||||
|
||||
export const editorApi = {
|
||||
validate: (yaml: string, env: string) =>
|
||||
/** With `stackId`, relative paths (env_file, include, secrets) resolve in
|
||||
* that stack's folder, as they will when it is deployed. */
|
||||
validate: (yaml: string, env: string, stackId?: string) =>
|
||||
api
|
||||
.post<{ ok: boolean; errors: string }>("/api/editor/validate", { yaml, env })
|
||||
.post<{ ok: boolean; errors: string }>("/api/editor/validate", {
|
||||
yaml,
|
||||
env,
|
||||
stack_id: stackId ?? null,
|
||||
})
|
||||
.then((r) => r.data),
|
||||
services: (yaml: string) =>
|
||||
api
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import api from "./client";
|
||||
import api, { withBlobError } from "./client";
|
||||
import type { HostPathResult } from "@/types";
|
||||
|
||||
export interface FileContent {
|
||||
@@ -61,13 +61,15 @@ export const filesApi = {
|
||||
filename: string,
|
||||
onProgress?: (loaded: number, total: number | undefined) => void,
|
||||
) => {
|
||||
const res = await api.get(`${base}/download`, {
|
||||
params: { path },
|
||||
responseType: "blob",
|
||||
onDownloadProgress: onProgress
|
||||
? (e) => onProgress(e.loaded, e.total || undefined)
|
||||
: undefined,
|
||||
});
|
||||
const res = await withBlobError(
|
||||
api.get(`${base}/download`, {
|
||||
params: { path },
|
||||
responseType: "blob",
|
||||
onDownloadProgress: onProgress
|
||||
? (e) => onProgress(e.loaded, e.total || undefined)
|
||||
: undefined,
|
||||
})
|
||||
);
|
||||
triggerDownload(res.data as Blob, filename);
|
||||
},
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ export interface UpdateStatus {
|
||||
remote_digest: string | null;
|
||||
checked_at: number;
|
||||
error: string | null;
|
||||
/** Built on this host: there is no registry copy to compare against. */
|
||||
local?: boolean;
|
||||
}
|
||||
|
||||
export interface ImageRow {
|
||||
@@ -53,6 +55,17 @@ export interface ScanDetail extends ScanSummary {
|
||||
findings: Finding[];
|
||||
}
|
||||
|
||||
export interface ScanSweepStatus {
|
||||
active: boolean;
|
||||
done: number;
|
||||
total: number;
|
||||
/** Set once a sweep has finished. */
|
||||
scanned: number;
|
||||
failed: number;
|
||||
/** Increments per sweep, to tell the one just started from the last. */
|
||||
run: number;
|
||||
}
|
||||
|
||||
const base = "/api/images";
|
||||
|
||||
export const imagesApi = {
|
||||
@@ -64,12 +77,13 @@ export const imagesApi = {
|
||||
api.get<ScanDetail>(`${base}/scan`, { params: { image } }).then((r) => r.data),
|
||||
scan: (image: string) =>
|
||||
api.post<ScanSummary>(`${base}/scan`, { image }).then((r) => r.data),
|
||||
scanAll: () =>
|
||||
api.post<{ scanned: number; failed: number }>(`${base}/scan-all`).then((r) => r.data),
|
||||
scanStatus: () =>
|
||||
/** Starts the sweep on the server and returns at once; poll scanStatus().
|
||||
* Waiting on the response outlived the proxy's 600s timeout. */
|
||||
startScanAll: () =>
|
||||
api
|
||||
.get<{ active: boolean; done: number; total: number }>(`${base}/scan-status`)
|
||||
.post<ScanSweepStatus>(`${base}/scan-all`, null, { params: { background: true } })
|
||||
.then((r) => r.data),
|
||||
scanStatus: () => api.get<ScanSweepStatus>(`${base}/scan-status`).then((r) => r.data),
|
||||
// Runs in the background on the server; poll pruneStatus() for progress.
|
||||
startPrune: (allUnused: boolean) =>
|
||||
api
|
||||
|
||||
@@ -33,8 +33,12 @@ export const stacksApi = {
|
||||
env?: string;
|
||||
}
|
||||
) => api.put<StackSummary>(`/api/stacks/${id}`, body).then((r) => r.data),
|
||||
/** `kept` lists folders left in place because another filesystem (a share,
|
||||
* a second disk) is mounted there. */
|
||||
remove: (id: string, deleteFiles = true) =>
|
||||
api.delete(`/api/stacks/${id}?delete_files=${deleteFiles}`).then((r) => r.data),
|
||||
api
|
||||
.delete<{ ok: boolean; kept?: string[] }>(`/api/stacks/${id}?delete_files=${deleteFiles}`)
|
||||
.then((r) => r.data),
|
||||
clone: (id: string, name: string) =>
|
||||
api.post(`/api/stacks/${id}/clone`, { name }).then((r) => r.data),
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { toast } from "sonner";
|
||||
import { Badge, Button, Card } from "@/components/ui";
|
||||
import { autoUpdateApi, type AutoUpdatePolicy } from "@/api/autoUpdate";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
import { parseServerTime } from "@/lib/utils";
|
||||
|
||||
const STATUS_TONE: Record<string, string> = {
|
||||
updated: "text-green-500",
|
||||
@@ -110,7 +111,7 @@ export function AutoUpdatePanel({
|
||||
|
||||
{p.last_run && (
|
||||
<p className="text-xs text-slate-500">
|
||||
Last run {new Date(p.last_run).toLocaleString()} —{" "}
|
||||
Last run {parseServerTime(p.last_run).toLocaleString()} —{" "}
|
||||
<span className={STATUS_TONE[p.last_status ?? ""] ?? "text-slate-500"}>
|
||||
{p.last_status}
|
||||
</span>
|
||||
|
||||
@@ -25,16 +25,20 @@ export function ContainerTerminal({
|
||||
service: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const token = useAuthStore((s) => s.accessToken);
|
||||
const [shell, setShell] = useState(SHELLS[0]);
|
||||
const [status, setStatus] = useState<Status>("connecting");
|
||||
const [detail, setDetail] = useState<string | null>(null);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Keyed on the container and shell only. With the access token as a
|
||||
// dependency, every background token refresh tore the session down and
|
||||
// opened a new shell — whatever was running in the terminal was gone.
|
||||
useEffect(() => {
|
||||
if (!token || !boxRef.current) return;
|
||||
if (!boxRef.current) return;
|
||||
setStatus("connecting");
|
||||
setDetail(null);
|
||||
let cancelled = false;
|
||||
let ws: WebSocket | null = null;
|
||||
|
||||
const term = new Terminal({
|
||||
fontSize: 13,
|
||||
@@ -47,55 +51,68 @@ export function ContainerTerminal({
|
||||
term.open(boxRef.current);
|
||||
fit.fit();
|
||||
|
||||
const proto = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
const url =
|
||||
`${proto}://${window.location.host}/ws/exec/${containerId}` +
|
||||
`?token=${encodeURIComponent(token)}&cmd=${encodeURIComponent(shell)}`;
|
||||
const ws = new WebSocket(url);
|
||||
|
||||
const sendResize = () => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "resize", rows: term.rows, cols: term.cols }));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onopen = () => {
|
||||
setStatus("open");
|
||||
term.focus();
|
||||
sendResize();
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data);
|
||||
if (msg.type === "data") term.write(msg.data as string);
|
||||
else if (msg.type === "exit") {
|
||||
term.write(`\r\n\x1b[90m[process exited${msg.code != null ? ` with code ${msg.code}` : ""}]\x1b[0m\r\n`);
|
||||
setStatus("closed");
|
||||
} else if (msg.type === "error") {
|
||||
term.write(`\r\n\x1b[31m${msg.detail || "error"}\x1b[0m\r\n`);
|
||||
setStatus("error");
|
||||
setDetail(msg.detail || "error");
|
||||
}
|
||||
} catch {
|
||||
/* non-JSON: write raw */
|
||||
term.write(typeof ev.data === "string" ? ev.data : "");
|
||||
}
|
||||
};
|
||||
ws.onclose = (ev) => {
|
||||
if (ev.code === 4403) {
|
||||
const connect = async () => {
|
||||
const token = await useAuthStore.getState().freshAccessToken();
|
||||
if (cancelled) return;
|
||||
if (!token) {
|
||||
setStatus("error");
|
||||
setDetail("Terminal access is admin-only.");
|
||||
term.write("\r\n\x1b[31mTerminal access is admin-only.\x1b[0m\r\n");
|
||||
} else if (ev.code === 4401) {
|
||||
setStatus("error");
|
||||
setDetail("Authentication failed.");
|
||||
} else {
|
||||
setStatus((s) => (s === "open" ? "closed" : s));
|
||||
setDetail("Your session has ended — sign in again.");
|
||||
return;
|
||||
}
|
||||
const proto = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
ws = new WebSocket(
|
||||
`${proto}://${window.location.host}/ws/exec/${encodeURIComponent(containerId)}` +
|
||||
`?token=${encodeURIComponent(token)}&cmd=${encodeURIComponent(shell)}`
|
||||
);
|
||||
wire(ws);
|
||||
};
|
||||
|
||||
const wire = (ws: WebSocket) => {
|
||||
ws.onopen = () => {
|
||||
setStatus("open");
|
||||
term.focus();
|
||||
sendResize();
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data);
|
||||
if (msg.type === "data") term.write(msg.data as string);
|
||||
else if (msg.type === "exit") {
|
||||
term.write(`\r\n\x1b[90m[process exited${msg.code != null ? ` with code ${msg.code}` : ""}]\x1b[0m\r\n`);
|
||||
setStatus("closed");
|
||||
} else if (msg.type === "error") {
|
||||
term.write(`\r\n\x1b[31m${msg.detail || "error"}\x1b[0m\r\n`);
|
||||
setStatus("error");
|
||||
setDetail(msg.detail || "error");
|
||||
}
|
||||
} catch {
|
||||
/* non-JSON: write raw */
|
||||
term.write(typeof ev.data === "string" ? ev.data : "");
|
||||
}
|
||||
};
|
||||
ws.onclose = (ev) => {
|
||||
if (ev.code === 4403) {
|
||||
setStatus("error");
|
||||
setDetail("Terminal access is admin-only.");
|
||||
term.write("\r\n\x1b[31mTerminal access is admin-only.\x1b[0m\r\n");
|
||||
} else if (ev.code === 4401) {
|
||||
setStatus("error");
|
||||
setDetail("Authentication failed.");
|
||||
} else {
|
||||
setStatus((s) => (s === "open" ? "closed" : s));
|
||||
}
|
||||
};
|
||||
};
|
||||
void connect();
|
||||
|
||||
const onData = term.onData((d) => {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "data", data: d }));
|
||||
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "data", data: d }));
|
||||
});
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
@@ -109,12 +126,13 @@ export function ContainerTerminal({
|
||||
ro.observe(boxRef.current);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
ro.disconnect();
|
||||
onData.dispose();
|
||||
ws.close();
|
||||
ws?.close();
|
||||
term.dispose();
|
||||
};
|
||||
}, [containerId, shell, token]);
|
||||
}, [containerId, shell]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
|
||||
@@ -40,15 +40,19 @@ export function DeployConsole({
|
||||
const [errorDetail, setErrorDetail] = useState<string | null>(null);
|
||||
const [showLog, setShowLog] = useState(true);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
const token = useAuthStore((s) => s.accessToken);
|
||||
|
||||
const tracker = useMemo(() => new DeployTracker(), []);
|
||||
const bufferRef = useRef<LogLine[]>([]);
|
||||
const indexRef = useRef(new Map<string, number>());
|
||||
const dirtyRef = useRef(false);
|
||||
|
||||
// Connected once per stack — deliberately not keyed on the access token.
|
||||
// Re-running this effect opens a *new* /ws/deploy, i.e. starts another
|
||||
// `compose up`; with the token as a dependency, every background token
|
||||
// refresh during (or after) a deploy did exactly that.
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
let ws: WebSocket | null = null;
|
||||
let cancelled = false;
|
||||
|
||||
// One line per layer/container, updated in place — the same way compose
|
||||
// renders on a TTY — so status changes replace their line instead of
|
||||
@@ -86,38 +90,63 @@ export function DeployConsole({
|
||||
push(ev.id, `${ev.id} ${ev.text}${details}`, ev.status === "error" ? "error" : "plain");
|
||||
};
|
||||
|
||||
const proto = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
const url = `${proto}://${window.location.host}/ws/deploy/${stackId}?token=${token}`;
|
||||
const ws = new WebSocket(url);
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data);
|
||||
if (msg.type === "log") {
|
||||
handleLine(msg.line as string);
|
||||
} else if (msg.type === "done") {
|
||||
const ok = msg.returncode === 0;
|
||||
tracker.finish(ok);
|
||||
dirtyRef.current = true;
|
||||
setPhase(ok ? "success" : "failed");
|
||||
} else if (msg.type === "error") {
|
||||
tracker.finish(false);
|
||||
dirtyRef.current = true;
|
||||
setPhase("error");
|
||||
setErrorDetail(msg.detail || "Deploy error");
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
const fail = (detail: string) => {
|
||||
tracker.finish(false);
|
||||
dirtyRef.current = true;
|
||||
setPhase("error");
|
||||
setErrorDetail(detail);
|
||||
};
|
||||
|
||||
// Set once a done/error frame arrived, so the close that follows it is
|
||||
// not mistaken for a dropped connection.
|
||||
let settled = false;
|
||||
|
||||
const connect = async () => {
|
||||
const token = await useAuthStore.getState().freshAccessToken();
|
||||
if (cancelled) return;
|
||||
if (!token) {
|
||||
settled = true;
|
||||
fail("Your session has ended — sign in again.");
|
||||
return;
|
||||
}
|
||||
const proto = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
ws = new WebSocket(
|
||||
`${proto}://${window.location.host}/ws/deploy/${encodeURIComponent(stackId)}` +
|
||||
`?token=${encodeURIComponent(token)}`
|
||||
);
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data);
|
||||
if (msg.type === "log") {
|
||||
handleLine(msg.line as string);
|
||||
} else if (msg.type === "done") {
|
||||
const ok = msg.returncode === 0;
|
||||
settled = true;
|
||||
tracker.finish(ok);
|
||||
dirtyRef.current = true;
|
||||
setPhase(ok ? "success" : "failed");
|
||||
} else if (msg.type === "error") {
|
||||
settled = true;
|
||||
fail(msg.detail || "Deploy error");
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
ws.onclose = (ev) => {
|
||||
// A socket that dropped before a done/error frame is surfaced.
|
||||
if (settled || cancelled) return;
|
||||
settled = true;
|
||||
fail(
|
||||
ev.code === 4403
|
||||
? "Deploying is admin-only."
|
||||
: ev.code === 4401
|
||||
? "Your session has ended — sign in again."
|
||||
: "Lost the connection to the server. The deploy carries on there."
|
||||
);
|
||||
};
|
||||
};
|
||||
ws.onclose = () => {
|
||||
// If the socket dropped before a done/error frame, surface it.
|
||||
setPhase((p) => {
|
||||
if (p !== "running") return p;
|
||||
tracker.finish(false);
|
||||
dirtyRef.current = true;
|
||||
return "error";
|
||||
});
|
||||
};
|
||||
void connect();
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
if (!dirtyRef.current) return;
|
||||
@@ -127,10 +156,11 @@ export function DeployConsole({
|
||||
}, FLUSH_MS);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(timer);
|
||||
ws.close();
|
||||
ws?.close();
|
||||
};
|
||||
}, [stackId, token, tracker]);
|
||||
}, [stackId, tracker]);
|
||||
|
||||
useEffect(() => {
|
||||
if (boxRef.current) boxRef.current.scrollTop = boxRef.current.scrollHeight;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { RefreshCw, HardDrive, Cpu, Plug, Gauge } from "lucide-react";
|
||||
import { VolumeWizard } from "@/components/volumes/VolumeWizard";
|
||||
import { GPUSelector } from "@/components/gpu/GPUSelector";
|
||||
@@ -22,9 +22,14 @@ export function EditorHelperPanel({
|
||||
const [tab, setTab] = useState<Tab>("volumes");
|
||||
const [privileged, setPrivileged] = useState(false);
|
||||
|
||||
// Only the newest request may answer: an older one finishing late would put
|
||||
// back the services of a YAML that has since changed.
|
||||
const request = useRef(0);
|
||||
const loadServices = async () => {
|
||||
const mine = ++request.current;
|
||||
try {
|
||||
const svc = await editorApi.services(yaml);
|
||||
if (mine !== request.current) return;
|
||||
setServices(svc);
|
||||
setService((cur) => (cur && svc.includes(cur) ? cur : svc[0] ?? ""));
|
||||
} catch (e) {
|
||||
@@ -32,10 +37,15 @@ export function EditorHelperPanel({
|
||||
}
|
||||
};
|
||||
|
||||
// Follows the YAML (debounced, it is a server round trip). Read once on mount
|
||||
// it listed the editor's starter service "app" for every existing stack —
|
||||
// its file only arrives after the panel mounted — and the helpers then
|
||||
// wrote into a service that did not exist.
|
||||
useEffect(() => {
|
||||
loadServices();
|
||||
const t = setTimeout(loadServices, 500);
|
||||
return () => clearTimeout(t);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [yaml]);
|
||||
|
||||
const guard = (): string | null => {
|
||||
if (!service) {
|
||||
|
||||
@@ -95,45 +95,64 @@ export function LogViewer({ stackId }: { stackId: string }) {
|
||||
const [serviceFilter, setServiceFilter] = useState<string>("__all__");
|
||||
const [search, setSearch] = useState("");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const token = useAuthStore((s) => s.accessToken);
|
||||
|
||||
// One stream per stack. Keyed on the access token, the stream reconnected on
|
||||
// every background token refresh and the server replayed its 200-line tail
|
||||
// into the view a second time.
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
setError(null);
|
||||
setLines([]);
|
||||
let gotError = false;
|
||||
const proto = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
const url = `${proto}://${window.location.host}/ws/logs/${stackId}?token=${token}`;
|
||||
const ws = new WebSocket(url);
|
||||
ws.onopen = () => setConnected(true);
|
||||
ws.onclose = (ev) => {
|
||||
setConnected(false);
|
||||
// An expired or revoked session closes 4401.
|
||||
if (!gotError && ev.code === 4401) {
|
||||
let cancelled = false;
|
||||
let ws: WebSocket | null = null;
|
||||
|
||||
const connect = async () => {
|
||||
const token = await useAuthStore.getState().freshAccessToken();
|
||||
if (cancelled) return;
|
||||
if (!token) {
|
||||
setError("Not authorized to stream logs — sign in again.");
|
||||
return;
|
||||
}
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data);
|
||||
if (msg.type === "log") {
|
||||
const { service, time, msg: body } = parseLine(msg.service, msg.line);
|
||||
setLines((prev) => {
|
||||
const next = [
|
||||
...prev,
|
||||
{ service, time, msg: body, raw: msg.line, level: levelFor(body) },
|
||||
];
|
||||
return next.length > MAX_LINES ? next.slice(-MAX_LINES) : next;
|
||||
});
|
||||
} else if (msg.type === "error") {
|
||||
gotError = true;
|
||||
setError(msg.detail || "Log stream error");
|
||||
const proto = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
ws = new WebSocket(
|
||||
`${proto}://${window.location.host}/ws/logs/${encodeURIComponent(stackId)}` +
|
||||
`?token=${encodeURIComponent(token)}`
|
||||
);
|
||||
ws.onopen = () => setConnected(true);
|
||||
ws.onclose = (ev) => {
|
||||
setConnected(false);
|
||||
// An expired or revoked session closes 4401.
|
||||
if (!gotError && ev.code === 4401) {
|
||||
setError("Not authorized to stream logs — sign in again.");
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data);
|
||||
if (msg.type === "log") {
|
||||
const { service, time, msg: body } = parseLine(msg.service, msg.line);
|
||||
setLines((prev) => {
|
||||
const next = [
|
||||
...prev,
|
||||
{ service, time, msg: body, raw: msg.line, level: levelFor(body) },
|
||||
];
|
||||
return next.length > MAX_LINES ? next.slice(-MAX_LINES) : next;
|
||||
});
|
||||
} else if (msg.type === "error") {
|
||||
gotError = true;
|
||||
setError(msg.detail || "Log stream error");
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
};
|
||||
return () => ws.close();
|
||||
}, [stackId, token]);
|
||||
void connect();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
ws?.close();
|
||||
};
|
||||
}, [stackId]);
|
||||
|
||||
// Unique services seen so far, for the container filter.
|
||||
const services = useMemo(() => {
|
||||
|
||||
@@ -67,8 +67,10 @@ export function StacksTable({
|
||||
<thead className="border-b border-slate-200 text-xs uppercase text-slate-500 dark:border-slate-700">
|
||||
<tr>
|
||||
<th className="px-4 py-2">Stack</th>
|
||||
<th className="px-4 py-2 w-48">CPU</th>
|
||||
<th className="px-4 py-2 w-48">Memory</th>
|
||||
{/* Left out on a phone: next to the name block there was no room,
|
||||
and the meters were drawn over the service count. */}
|
||||
<th className="hidden w-48 px-4 py-2 sm:table-cell">CPU</th>
|
||||
<th className="hidden w-48 px-4 py-2 sm:table-cell">Memory</th>
|
||||
{isAdmin && <th className="px-4 py-2 w-px"></th>}
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -188,7 +190,7 @@ function StackRow({
|
||||
{status && <ActionProgressInline status={status} onDismiss={onDismissStatus} />}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<td className="hidden px-4 py-2.5 sm:table-cell">
|
||||
{running && stats ? (
|
||||
<Meter
|
||||
used={stats.cpu_used}
|
||||
@@ -204,7 +206,7 @@ function StackRow({
|
||||
<span className="text-slate-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<td className="hidden px-4 py-2.5 sm:table-cell">
|
||||
{running && stats ? (
|
||||
<Meter
|
||||
used={stats.mem_used}
|
||||
@@ -273,7 +275,7 @@ function StackRow({
|
||||
{confirming && (
|
||||
<ConfirmDialog
|
||||
title={`Delete stack “${stack.id}”?`}
|
||||
message="The stack is stopped and its compose files are removed. This cannot be undone."
|
||||
message="The stack's containers are removed and its folder is deleted from disk — the compose file, .env and any data folders inside it (named volumes are kept). This cannot be undone."
|
||||
confirmLabel="Delete stack"
|
||||
danger
|
||||
busy={deleting}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import { Card } from "@/components/ui";
|
||||
|
||||
/** What a read-only account sees on a page that only admins can use — instead
|
||||
* of the page itself, whose requests would all come back 403. */
|
||||
export function AdminOnly({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Card className="flex flex-col items-center gap-3 py-16 text-center">
|
||||
<ShieldCheck className="h-10 w-10 text-slate-400" />
|
||||
<h2 className="sp-heading text-lg">Admin only</h2>
|
||||
<p className="max-w-md text-sm text-slate-500">{children}</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -70,14 +70,18 @@ function Label<T>({ group }: { group: StackGroup<T> }) {
|
||||
</>
|
||||
);
|
||||
}
|
||||
// Labelled with a compose project that is no longer a stack: exactly where
|
||||
// things left behind by a deleted stack collect.
|
||||
// Labelled with a compose project that is no StackPilot stack: left behind
|
||||
// by a deleted stack, or started outside StackPilot (`docker compose up` in
|
||||
// some other folder) — which of the two cannot be told from the label.
|
||||
return (
|
||||
<>
|
||||
<Unlink className="h-4 w-4 shrink-0 text-amber-500" />
|
||||
<span className="font-semibold">{group.stackId}</span>
|
||||
<span className="rounded-pill border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[11px] font-semibold text-amber-600 dark:text-amber-400">
|
||||
stack removed
|
||||
<span
|
||||
title="Compose project without a StackPilot stack — left behind by a deleted stack, or started outside StackPilot"
|
||||
className="rounded-pill border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[11px] font-semibold text-amber-600 dark:text-amber-400"
|
||||
>
|
||||
not a stack
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -67,10 +67,14 @@ export function useDockerEvents() {
|
||||
}
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
const connect = async () => {
|
||||
// The token this effect started with may have expired by the time a
|
||||
// dropped socket reconnects; a refused one would just retry forever.
|
||||
const fresh = await useAuthStore.getState().freshAccessToken();
|
||||
if (closed || !fresh) return;
|
||||
const proto = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
socket = new WebSocket(
|
||||
`${proto}://${window.location.host}/ws/events?token=${encodeURIComponent(token)}`
|
||||
`${proto}://${window.location.host}/ws/events?token=${encodeURIComponent(fresh)}`
|
||||
);
|
||||
|
||||
socket.onopen = () => {
|
||||
@@ -94,12 +98,12 @@ export function useDockerEvents() {
|
||||
// token is short-lived and the API client refreshes it out from under us.
|
||||
socket.onclose = () => {
|
||||
if (closed) return;
|
||||
retry = window.setTimeout(connect, delay);
|
||||
retry = window.setTimeout(() => void connect(), delay);
|
||||
delay = Math.min(delay * 2, RECONNECT_MAX_MS);
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
void connect();
|
||||
|
||||
return () => {
|
||||
closed = true;
|
||||
|
||||
@@ -107,7 +107,7 @@ export function useStackActions() {
|
||||
begin(id, label);
|
||||
const t = toast.loading(`${label} ${id}…`);
|
||||
try {
|
||||
await streamCompose(path, token!, (progress) =>
|
||||
await streamCompose(path, (progress) =>
|
||||
setStatusMap((s) => (s[id] ? { ...s, [id]: { ...s[id], progress } } : s))
|
||||
);
|
||||
toast.success(`${label} ${id} ✓`, { id: t });
|
||||
|
||||
@@ -19,6 +19,10 @@ body {
|
||||
margin: 0;
|
||||
font-family: var(--sp-font);
|
||||
background: var(--sp-bg);
|
||||
/* The same text colour AppShell sets: dialogs are portalled onto <body>,
|
||||
outside it, and inherited the browser's black there — unreadable on the
|
||||
dark theme's dialog background. */
|
||||
@apply text-slate-900 dark:text-slate-100;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* stacks list, where an update needs to be legible at a glance in one row.
|
||||
*/
|
||||
import { DeployTracker, parseDeployLine } from "./deployProgress";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
|
||||
/** Compose emits status changes far faster than the UI needs to repaint. */
|
||||
const FLUSH_MS = 150;
|
||||
@@ -27,12 +28,16 @@ function snapshot(tracker: DeployTracker): ProgressSnapshot {
|
||||
* Opens `path`, reporting progress until compose finishes. Resolves on exit
|
||||
* code 0, rejects with the failure reason otherwise. The compose subprocess
|
||||
* keeps running server-side even if this socket is closed early.
|
||||
*
|
||||
* Fetches a fresh access token itself: an expired one is refused with 4401,
|
||||
* which used to surface as "connection closed before the update finished".
|
||||
*/
|
||||
export function streamCompose(
|
||||
export async function streamCompose(
|
||||
path: string,
|
||||
token: string,
|
||||
onProgress: (p: ProgressSnapshot) => void
|
||||
): Promise<void> {
|
||||
const token = await useAuthStore.getState().freshAccessToken();
|
||||
if (!token) throw new Error("your session has ended — sign in again");
|
||||
return new Promise((resolve, reject) => {
|
||||
const tracker = new DeployTracker();
|
||||
const proto = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
@@ -82,6 +87,15 @@ export function streamCompose(
|
||||
ws.onerror = () => settle(new Error("connection to the server failed"));
|
||||
// Reached normally after settle() closes the socket; the guard makes this
|
||||
// a no-op then, so it only fires for a stream that dropped mid-update.
|
||||
ws.onclose = () => settle(new Error("connection closed before the update finished"));
|
||||
ws.onclose = (ev) =>
|
||||
settle(
|
||||
new Error(
|
||||
ev.code === 4403
|
||||
? "admin rights required"
|
||||
: ev.code === 4401
|
||||
? "your session has ended — sign in again"
|
||||
: "connection closed before the update finished"
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Monaco from the bundle instead of a CDN.
|
||||
*
|
||||
* `@monaco-editor/react` fetches the editor from cdn.jsdelivr.net by default,
|
||||
* at run time: on a host without internet access, or behind a DNS filter that
|
||||
* blocks jsdelivr, the compose editor never got past "Loading…". Here it is
|
||||
* built into the chunks of the two pages that edit text (both lazy-loaded, so
|
||||
* nobody pays for it elsewhere): the API, the editor features (find, folding,
|
||||
* the diff view …) and the languages the file browser picks by extension.
|
||||
* Each language's tokenizer is itself a separate chunk, fetched on first use.
|
||||
*
|
||||
* Imported for its side effect, before any <Editor/> mounts.
|
||||
*/
|
||||
import { loader } from "@monaco-editor/react";
|
||||
import * as monaco from "monaco-editor/editor";
|
||||
import "monaco-editor/features/register.all";
|
||||
import "monaco-editor/languages/definitions/yaml/register";
|
||||
import "monaco-editor/languages/definitions/ini/register";
|
||||
import "monaco-editor/languages/definitions/shell/register";
|
||||
import "monaco-editor/languages/definitions/dockerfile/register";
|
||||
import "monaco-editor/languages/definitions/python/register";
|
||||
import "monaco-editor/languages/definitions/javascript/register";
|
||||
import "monaco-editor/languages/definitions/typescript/register";
|
||||
import "monaco-editor/languages/definitions/markdown/register";
|
||||
import "monaco-editor/languages/definitions/html/register";
|
||||
import "monaco-editor/languages/definitions/css/register";
|
||||
import "monaco-editor/languages/definitions/xml/register";
|
||||
import "monaco-editor/languages/definitions/sql/register";
|
||||
// JSON is the one with a language service (validation), and its own worker.
|
||||
// Both JSON paths spell out ".js": without it Vite fails to resolve them
|
||||
// through the package's "./*" export, the others resolve either way.
|
||||
import "monaco-editor/languages/features/json/register.js";
|
||||
import EditorWorker from "monaco-editor/editor/editor.worker?worker";
|
||||
import JsonWorker from "monaco-editor/language/json/json.worker.js?worker";
|
||||
|
||||
self.MonacoEnvironment = {
|
||||
getWorker: (_moduleId: string, label: string) =>
|
||||
label === "json" ? new JsonWorker() : new EditorWorker(),
|
||||
};
|
||||
|
||||
loader.config({ monaco });
|
||||
@@ -0,0 +1,43 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { parseServerTime, relativeTime } from "./utils";
|
||||
|
||||
describe("parseServerTime", () => {
|
||||
it("reads an offset-less API timestamp as UTC", () => {
|
||||
// What SQLite hands back for a UTC datetime: no zone at all.
|
||||
expect(parseServerTime("2026-10-15T09:56:04.321963").toISOString()).toBe(
|
||||
"2026-10-15T09:56:04.321Z"
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a timestamp that carries its zone alone", () => {
|
||||
expect(parseServerTime("2026-10-15T11:56:04+02:00").toISOString()).toBe(
|
||||
"2026-10-15T09:56:04.000Z"
|
||||
);
|
||||
expect(parseServerTime("2026-10-15T09:56:04.123456789Z").toISOString()).toBe(
|
||||
"2026-10-15T09:56:04.123Z"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("relativeTime", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-10-15T10:00:00Z"));
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("is not hours off for a UTC timestamp without an offset", () => {
|
||||
expect(relativeTime("2026-10-15T09:59:30")).toBe("just now");
|
||||
expect(relativeTime("2026-10-15T09:55:00")).toBe("5m ago");
|
||||
});
|
||||
|
||||
it("says when a time is still ahead", () => {
|
||||
// A schedule's next run used to read "next just now", whenever it was.
|
||||
expect(relativeTime("2026-10-15T13:00:00")).toBe("in 3h");
|
||||
expect(relativeTime("2026-10-25T10:00:00Z")).toBe("in 10d");
|
||||
});
|
||||
|
||||
it("does not print NaN for something unparseable", () => {
|
||||
expect(relativeTime("not a date")).toBe("—");
|
||||
});
|
||||
});
|
||||
@@ -21,15 +21,32 @@ export function formatUptime(seconds: number): string {
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A timestamp from the API as a Date.
|
||||
*
|
||||
* StackPilot's own times are UTC, but SQLite hands them back without an
|
||||
* offset, and JavaScript reads an offset-less date-time as *local* time — which
|
||||
* put every audit entry, sync and backup two hours in the past in German summer
|
||||
* time. Docker's own timestamps carry a zone and pass through unchanged.
|
||||
*/
|
||||
export function parseServerTime(iso: string): Date {
|
||||
const zoned = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(iso);
|
||||
return new Date(zoned || !iso.includes("T") ? iso : `${iso}Z`);
|
||||
}
|
||||
|
||||
/** "5m ago", or "in 5m" for a time still ahead (a schedule's next run). */
|
||||
export function relativeTime(iso: string): string {
|
||||
const then = new Date(iso).getTime();
|
||||
const then = parseServerTime(iso).getTime();
|
||||
if (Number.isNaN(then)) return "—";
|
||||
const diff = Date.now() - then;
|
||||
const s = Math.floor(diff / 1000);
|
||||
if (s < 60) return "just now";
|
||||
const ahead = diff < 0;
|
||||
const say = (v: string) => (ahead ? `in ${v}` : `${v} ago`);
|
||||
const s = Math.floor(Math.abs(diff) / 1000);
|
||||
if (s < 60) return ahead ? "in a moment" : "just now";
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
if (m < 60) return say(`${m}m`);
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
if (h < 24) return say(`${h}h`);
|
||||
const d = Math.floor(h / 24);
|
||||
return `${d}d ago`;
|
||||
return say(`${d}d`);
|
||||
}
|
||||
@@ -5,10 +5,18 @@ import { Card, Input, Spinner } from "@/components/ui";
|
||||
import api from "@/api/client";
|
||||
import type { AuditEntry } from "@/types";
|
||||
import { relativeTime } from "@/lib/utils";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import { AdminOnly } from "@/components/ui/AdminOnly";
|
||||
|
||||
const PAGE = 100;
|
||||
|
||||
export function Audit() {
|
||||
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
|
||||
if (!isAdmin) return <AdminOnly>The audit log is available to administrators only.</AdminOnly>;
|
||||
return <AuditLog />;
|
||||
}
|
||||
|
||||
function AuditLog() {
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [filter, setFilter] = useState("");
|
||||
|
||||
|
||||
@@ -24,11 +24,14 @@ import {
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import Editor from "@monaco-editor/react";
|
||||
// Before any editor mounts: points the wrapper at the bundled Monaco.
|
||||
import "@/lib/monaco";
|
||||
import { Button, Card, Input, Spinner } from "@/components/ui";
|
||||
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
||||
import { filesApi } from "@/api/files";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import { AdminOnly } from "@/components/ui/AdminOnly";
|
||||
import { useThemeStore } from "@/store/theme";
|
||||
import { formatBytes, relativeTime } from "@/lib/utils";
|
||||
import type { HostPathEntry } from "@/types";
|
||||
@@ -71,6 +74,11 @@ function crumbs(path: string): { label: string; path: string }[] {
|
||||
|
||||
export function Files() {
|
||||
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
|
||||
if (!isAdmin) return <AdminOnly>The file browser is available to administrators only.</AdminOnly>;
|
||||
return <FileBrowser isAdmin={isAdmin} />;
|
||||
}
|
||||
|
||||
function FileBrowser({ isAdmin }: { isAdmin: boolean }) {
|
||||
const qc = useQueryClient();
|
||||
const [path, setPath] = useState("/");
|
||||
// Persist "Show hidden" across reloads — otherwise an uploaded dotfile (.env)
|
||||
@@ -293,7 +301,7 @@ export function Files() {
|
||||
<Button variant="outline" onClick={refresh}>
|
||||
<RefreshCw className={isFetching ? "h-4 w-4 animate-spin" : "h-4 w-4"} /> Refresh
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
{isAdmin && !data?.virtual && (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setNewKind("dir")}>
|
||||
<FolderPlus className="h-4 w-4" /> Folder
|
||||
@@ -476,51 +484,54 @@ export function Files() {
|
||||
{e.mtime ? relativeTime(new Date(e.mtime * 1000).toISOString()) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
title={e.type === "dir" ? "Download as .zip" : "Download"}
|
||||
onClick={() => download(e)}
|
||||
className="rounded p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
>
|
||||
<Download className="h-4 w-4 text-slate-500" />
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<button
|
||||
title="Copy"
|
||||
onClick={() =>
|
||||
setClip({ src: join(path, e.name), name: e.name, type: e.type, mode: "copy" })
|
||||
}
|
||||
className="rounded p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
>
|
||||
<Copy className="h-4 w-4 text-slate-500" />
|
||||
</button>
|
||||
<button
|
||||
title="Cut (move)"
|
||||
onClick={() =>
|
||||
setClip({ src: join(path, e.name), name: e.name, type: e.type, mode: "cut" })
|
||||
}
|
||||
className="rounded p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
>
|
||||
<Scissors className="h-4 w-4 text-slate-500" />
|
||||
</button>
|
||||
<button
|
||||
title="Rename"
|
||||
onClick={() => setRenaming(e)}
|
||||
className="rounded p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
>
|
||||
<Pencil className="h-4 w-4 text-slate-500" />
|
||||
</button>
|
||||
<button
|
||||
title="Delete"
|
||||
onClick={() => setDeleting(e)}
|
||||
className="rounded p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* A folder on the way to a root is only a way through. */}
|
||||
{!data?.virtual && (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
title={e.type === "dir" ? "Download as .zip" : "Download"}
|
||||
onClick={() => download(e)}
|
||||
className="rounded p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
>
|
||||
<Download className="h-4 w-4 text-slate-500" />
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<button
|
||||
title="Copy"
|
||||
onClick={() =>
|
||||
setClip({ src: join(path, e.name), name: e.name, type: e.type, mode: "copy" })
|
||||
}
|
||||
className="rounded p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
>
|
||||
<Copy className="h-4 w-4 text-slate-500" />
|
||||
</button>
|
||||
<button
|
||||
title="Cut (move)"
|
||||
onClick={() =>
|
||||
setClip({ src: join(path, e.name), name: e.name, type: e.type, mode: "cut" })
|
||||
}
|
||||
className="rounded p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
>
|
||||
<Scissors className="h-4 w-4 text-slate-500" />
|
||||
</button>
|
||||
<button
|
||||
title="Rename"
|
||||
onClick={() => setRenaming(e)}
|
||||
className="rounded p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
>
|
||||
<Pencil className="h-4 w-4 text-slate-500" />
|
||||
</button>
|
||||
<button
|
||||
title="Delete"
|
||||
onClick={() => setDeleting(e)}
|
||||
className="rounded p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -18,6 +18,12 @@ import { useAuthStore } from "@/store/auth";
|
||||
function UpdateBadge({ row }: { row: ImageRow }) {
|
||||
const u = row.update;
|
||||
if (!u) return <span className="inline-flex items-center gap-1 text-xs text-slate-400"><HelpCircle className="h-3.5 w-3.5" /> not checked</span>;
|
||||
if (u.local)
|
||||
return (
|
||||
<span className="text-xs text-slate-400" title="Built on this host — there is no registry copy to check for updates">
|
||||
local build
|
||||
</span>
|
||||
);
|
||||
if (u.error) return <span className="text-xs text-amber-500">⚠ {u.error}</span>;
|
||||
if (u.update_available)
|
||||
return (
|
||||
@@ -83,10 +89,19 @@ function ImagesSection({ isAdmin }: { isAdmin: boolean }) {
|
||||
setScanning(true);
|
||||
const t = toast.loading("Scanning images… this runs the scanner per image");
|
||||
try {
|
||||
const r = await imagesApi.scanAll();
|
||||
const started = await imagesApi.startScanAll();
|
||||
// The sweep runs on the server; follow it until it is done.
|
||||
let status = started;
|
||||
while (status.active) {
|
||||
await new Promise((r) => window.setTimeout(r, 2000));
|
||||
status = await imagesApi.scanStatus();
|
||||
if (status.run !== started.run) break; // a newer sweep took over
|
||||
toast.loading(`Scanning images… ${status.done}/${status.total}`, { id: t });
|
||||
qc.invalidateQueries({ queryKey: ["image-scans"] });
|
||||
}
|
||||
await qc.invalidateQueries({ queryKey: ["image-scans"] });
|
||||
toast.success(
|
||||
`Scanned ${r.scanned} image(s)${r.failed ? `, ${r.failed} failed` : ""}`,
|
||||
`Scanned ${status.scanned} image(s)${status.failed ? `, ${status.failed} failed` : ""}`,
|
||||
{ id: t }
|
||||
);
|
||||
} catch (e) {
|
||||
|
||||
@@ -250,15 +250,18 @@ function NetworkRows({
|
||||
{isAdmin && (
|
||||
<td className="px-4 py-2 text-right">
|
||||
{!n.is_default && (
|
||||
// Disabled while in use: Docker refuses to remove it anyway, and
|
||||
// the red bin offered a delete that could only fail.
|
||||
<button
|
||||
title={n.in_use ? "In use — disconnect containers first" : "Delete"}
|
||||
title={n.in_use ? "In use — disconnect its containers first" : "Delete"}
|
||||
disabled={n.in_use}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className="rounded-lg p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
className="rounded-lg p-1.5 hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent dark:hover:bg-slate-700"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
<Trash2 className={n.in_use ? "h-4 w-4 text-slate-400" : "h-4 w-4 text-red-500"} />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
Send,
|
||||
Trash2,
|
||||
Users as UsersIcon,
|
||||
ShieldCheck,
|
||||
Power,
|
||||
RefreshCw,
|
||||
HardDrive,
|
||||
@@ -40,21 +39,14 @@ import { stacksApi } from "@/api/stacks";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
import { relativeTime } from "@/lib/utils";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import { AdminOnly } from "@/components/ui/AdminOnly";
|
||||
import type { User } from "@/types";
|
||||
|
||||
export function Settings() {
|
||||
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<Card className="flex flex-col items-center gap-3 py-16 text-center">
|
||||
<ShieldCheck className="h-10 w-10 text-slate-400" />
|
||||
<h2 className="sp-heading text-lg">Admin only</h2>
|
||||
<p className="max-w-md text-sm text-slate-500">
|
||||
Settings are available to administrators only.
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
return <AdminOnly>Settings are available to administrators only.</AdminOnly>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -514,6 +506,11 @@ const FIELDS: Record<string, { key: string; label: string; secret?: boolean; are
|
||||
{ key: "password", label: "Password", secret: true },
|
||||
{ key: "private_key", label: "Private key (optional, instead of password)", secret: true, area: true },
|
||||
{ key: "path", label: "Remote directory", placeholder: "/backups/stackpilot" },
|
||||
{
|
||||
key: "host_key",
|
||||
label: "Host key (empty = trust the key seen on the first connection)",
|
||||
placeholder: "ssh-ed25519 AAAA… or SHA256:… (ssh-keyscan HOST | ssh-keygen -lf -)",
|
||||
},
|
||||
],
|
||||
s3: [
|
||||
{ key: "endpoint_url", label: "Endpoint URL (blank = AWS)", placeholder: "https://minio.example:9000" },
|
||||
@@ -808,8 +805,12 @@ function DestinationRow({ dest, onChange }: { dest: BackupDestination; onChange:
|
||||
const [editing, setEditing] = useState(false);
|
||||
const test = useMutation({
|
||||
mutationFn: () => destinationsApi.test(dest.id),
|
||||
onSuccess: (r) =>
|
||||
r.ok ? toast.success("Reachable") : toast.error(r.error || "Connection failed"),
|
||||
onSuccess: (r) => {
|
||||
if (!r.ok) return toast.error(r.error || "Connection failed");
|
||||
toast.success("Reachable", r.host_key ? { description: `Host key ${r.host_key}` } : undefined);
|
||||
// A first SFTP connection pins the server's key; show it in the form.
|
||||
onChange();
|
||||
},
|
||||
onError: (e) => toast.error(apiErrorMessage(e)),
|
||||
});
|
||||
const remove = useMutation({
|
||||
|
||||
@@ -107,15 +107,15 @@ export function StackDetail() {
|
||||
<ActionStatusList statuses={actions.statuses} onDismiss={actions.dismissStatus} />
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 border-b border-slate-200 dark:border-slate-700">
|
||||
<div className="flex gap-1 overflow-x-auto border-b border-slate-200 dark:border-slate-700">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={
|
||||
tab === t
|
||||
? "border-b-2 border-accent px-4 py-2 text-sm font-medium text-accent dark:border-accent-dark dark:text-accent-dark"
|
||||
: "px-4 py-2 text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
|
||||
? "shrink-0 whitespace-nowrap border-b-2 border-accent px-4 py-2 text-sm font-medium text-accent dark:border-accent-dark dark:text-accent-dark"
|
||||
: "shrink-0 whitespace-nowrap px-4 py-2 text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
|
||||
}
|
||||
>
|
||||
{t}
|
||||
@@ -132,7 +132,7 @@ export function StackDetail() {
|
||||
/>
|
||||
)}
|
||||
{tab === "Logs" && <LogViewer stackId={id} />}
|
||||
{tab === "Environment" && <EnvView env={data.env} />}
|
||||
{tab === "Environment" && <EnvView env={data.env} isAdmin={isAdmin} />}
|
||||
{tab === "Compose" && <ComposeView yaml={data.yaml} />}
|
||||
{tab === "Git" && <GitPanel stackId={id} isAdmin={isAdmin} />}
|
||||
{tab === "Secrets" && (
|
||||
@@ -174,13 +174,16 @@ function Overview({
|
||||
);
|
||||
}
|
||||
|
||||
function EnvView({ env }: { env: string }) {
|
||||
function EnvView({ env, isAdmin }: { env: string; isAdmin: boolean }) {
|
||||
return (
|
||||
<Card className="h-full overflow-auto">
|
||||
{env ? (
|
||||
<pre className="whitespace-pre-wrap font-mono text-xs">{env}</pre>
|
||||
) : (
|
||||
) : isAdmin ? (
|
||||
<p className="text-sm text-slate-500">No .env file for this stack.</p>
|
||||
) : (
|
||||
// The server leaves it out for this role; "no .env file" was untrue.
|
||||
<p className="text-sm text-slate-500">The .env file is shown to administrators only.</p>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
@@ -205,8 +208,15 @@ function DeleteStackButton({ stackId }: { stackId: string }) {
|
||||
setBusy(true);
|
||||
const t = toast.loading(`Deleting ${stackId}…`);
|
||||
try {
|
||||
await stacksApi.remove(stackId, deleteFiles);
|
||||
toast.success(`Deleted ${stackId}`, { id: t });
|
||||
const res = await stacksApi.remove(stackId, deleteFiles);
|
||||
if (res.kept?.length) {
|
||||
toast.success(`Deleted ${stackId}`, {
|
||||
id: t,
|
||||
description: `Left in place (another filesystem is mounted there): ${res.kept.join(", ")}`,
|
||||
});
|
||||
} else {
|
||||
toast.success(`Deleted ${stackId}`, { id: t });
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: ["stacks"] });
|
||||
navigate("/stacks");
|
||||
} catch (e) {
|
||||
@@ -223,7 +233,7 @@ function DeleteStackButton({ stackId }: { stackId: string }) {
|
||||
{open && (
|
||||
<ConfirmDialog
|
||||
title={`Delete stack “${stackId}”?`}
|
||||
message="The stack is stopped and removed. This cannot be undone."
|
||||
message="The stack's containers are stopped and removed. Named volumes are kept. This cannot be undone."
|
||||
confirmLabel="Delete stack"
|
||||
danger
|
||||
busy={busy}
|
||||
@@ -238,9 +248,12 @@ function DeleteStackButton({ stackId }: { stackId: string }) {
|
||||
className="mt-0.5 h-4 w-4"
|
||||
/>
|
||||
<span>
|
||||
Also delete the compose files from disk
|
||||
Also delete the stack folder from disk
|
||||
<span className="block text-xs text-slate-500">
|
||||
Uncheck to keep <code>{stackId}/</code> on disk (it can be re-discovered later).
|
||||
Everything in <code>{stackId}/</code> goes — the compose file, <code>.env</code> and
|
||||
any data folders inside it (e.g. <code>./config</code>). Shares or disks mounted
|
||||
inside are left alone. Uncheck to keep the folder; StackPilot will then list it
|
||||
again as a stopped stack.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useBlocker, useNavigate, useParams } from "react-router-dom";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import Editor, { DiffEditor } from "@monaco-editor/react";
|
||||
// Before any editor mounts: points the wrapper at the bundled Monaco.
|
||||
import "@/lib/monaco";
|
||||
import { Rocket, Save, Wand2, FileCode, CheckCircle2, GitCompare, X } from "lucide-react";
|
||||
import { Button, Card, Input } from "@/components/ui";
|
||||
import { EditorHelperPanel } from "@/components/stacks/EditorHelperPanel";
|
||||
@@ -9,12 +11,15 @@ import { IconPicker } from "@/components/stacks/IconPicker";
|
||||
import { StackIcon } from "@/components/ui/StackIcon";
|
||||
import { EnvEditor } from "@/components/env/EnvEditor";
|
||||
import { PortConflictDialog } from "@/components/stacks/PortConflictDialog";
|
||||
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
||||
import { DeployConsole } from "@/components/stacks/DeployConsole";
|
||||
import { stacksApi } from "@/api/stacks";
|
||||
import { editorApi } from "@/api/editor";
|
||||
import { portsApi, type PortConflict } from "@/api/ports";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
import { useThemeStore } from "@/store/theme";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import { AdminOnly } from "@/components/ui/AdminOnly";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const STARTER = `services:
|
||||
@@ -26,6 +31,12 @@ const STARTER = `services:
|
||||
`;
|
||||
|
||||
export function StackEditor() {
|
||||
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
|
||||
if (!isAdmin) return <AdminOnly>Creating and editing stacks is for administrators only.</AdminOnly>;
|
||||
return <StackEditorForm />;
|
||||
}
|
||||
|
||||
function StackEditorForm() {
|
||||
const { id } = useParams();
|
||||
const isNew = !id;
|
||||
const navigate = useNavigate();
|
||||
@@ -59,15 +70,57 @@ export function StackEditor() {
|
||||
});
|
||||
|
||||
|
||||
// Filled from the server once per stack, never again while editing. The
|
||||
// stack query refetches on every container event (and a crash-looping
|
||||
// service emits them constantly), and each refetch used to replace whatever
|
||||
// had been typed but not saved — on exactly the stack you were trying to fix.
|
||||
// What the form held when it was last loaded or saved. A ref, like the
|
||||
// current values below, because the navigation right after a save is checked
|
||||
// against a render from before it.
|
||||
const saved = useRef({ name: "", description: "", yaml: STARTER, env: "" });
|
||||
const loadedFor = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (existing.data) {
|
||||
setName(existing.data.name);
|
||||
setDescription(existing.data.description ?? "");
|
||||
if (existing.data && id && loadedFor.current !== id) {
|
||||
loadedFor.current = id;
|
||||
const loaded = {
|
||||
name: existing.data.name,
|
||||
description: existing.data.description ?? "",
|
||||
yaml: existing.data.yaml || STARTER,
|
||||
env: existing.data.env || "",
|
||||
};
|
||||
saved.current = loaded;
|
||||
setName(loaded.name);
|
||||
setDescription(loaded.description);
|
||||
setIcon(existing.data.icon ?? null);
|
||||
setYaml(existing.data.yaml || STARTER);
|
||||
setEnv(existing.data.env || "");
|
||||
setYaml(loaded.yaml);
|
||||
setEnv(loaded.env);
|
||||
}
|
||||
}, [existing.data]);
|
||||
}, [existing.data, id]);
|
||||
|
||||
const current = useRef({ name, description, yaml, env });
|
||||
current.current = { name, description, yaml, env };
|
||||
const unsaved = useCallback(() => {
|
||||
const a = current.current;
|
||||
const b = saved.current;
|
||||
return a.name !== b.name || a.description !== b.description || a.yaml !== b.yaml || a.env !== b.env;
|
||||
}, []);
|
||||
|
||||
// Leaving with unsaved edits asks first: a click on the top bar used to drop
|
||||
// a half-written compose file without a word.
|
||||
const blocker = useBlocker(
|
||||
({ currentLocation, nextLocation }) =>
|
||||
currentLocation.pathname !== nextLocation.pathname && unsaved()
|
||||
);
|
||||
useEffect(() => {
|
||||
const onBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (unsaved()) {
|
||||
e.preventDefault();
|
||||
e.returnValue = "";
|
||||
}
|
||||
};
|
||||
window.addEventListener("beforeunload", onBeforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", onBeforeUnload);
|
||||
}, [unsaved]);
|
||||
|
||||
const save = async (deploy: boolean) => {
|
||||
if (isNew && !name.trim()) {
|
||||
@@ -95,6 +148,7 @@ export function StackEditor() {
|
||||
} else {
|
||||
await stacksApi.update(id!, { name, description, icon: iconField, yaml, env });
|
||||
}
|
||||
saved.current = { name, description, yaml, env };
|
||||
if (iconFile && stackId) {
|
||||
await stacksApi.uploadIcon(stackId, iconFile);
|
||||
setIconFile(null);
|
||||
@@ -154,6 +208,9 @@ export function StackEditor() {
|
||||
id: id ?? "",
|
||||
name,
|
||||
icon: iconFile ? "custom:pending" : icon,
|
||||
// What "automatic" resolves to for this stack (the app recognised from its
|
||||
// images), so the editor previews the icon the stack list shows.
|
||||
auto_icon: existing.data?.auto_icon,
|
||||
};
|
||||
const iconStatus = existing.data?.status ?? "stopped";
|
||||
|
||||
@@ -161,7 +218,7 @@ export function StackEditor() {
|
||||
setValidating(true);
|
||||
setValidation(null);
|
||||
try {
|
||||
const result = await editorApi.validate(yaml, env);
|
||||
const result = await editorApi.validate(yaml, env, id);
|
||||
setValidation(result);
|
||||
if (result.ok) toast.success("Compose config is valid");
|
||||
} catch (err) {
|
||||
@@ -188,7 +245,9 @@ export function StackEditor() {
|
||||
// auto and leave the editors at their intrinsic (tiny) size. Size against
|
||||
// the viewport instead, minus the top bar (pt-[76px]) and the page's
|
||||
// bottom padding (pb-10), so the editor fills whatever screen the user has.
|
||||
<div className="flex h-[calc(100vh-116px)] min-h-[420px] flex-col space-y-3">
|
||||
// On a phone the page scrolls instead: editor and helper panel stack, and
|
||||
// squeezed side by side the editor was left about 25px wide.
|
||||
<div className="flex min-h-[420px] flex-col space-y-3 md:h-[calc(100vh-116px)]">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@@ -206,7 +265,7 @@ export function StackEditor() {
|
||||
disabled={!isNew}
|
||||
/>
|
||||
<Input
|
||||
className="max-w-md flex-1"
|
||||
className="min-w-[14rem] max-w-md flex-1"
|
||||
placeholder="Description (optional)"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
@@ -235,13 +294,20 @@ export function StackEditor() {
|
||||
)}
|
||||
|
||||
{convertOpen && (
|
||||
<Card className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="docker run -d --name web -p 8080:80 nginx:alpine"
|
||||
<Card className="flex flex-col gap-2 sm:flex-row sm:items-start">
|
||||
{/* A textarea: commands from a README come split over lines with
|
||||
"\", and a one-line input dropped the newlines on paste. */}
|
||||
<textarea
|
||||
rows={3}
|
||||
placeholder={"docker run -d --name web \\\n -p 8080:80 \\\n nginx:alpine"}
|
||||
value={runCmd}
|
||||
onChange={(e) => setRunCmd(e.target.value)}
|
||||
spellCheck={false}
|
||||
className="min-h-[4.5rem] w-full flex-1 rounded-lg border border-slate-300 bg-white px-3 py-2 font-mono text-xs text-slate-900 outline-none focus:border-accent focus:ring-1 focus:ring-accent dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100 dark:focus:border-accent-dark"
|
||||
/>
|
||||
<Button onClick={convert}>Convert</Button>
|
||||
<Button onClick={convert} disabled={!runCmd.trim()}>
|
||||
Convert
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -254,11 +320,16 @@ export function StackEditor() {
|
||||
</TabBtn>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 gap-3">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 md:flex-row">
|
||||
{/* Editor */}
|
||||
<div className="min-h-0 flex-1 overflow-hidden rounded-lg border border-slate-200 dark:border-slate-700">
|
||||
<div className="h-[60vh] min-h-0 flex-none overflow-hidden rounded-lg border border-slate-200 dark:border-slate-700 md:h-auto md:flex-1">
|
||||
{tab === "compose" && showDiff ? (
|
||||
<DiffEditor
|
||||
// Without these the wrapper disposes the models before the diff
|
||||
// widget lets go of them, and closing the diff threw "TextModel
|
||||
// got disposed before DiffEditorWidget model got reset".
|
||||
keepCurrentOriginalModel
|
||||
keepCurrentModifiedModel
|
||||
height="100%"
|
||||
language="yaml"
|
||||
theme={theme === "dark" ? "vs-dark" : "light"}
|
||||
@@ -289,7 +360,7 @@ export function StackEditor() {
|
||||
|
||||
{/* Helper panel (Volumes / GPU / Devices) */}
|
||||
{tab === "compose" && !showDiff && (
|
||||
<div className="w-[38%] min-w-[320px] overflow-hidden rounded-lg border border-slate-200 dark:border-slate-700">
|
||||
<div className="overflow-hidden rounded-lg border border-slate-200 dark:border-slate-700 md:w-[38%] md:min-w-[320px]">
|
||||
<EditorHelperPanel yaml={yaml} onYaml={setYaml} />
|
||||
</div>
|
||||
)}
|
||||
@@ -335,6 +406,17 @@ export function StackEditor() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{blocker.state === "blocked" && (
|
||||
<ConfirmDialog
|
||||
title="Discard unsaved changes?"
|
||||
message="The compose file, .env or details have changes that are not saved yet."
|
||||
confirmLabel="Discard"
|
||||
danger
|
||||
onConfirm={() => blocker.proceed()}
|
||||
onCancel={() => blocker.reset()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deployId && (
|
||||
<DeployConsole
|
||||
stackId={deployId}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AxiosError, AxiosHeaders } from "axios";
|
||||
|
||||
// The store talks through its own axios instance; hand it a fake one.
|
||||
const post = vi.fn();
|
||||
const get = vi.fn();
|
||||
vi.mock("axios", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("axios")>();
|
||||
return {
|
||||
...actual,
|
||||
default: { ...actual.default, create: () => ({ post, get }), isAxiosError: actual.isAxiosError },
|
||||
};
|
||||
});
|
||||
|
||||
const { useAuthStore, backendUnavailable } = await import("./auth");
|
||||
|
||||
function httpError(status: number) {
|
||||
return new AxiosError("failed", "ERR_BAD_RESPONSE", undefined, undefined, {
|
||||
status,
|
||||
statusText: "",
|
||||
data: {},
|
||||
headers: {},
|
||||
config: { headers: new AxiosHeaders() },
|
||||
});
|
||||
}
|
||||
|
||||
const token = { data: { access_token: "fresh", refresh_token: "" } };
|
||||
const me = { data: { id: 1, username: "admin", role: "admin" } };
|
||||
|
||||
beforeEach(() => {
|
||||
post.mockReset();
|
||||
get.mockReset();
|
||||
useAuthStore.setState({ accessToken: null, user: null, ready: false, waiting: false });
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe("backendUnavailable", () => {
|
||||
it("is a proxy error or no answer, not a refusal", () => {
|
||||
expect(backendUnavailable(httpError(502))).toBe(true);
|
||||
expect(backendUnavailable(httpError(504))).toBe(true);
|
||||
expect(backendUnavailable(new AxiosError("Network Error", "ERR_NETWORK"))).toBe(true);
|
||||
expect(backendUnavailable(httpError(401))).toBe(false);
|
||||
expect(backendUnavailable(new Error("x"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("restore", () => {
|
||||
it("waits for a backend that is still starting instead of signing out", async () => {
|
||||
vi.useFakeTimers();
|
||||
post.mockRejectedValueOnce(httpError(502)).mockRejectedValueOnce(httpError(502)).mockResolvedValue(token);
|
||||
get.mockResolvedValue(me);
|
||||
const done = useAuthStore.getState().restore();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(useAuthStore.getState().waiting).toBe(true);
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
await done;
|
||||
const state = useAuthStore.getState();
|
||||
expect(state.accessToken).toBe("fresh");
|
||||
expect(state.user?.username).toBe("admin");
|
||||
expect(state.ready).toBe(true);
|
||||
expect(state.waiting).toBe(false);
|
||||
});
|
||||
|
||||
it("goes to the login screen when the backend says no", async () => {
|
||||
post.mockRejectedValue(httpError(401));
|
||||
await useAuthStore.getState().restore();
|
||||
expect(post).toHaveBeenCalledTimes(1);
|
||||
expect(useAuthStore.getState().accessToken).toBeNull();
|
||||
expect(useAuthStore.getState().ready).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("refresh", () => {
|
||||
it("keeps the session through a restart", async () => {
|
||||
useAuthStore.setState({ accessToken: "old", user: me.data as never });
|
||||
post.mockRejectedValue(httpError(502));
|
||||
expect(await useAuthStore.getState().refresh()).toBeNull();
|
||||
expect(useAuthStore.getState().accessToken).toBe("old");
|
||||
});
|
||||
|
||||
it("ends it when the refresh token is refused", async () => {
|
||||
useAuthStore.setState({ accessToken: "old", user: me.data as never });
|
||||
post.mockRejectedValue(httpError(401));
|
||||
await useAuthStore.getState().refresh();
|
||||
expect(useAuthStore.getState().accessToken).toBeNull();
|
||||
expect(useAuthStore.getState().user).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -20,10 +20,21 @@ interface AuthState {
|
||||
user: User | null;
|
||||
/** False until the initial cookie-based restore has settled. */
|
||||
ready: boolean;
|
||||
/** The restore is waiting for a backend that is not answering yet. */
|
||||
waiting: boolean;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
setup: (username: string, password: string) => Promise<void>;
|
||||
restore: () => Promise<void>;
|
||||
refresh: () => Promise<string | null>;
|
||||
/**
|
||||
* The access token, refreshed first if it is about to expire.
|
||||
*
|
||||
* For WebSockets, which carry the token in the URL and are refused (4401)
|
||||
* with an expired one. HTTP calls refresh on a 401 and retry; a socket gets
|
||||
* no such second chance — an update started after an idle hour just failed
|
||||
* with "connection closed".
|
||||
*/
|
||||
freshAccessToken: () => Promise<string | null>;
|
||||
fetchMe: () => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
signOutEverywhere: () => Promise<void>;
|
||||
@@ -32,6 +43,38 @@ interface AuthState {
|
||||
// Raw client without interceptors (avoids refresh loops).
|
||||
const raw = axios.create({ baseURL: "/", withCredentials: true });
|
||||
|
||||
/** Seconds before expiry at which a token counts as stale for a new socket. */
|
||||
const TOKEN_MARGIN_S = 60;
|
||||
|
||||
/** The `exp` claim of a JWT, in seconds — or null if it cannot be read. */
|
||||
function tokenExpiry(token: string): number | null {
|
||||
try {
|
||||
const part = token.split(".")[1];
|
||||
const json = atob(part.replace(/-/g, "+").replace(/_/g, "/"));
|
||||
const exp = JSON.parse(json).exp;
|
||||
return typeof exp === "number" ? exp : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The backend is down or restarting — the proxy answering 502–504, or no answer
|
||||
* at all. That is not "signed out", and treating it as such sent everyone to
|
||||
* the login screen whenever the page was reloaded during a restart (an update,
|
||||
* the self-update) or a token ran out while it lasted.
|
||||
*/
|
||||
export function backendUnavailable(err: unknown): boolean {
|
||||
if (!axios.isAxiosError(err)) return false;
|
||||
if (!err.response) return true;
|
||||
return [502, 503, 504].includes(err.response.status);
|
||||
}
|
||||
|
||||
/** How long the boot-time restore waits for the backend before giving up. */
|
||||
const RESTORE_PATIENCE_MS = 120_000;
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// Before 0.46.0 both tokens were persisted here by zustand/persist. Upgrading
|
||||
// users still have a valid 30-day refresh token sitting in localStorage, which
|
||||
// is exactly what this change exists to remove — so drop it on first load.
|
||||
@@ -45,6 +88,7 @@ export const useAuthStore = create<AuthState>()((set, get) => ({
|
||||
accessToken: null,
|
||||
user: null,
|
||||
ready: false,
|
||||
waiting: false,
|
||||
|
||||
login: async (username, password) => {
|
||||
const { data } = await raw.post<TokenPair>("/api/auth/login", { username, password });
|
||||
@@ -64,15 +108,26 @@ export const useAuthStore = create<AuthState>()((set, get) => ({
|
||||
|
||||
// Called once on boot: if the refresh cookie is still good we come back
|
||||
// signed in, otherwise we land on the login screen.
|
||||
// A backend that is still starting is waited for, not taken as an answer.
|
||||
restore: async () => {
|
||||
try {
|
||||
const token = await get().refresh();
|
||||
if (token) await get().fetchMe();
|
||||
} catch {
|
||||
/* not signed in */
|
||||
} finally {
|
||||
set({ ready: true });
|
||||
const giveUp = Date.now() + RESTORE_PATIENCE_MS;
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
const { data } = await raw.post<TokenPair>("/api/auth/refresh", {});
|
||||
set({ accessToken: data.access_token });
|
||||
await get().fetchMe();
|
||||
break;
|
||||
} catch (err) {
|
||||
if (backendUnavailable(err) && Date.now() < giveUp) {
|
||||
set({ waiting: true });
|
||||
await sleep(Math.min(1000 * 2 ** attempt, 5000));
|
||||
continue;
|
||||
}
|
||||
set({ accessToken: null, user: null }); // not signed in
|
||||
break;
|
||||
}
|
||||
}
|
||||
set({ ready: true, waiting: false });
|
||||
},
|
||||
|
||||
refresh: async () => {
|
||||
@@ -80,12 +135,23 @@ export const useAuthStore = create<AuthState>()((set, get) => ({
|
||||
const { data } = await raw.post<TokenPair>("/api/auth/refresh", {});
|
||||
set({ accessToken: data.access_token });
|
||||
return data.access_token;
|
||||
} catch {
|
||||
set({ accessToken: null, user: null });
|
||||
} catch (err) {
|
||||
// Only an answer from the backend ends the session; while it restarts
|
||||
// the next request tries again.
|
||||
if (!backendUnavailable(err)) set({ accessToken: null, user: null });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
freshAccessToken: async () => {
|
||||
const token = get().accessToken;
|
||||
if (token) {
|
||||
const exp = tokenExpiry(token);
|
||||
if (exp === null || exp - Date.now() / 1000 > TOKEN_MARGIN_S) return token;
|
||||
}
|
||||
return get().refresh();
|
||||
},
|
||||
|
||||
fetchMe: async () => {
|
||||
const token = get().accessToken;
|
||||
if (!token) return;
|
||||
|
||||
@@ -173,6 +173,9 @@ export interface HostPathResult {
|
||||
parent: string | null;
|
||||
roots: string[];
|
||||
entries: HostPathEntry[];
|
||||
/** A folder above the allowed roots, listing only the way to them: nothing
|
||||
* can be created or changed in it. */
|
||||
virtual?: boolean;
|
||||
}
|
||||
|
||||
export interface TokenPair {
|
||||
|
||||
Reference in new issue
Block a user