Initial curated DockDeck source from 26772208f608782e88c736dc70878e7b5abd3916
No files matched your search
@@ -0,0 +1,9 @@
|
||||
.git
|
||||
node_modules
|
||||
dist
|
||||
coverage
|
||||
playwright-report
|
||||
test-results
|
||||
data
|
||||
.env*
|
||||
*.log
|
||||
@@ -0,0 +1,12 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
@@ -0,0 +1,118 @@
|
||||
# Application (local-only by default)
|
||||
HOST=127.0.0.1
|
||||
PORT=3000
|
||||
DATABASE_PATH=./data/dockdeck.db
|
||||
INTEGRATION_ENV_PATH=./data/integrations.env
|
||||
POLLING_INTERVAL_MS=30000
|
||||
|
||||
# Optional operator authentication (HTTP Basic). Off by default - set both
|
||||
# to require login. This dashboard holds read credentials for several other
|
||||
# services (see README), so enabling this is strongly recommended for any
|
||||
# deployment reachable beyond a fully trusted single-user LAN.
|
||||
# Generate DOCKDECK_OPERATOR_PASSWORD_HASH without placing the password in argv:
|
||||
# printf '%s' "$DOCKDECK_PASSWORD" | node scripts/hash-operator-password.mjs
|
||||
DOCKDECK_OPERATOR_USER=
|
||||
DOCKDECK_OPERATOR_PASSWORD_HASH=
|
||||
|
||||
# Docker Compose publication. Keep loopback unless an authenticated trusted-LAN
|
||||
# or HTTPS reverse-proxy deployment is intentional.
|
||||
DOCKDECK_BIND_ADDRESS=127.0.0.1
|
||||
DOCKDECK_PORT=1218
|
||||
|
||||
# Optional image identity overrides for manual Compose builds. Controller
|
||||
# metadata supplies GITEA_COMMIT_SHA and, once AppOps exports it, APPOPS_BUILD_DATE.
|
||||
# Otherwise set DOCKDECK_BUILD_DATE as canonical UTC ISO with optional .SSS;
|
||||
# full-revision builds fail closed when only the development epoch is available.
|
||||
DOCKDECK_BUILD_REVISION=
|
||||
DOCKDECK_BUILD_DATE=
|
||||
DOCKDECK_BUILD_SOURCE=jens/dockdeck
|
||||
DOCKDECK_INTERNAL_SUBNET=172.31.240.0/28
|
||||
DOCKDECK_EGRESS_SUBNET=172.31.240.16/28
|
||||
|
||||
# Discovery. MOCK_DISCOVERY is intended for local development and tests only.
|
||||
MOCK_DISCOVERY=true
|
||||
APPOPS_URL=
|
||||
DOCKER_PROXY_URL=
|
||||
UNRAID_TEMPLATES_PATH=/unraid-templates
|
||||
UNRAID_ICONS_SOURCE_PATH=/unraid-icons-source
|
||||
UNRAID_ICONS_PATH=/unraid-icons-cache
|
||||
UNRAID_ICONS_DIR=/boot/config/plugins/dockerMan/images
|
||||
UNRAID_HOST=host.local
|
||||
UNRAID_URL=http://host.local:5000
|
||||
UNRAID_RUNTIME_PATH=/unraid-runtime
|
||||
UNRAID_HWMON_PATH=/host-hwmon
|
||||
UNRAID_CPUINFO_PATH=/host-proc/cpuinfo
|
||||
UNRAID_MEMINFO_PATH=/host-proc/meminfo
|
||||
UNRAID_UPS_STATUS_PATH=
|
||||
|
||||
# Optional read-only Nginx Proxy Manager connection. Keep credentials server-side.
|
||||
NPM_URL=
|
||||
NPM_TOKEN=
|
||||
NPM_USERNAME=
|
||||
NPM_PASSWORD=
|
||||
|
||||
# Optional provider-aware widget APIs. Use dedicated read-only or minimum-scope
|
||||
# credentials where the provider supports them. Values never reach the browser.
|
||||
ADGUARD_URL=
|
||||
ADGUARD_USERNAME=
|
||||
ADGUARD_PASSWORD=
|
||||
PLEX_URL=
|
||||
PLEX_TOKEN=
|
||||
IMMICH_URL=
|
||||
IMMICH_API_KEY=
|
||||
HOME_ASSISTANT_URL=
|
||||
HOME_ASSISTANT_TOKEN=
|
||||
OLLAMA_URL=
|
||||
GLANCES_URL=
|
||||
TAUTULLI_URL=
|
||||
TAUTULLI_API_KEY=
|
||||
PAPERLESS_URL=
|
||||
PAPERLESS_TOKEN=
|
||||
SONARR_URL=
|
||||
SONARR_API_KEY=
|
||||
RADARR_URL=
|
||||
RADARR_API_KEY=
|
||||
LIDARR_URL=
|
||||
LIDARR_API_KEY=
|
||||
GITEA_WIDGET_URL=
|
||||
GITEA_WIDGET_TOKEN=
|
||||
JELLYFIN_URL=
|
||||
JELLYFIN_API_KEY=
|
||||
SEERR_URL=
|
||||
SEERR_API_KEY=
|
||||
PROWLARR_URL=
|
||||
PROWLARR_API_KEY=
|
||||
AUTHENTIK_URL=
|
||||
AUTHENTIK_TOKEN=
|
||||
PORTAINER_URL=
|
||||
PORTAINER_API_KEY=
|
||||
GRAFANA_URL=
|
||||
GRAFANA_TOKEN=
|
||||
PROMETHEUS_URL=
|
||||
NEXTCLOUD_URL=
|
||||
NEXTCLOUD_USERNAME=
|
||||
NEXTCLOUD_APP_PASSWORD=
|
||||
AUDIOBOOKSHELF_URL=
|
||||
AUDIOBOOKSHELF_TOKEN=
|
||||
NETDATA_URL=
|
||||
PEERTUBE_URL=
|
||||
|
||||
# Apps whose native authentication requires non-GET requests can use a tiny
|
||||
# read-only bridge returning { "summary": "...", "stats": [{ "label": "...", "value": "..." }] }.
|
||||
DELUGE_METRICS_URL=
|
||||
DELUGE_METRICS_TOKEN=
|
||||
QBITTORRENT_METRICS_URL=
|
||||
QBITTORRENT_METRICS_TOKEN=
|
||||
JDOWNLOADER_METRICS_URL=
|
||||
JDOWNLOADER_METRICS_TOKEN=
|
||||
TDARR_METRICS_URL=
|
||||
TDARR_METRICS_TOKEN=
|
||||
BAZARR_METRICS_URL=
|
||||
BAZARR_METRICS_TOKEN=
|
||||
VAULTWARDEN_METRICS_URL=
|
||||
VAULTWARDEN_METRICS_TOKEN=
|
||||
|
||||
# Optional one-time private repository initialization
|
||||
GITEA_URL=
|
||||
GITEA_OWNER=
|
||||
GITEA_TOKEN=
|
||||
@@ -0,0 +1,5 @@
|
||||
* text=auto eol=lf
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.webp binary
|
||||
@@ -0,0 +1,15 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
playwright-report/
|
||||
test-results/
|
||||
data/*.db
|
||||
data/*.db-*
|
||||
data/wallpaper.*
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
.DS_Store
|
||||
.codex-input/
|
||||
@@ -0,0 +1,12 @@
|
||||
# Contributing
|
||||
|
||||
Keep DockDeck changes portable, least-privileged and free from operator-specific infrastructure.
|
||||
|
||||
- Branch from the protected default branch.
|
||||
- Use synthetic container inventories, hostnames and launch URLs in tests and documentation.
|
||||
- Do not commit `.env` files, Docker socket credentials, AppOps tokens, private host inventories, configuration exports, uploaded wallpapers or deployment keys.
|
||||
- Keep bind addresses, AppOps endpoints, Unraid identity and browser-extension targets configurable; examples must use loopback or neutral placeholder hosts.
|
||||
- Add tests for behavioural changes and run the documented frontend, backend, browser and managed validation gates.
|
||||
- Explain security, permission, migration and deployment impact in the pull request.
|
||||
|
||||
Report vulnerabilities according to `SECURITY.md`.
|
||||
@@ -0,0 +1,55 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
FROM node:22-alpine@sha256:c610fcdfb1d5b4740dd70c284ed3cb16bb857e0f7166196e36a5501df7a3aa32 AS dependencies-base
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache python3 make g++
|
||||
COPY package.json package-lock.json ./
|
||||
# Keep the image build on the same npm major that produces and validates the
|
||||
# nested dependency overrides in package-lock.json. Node 22's bundled npm is
|
||||
# older and rejects this otherwise valid lockfile during `npm ci`.
|
||||
RUN npm install --global npm@11.16.0
|
||||
|
||||
FROM dependencies-base AS build
|
||||
ARG DOCKDECK_BUILD_VERSION=0.1.0
|
||||
ARG DOCKDECK_BUILD_REVISION=development
|
||||
ARG DOCKDECK_BUILD_DATE=1970-01-01T00:00:00.000Z
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN DOCKDECK_BUILD_VERSION="$DOCKDECK_BUILD_VERSION" \
|
||||
DOCKDECK_BUILD_REVISION="$DOCKDECK_BUILD_REVISION" \
|
||||
DOCKDECK_BUILD_DATE="$DOCKDECK_BUILD_DATE" \
|
||||
npm run build
|
||||
|
||||
# Installing the production dependency graph from the lockfile avoids mutating
|
||||
# a full dependency tree with `npm prune`. Concurrent BuildKit validations had
|
||||
# both stalled in that prune operation, while this stage remains reproducible.
|
||||
FROM dependencies-base AS production-dependencies
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
FROM node:22-alpine@sha256:c610fcdfb1d5b4740dd70c284ed3cb16bb857e0f7166196e36a5501df7a3aa32 AS runtime
|
||||
ARG DOCKDECK_BUILD_VERSION=0.1.0
|
||||
ARG DOCKDECK_BUILD_REVISION=development
|
||||
ARG DOCKDECK_BUILD_DATE=1970-01-01T00:00:00.000Z
|
||||
ARG DOCKDECK_BUILD_SOURCE=jens/dockdeck
|
||||
LABEL org.opencontainers.image.version="$DOCKDECK_BUILD_VERSION" \
|
||||
org.opencontainers.image.revision="$DOCKDECK_BUILD_REVISION" \
|
||||
org.opencontainers.image.created="$DOCKDECK_BUILD_DATE" \
|
||||
org.opencontainers.image.source="$DOCKDECK_BUILD_SOURCE"
|
||||
ENV NODE_ENV=production \
|
||||
HOST=0.0.0.0 \
|
||||
PORT=3000 \
|
||||
DATABASE_PATH=/data/dockdeck.db
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache su-exec && \
|
||||
addgroup -S dockdeck && \
|
||||
adduser -S -G dockdeck dockdeck && \
|
||||
mkdir -p /data /host-proc /unraid-icons-cache && \
|
||||
chown dockdeck:dockdeck /data /unraid-icons-cache
|
||||
COPY --from=build --chown=dockdeck:dockdeck /app/package.json ./package.json
|
||||
COPY --from=production-dependencies --chown=dockdeck:dockdeck /app/node_modules ./node_modules
|
||||
COPY --from=build --chown=dockdeck:dockdeck /app/dist ./dist
|
||||
COPY docker-entrypoint.sh /usr/local/bin/dockdeck-entrypoint
|
||||
RUN chmod 0755 /usr/local/bin/dockdeck-entrypoint
|
||||
EXPOSE 3000
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 CMD wget -qO- http://127.0.0.1:3000/api/health >/dev/null || exit 1
|
||||
ENTRYPOINT ["dockdeck-entrypoint"]
|
||||
CMD ["node", "dist/server/index.js"]
|
||||
@@ -0,0 +1,3 @@
|
||||
# Public source export
|
||||
|
||||
This source snapshot was generated from the private canonical repository at revision `26772208f608782e88c736dc70878e7b5abd3916`. It contains no private Git history or operational evidence. Changes are published from the canonical repository.
|
||||
@@ -0,0 +1,726 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"sourceRevision": "26772208f608782e88c736dc70878e7b5abd3916",
|
||||
"files": [
|
||||
{
|
||||
"path": ".dockerignore",
|
||||
"sha256": "58586bd103b46a2d77edacc75041f38d18c3389ec48fd4c84c676fd4f8312878",
|
||||
"bytes": 80
|
||||
},
|
||||
{
|
||||
"path": ".editorconfig",
|
||||
"sha256": "a6b98ea7cb6d61ed8d430dd0dffa46c87012b5cf859d4ce7207898954951fdcd",
|
||||
"bytes": 188
|
||||
},
|
||||
{
|
||||
"path": ".env.example",
|
||||
"sha256": "6bbf394db99a591bc1273aec793a57429ef7e2e592ccbc015b21c1f0f32ca33c",
|
||||
"bytes": 3391
|
||||
},
|
||||
{
|
||||
"path": ".gitattributes",
|
||||
"sha256": "be53271f536f849171fc1ddb1dab13a43c79f5083f8bb892bde86d2124b1ae95",
|
||||
"bytes": 73
|
||||
},
|
||||
{
|
||||
"path": ".gitignore",
|
||||
"sha256": "8c1729e11d738edb71cc50a0ceddd262d5c42afbc9da4833d432fa147be293e3",
|
||||
"bytes": 172
|
||||
},
|
||||
{
|
||||
"path": "CONTRIBUTING.md",
|
||||
"sha256": "c75e499854879e14122a7d18eaa78ae8f2ec7d6ff35f7c0c031c28f83d2f1d32",
|
||||
"bytes": 823
|
||||
},
|
||||
{
|
||||
"path": "Dockerfile",
|
||||
"sha256": "675e708823a9f4ddb9c4d2c3933114d2fbcd8589efa2090e69506ed866ceb3d4",
|
||||
"bytes": 2580
|
||||
},
|
||||
{
|
||||
"path": "README.md",
|
||||
"sha256": "13e9fc91e1af7ffd1f3c08312f10e8e41d15ff305059cb58dba6c96359b5ec56",
|
||||
"bytes": 6069
|
||||
},
|
||||
{
|
||||
"path": "SECURITY.md",
|
||||
"sha256": "b91599e60feb758089bd951d483ba56ed134017176d8686b8ad904186308bb8c",
|
||||
"bytes": 1578
|
||||
},
|
||||
{
|
||||
"path": "browser-extension/dockdeck-new-tab/background.js",
|
||||
"sha256": "2c1b7a3e4fac564ed528f6e0e792a17d3c0e7327d442fda0e06274973c0af649",
|
||||
"bytes": 890
|
||||
},
|
||||
{
|
||||
"path": "browser-extension/dockdeck-new-tab/manifest.json",
|
||||
"sha256": "6375cc7dee5415baf7eb9f54640448b604d4eb35ae91ebb7aca7db4cae0d984d",
|
||||
"bytes": 340
|
||||
},
|
||||
{
|
||||
"path": "browser-extension/dockdeck-new-tab/newtab.html",
|
||||
"sha256": "dcc01bcd5819726c87d2ac51e4c7cd48bc1629e4b49052aa8edb8dd3a832f18b",
|
||||
"bytes": 560
|
||||
},
|
||||
{
|
||||
"path": "browser-extension/dockdeck-new-tab/newtab.js",
|
||||
"sha256": "279e3851ed45ce4f665f55c48694af8d4fec7cf30011d0b3ac9dc1405acb7f35",
|
||||
"bytes": 167
|
||||
},
|
||||
{
|
||||
"path": "browser-extension/dockdeck-new-tab/options.html",
|
||||
"sha256": "c910ce9029c21d50b1e66ccb2c2a7234ac79e78d56d92d8a541be62096c9dbd3",
|
||||
"bytes": 996
|
||||
},
|
||||
{
|
||||
"path": "browser-extension/dockdeck-new-tab/options.js",
|
||||
"sha256": "afef1673466fc3268f958277a46234e12081c84a699581177fcba2ac16f3857a",
|
||||
"bytes": 650
|
||||
},
|
||||
{
|
||||
"path": "deploy/dockdeck-unraid-template.xml",
|
||||
"sha256": "5cb29a9fa74f91206a360227183390ad70fdc8aa42264b0f2c6a70c74f756c29",
|
||||
"bytes": 3951
|
||||
},
|
||||
{
|
||||
"path": "deploy/refresh-unraid-icon.sh",
|
||||
"sha256": "01b37896916699775920ec05fe6b030fded4816b031edf3753d2152756504230",
|
||||
"bytes": 1477
|
||||
},
|
||||
{
|
||||
"path": "deploy/sanitize-unraid-templates.py",
|
||||
"sha256": "59503a2854755dd73bdc302ce838dcb4bb40d3331a1eb0a1d004456f604b0b22",
|
||||
"bytes": 1797
|
||||
},
|
||||
{
|
||||
"path": "docker-compose.socket-proxy.yml",
|
||||
"sha256": "5c2e6f2a2df9d5dd5c6045bf1c99360a207a14edde96bd627d861fb65b422367",
|
||||
"bytes": 1149
|
||||
},
|
||||
{
|
||||
"path": "docker-compose.yml",
|
||||
"sha256": "e02d65af6c56af32851b2eaf6e388e4bc2e3e893b1bad82a0d708e703cf9340a",
|
||||
"bytes": 5366
|
||||
},
|
||||
{
|
||||
"path": "docker-entrypoint.sh",
|
||||
"sha256": "6dcf3408e744f02eebae994bfa5d54e7e3f158114996542a99562d4b536baa73",
|
||||
"bytes": 564
|
||||
},
|
||||
{
|
||||
"path": "docs/ARCHITECTURE.md",
|
||||
"sha256": "45e8200647b4ce94db49c7a43f7a913330b58f8a2054cf3552352286a202240e",
|
||||
"bytes": 4401
|
||||
},
|
||||
{
|
||||
"path": "docs/EXTERNAL_ACCESS.md",
|
||||
"sha256": "f3ebe012034062e2bb9e3596dad1d96406906d806c905ab2ee2adeba490d511e",
|
||||
"bytes": 812
|
||||
},
|
||||
{
|
||||
"path": "docs/design/dockdeck-canon-v1/implementation/dashboard-dark-desktop-1440x900.png",
|
||||
"sha256": "35dba23510a8a87490c69d64ea8fb97e9854119ba5f56e4c62091bc698875a2d",
|
||||
"bytes": 616632
|
||||
},
|
||||
{
|
||||
"path": "docs/design/dockdeck-canon-v1/implementation/dashboard-dark-mobile-390x844.png",
|
||||
"sha256": "7d796ddcc5e878f5b9101250ce6d7d2666746dfefad1f23936cca61ffc30c736",
|
||||
"bytes": 155162
|
||||
},
|
||||
{
|
||||
"path": "eslint.config.js",
|
||||
"sha256": "2ebe8fd37b5386153c3e4698bd503de9f9067dc954b1549b24e81aec4fece96d",
|
||||
"bytes": 988
|
||||
},
|
||||
{
|
||||
"path": "fixtures/unraid-templates/README.md",
|
||||
"sha256": "b56960fd72f4f5d7bf2982f3f580744b2e4fcc35b83ba937c4bcc1a51fc0a1c9",
|
||||
"bytes": 224
|
||||
},
|
||||
{
|
||||
"path": "index.html",
|
||||
"sha256": "4f13f5557414ff8a098ee317509087eb2463118c5f3a55b163f766c03957c6bc",
|
||||
"bytes": 618
|
||||
},
|
||||
{
|
||||
"path": "package-lock.json",
|
||||
"sha256": "2d319714fa5f3d271e4d2709dabd6873bd7fb873b58b5df8e86712b4a8f475f9",
|
||||
"bytes": 244776
|
||||
},
|
||||
{
|
||||
"path": "package.json",
|
||||
"sha256": "2ce4e86efde22f8232612a4008e62817fe0fb89750fd07f738486e2f956bbecc",
|
||||
"bytes": 2399
|
||||
},
|
||||
{
|
||||
"path": "playwright.config.ts",
|
||||
"sha256": "35fbae0073b2a6ad2467878a94697fd35e43edd9b673d5b5a1b024938e68b9d6",
|
||||
"bytes": 1579
|
||||
},
|
||||
{
|
||||
"path": "public/dockdeck-icon.png",
|
||||
"sha256": "b15cc1330d5d79639eca6efe88af796f2448d9bad71eee61fbd339092a673b0a",
|
||||
"bytes": 100522
|
||||
},
|
||||
{
|
||||
"path": "public/dockdeck-icon.svg",
|
||||
"sha256": "c97cd2ee5cd54179f789f4dafaf11e81d1905f39231172c57987d3eb80c346f3",
|
||||
"bytes": 1319
|
||||
},
|
||||
{
|
||||
"path": "public/dockdeck-mark-dark.svg",
|
||||
"sha256": "912ebf59ea64c8b20a9e373524d6aea73e2b56c17afa103073f20bf4f8994efe",
|
||||
"bytes": 584
|
||||
},
|
||||
{
|
||||
"path": "public/dockdeck-mark-light.svg",
|
||||
"sha256": "6b15c43227798c47fd58960f7fb57f1e03b80cb09c6d612cfecadba8d2272620",
|
||||
"bytes": 583
|
||||
},
|
||||
{
|
||||
"path": "public/dockdeck-mark-monochrome.svg",
|
||||
"sha256": "cf862bf725e1b07419d8da5fc65342eae2911946f739f8b87dd6d91608c2ec5c",
|
||||
"bytes": 585
|
||||
},
|
||||
{
|
||||
"path": "public/dockdeck-mark-v2.svg",
|
||||
"sha256": "7a425bba1649db9490a5df6876526b3fccec5154198488caf09c5cf2fa74881a",
|
||||
"bytes": 589
|
||||
},
|
||||
{
|
||||
"path": "public/dockdeck-mark.svg",
|
||||
"sha256": "8df2b242eea22336788f9b17f787f87d3c793964fb747642da2bb47b478a65d5",
|
||||
"bytes": 602
|
||||
},
|
||||
{
|
||||
"path": "public/dockdeck-wordmark.svg",
|
||||
"sha256": "95b65e2ee1a4ecbfbde2d07a682eb4a206c2ad395a3cb81d9ee478a4feaa9eef",
|
||||
"bytes": 760
|
||||
},
|
||||
{
|
||||
"path": "public/itworx-app-icon.png",
|
||||
"sha256": "c88894eb00aecaa6d70521e3a0c91ca938ec18f8dd18d4d4db182e83624eaa3f",
|
||||
"bytes": 2066550
|
||||
},
|
||||
{
|
||||
"path": "public/itworx-symbol.png",
|
||||
"sha256": "66b4f3b78132319001ca60f7585522b16b1110cd5d884250491d1fad846663fb",
|
||||
"bytes": 2081927
|
||||
},
|
||||
{
|
||||
"path": "public/itworx-wordmark.svg",
|
||||
"sha256": "e8801078e8d974044b71d2ed7954dea6dbb9fba2a9580e6d75903c303783508b",
|
||||
"bytes": 116165
|
||||
},
|
||||
{
|
||||
"path": "public/manifest.webmanifest",
|
||||
"sha256": "7ccc54d4738288326ff94c4e3239d92b79b1616c50c560ad4c42bde60f678294",
|
||||
"bytes": 377
|
||||
},
|
||||
{
|
||||
"path": "public/sw.js",
|
||||
"sha256": "bfdfd5b65c9076d866b0cfb756c5ae49bd47b0e784312f3092777d7baebba25f",
|
||||
"bytes": 1464
|
||||
},
|
||||
{
|
||||
"path": "scripts/build.mjs",
|
||||
"sha256": "484c34939e867d30163373853d21699f4fcbccfa1b5e0d1dde9c3f13e8819667",
|
||||
"bytes": 3315
|
||||
},
|
||||
{
|
||||
"path": "scripts/hash-operator-password.mjs",
|
||||
"sha256": "9a4952fb1c03a904ffe7376869d3457320c3480366fef45a250e7b3ba8ea28a2",
|
||||
"bytes": 1813
|
||||
},
|
||||
{
|
||||
"path": "src/client/App.tsx",
|
||||
"sha256": "5b888ad9069fcddf9749702b90513dcbc90a3759c7b49e4369efcf41e3c22574",
|
||||
"bytes": 7075
|
||||
},
|
||||
{
|
||||
"path": "src/client/api.ts",
|
||||
"sha256": "0676239b6b1f53e07d4838bd24f7fed5e0736335a1e0e4fbd08484f12218a3d7",
|
||||
"bytes": 4436
|
||||
},
|
||||
{
|
||||
"path": "src/client/bookmark-import.ts",
|
||||
"sha256": "d81c8d50daeb96a166734af7ce992a6a6d97bc3d9d39014f4bb5c5bc4dc24db5",
|
||||
"bytes": 13000
|
||||
},
|
||||
{
|
||||
"path": "src/client/canon.css",
|
||||
"sha256": "529334a83f5e5fb4353fd48559354353db9e63d860ed8a1b3653a47a47f106e0",
|
||||
"bytes": 25510
|
||||
},
|
||||
{
|
||||
"path": "src/client/categoryIcons.ts",
|
||||
"sha256": "615ca76c404af963759360ce803fa380c6f11d1631cea72f34844bfbba864d47",
|
||||
"bytes": 1774
|
||||
},
|
||||
{
|
||||
"path": "src/client/components/AppTile.tsx",
|
||||
"sha256": "b4d0b08b2632fe1397a69ffa3fe9683754294e145ba634669111eb23c5c435e1",
|
||||
"bytes": 3542
|
||||
},
|
||||
{
|
||||
"path": "src/client/components/AppearanceSettings.tsx",
|
||||
"sha256": "12edb5b07438a282145eab8e6af930183c44aa16d99e72a7c721bc0b75f539e7",
|
||||
"bytes": 28154
|
||||
},
|
||||
{
|
||||
"path": "src/client/components/BookmarkImport.tsx",
|
||||
"sha256": "f73e789e5d0f6447a5b6ce822385ad11a7c47d54a232eca8aa2314ad4e4b22fc",
|
||||
"bytes": 14084
|
||||
},
|
||||
{
|
||||
"path": "src/client/components/Brand.tsx",
|
||||
"sha256": "4645aeab8e929dd2add6339630853db3b9e3e6e52a309237fa90c6f5085edf1f",
|
||||
"bytes": 745
|
||||
},
|
||||
{
|
||||
"path": "src/client/components/CategoryIcon.tsx",
|
||||
"sha256": "660249d24816068a865ffa0b0ce8c9b0ff2c58c6eaf55f7852fcc149c8e02206",
|
||||
"bytes": 288
|
||||
},
|
||||
{
|
||||
"path": "src/client/components/Dashboard.tsx",
|
||||
"sha256": "c3b4a59ad1fbdfda16fb7c65342128cb38e19181e683490c32ca6ebe5c2567df",
|
||||
"bytes": 19497
|
||||
},
|
||||
{
|
||||
"path": "src/client/components/DashboardHero.tsx",
|
||||
"sha256": "48883993d2b667a2c1dcf5a8cd0be437e7c3a4b290f0037e4e3821e5dea347cc",
|
||||
"bytes": 1565
|
||||
},
|
||||
{
|
||||
"path": "src/client/components/DashboardLaunchHistory.tsx",
|
||||
"sha256": "498328fa64ba20fcc6becadb8339d5ea8aa662709c971bf20b39d5f840ae5023",
|
||||
"bytes": 4674
|
||||
},
|
||||
{
|
||||
"path": "src/client/components/DashboardSidebar.tsx",
|
||||
"sha256": "1e759bbafcb261abde6bdc65c2ab7e49b31ac29ae5f0babf8db424ad59860391",
|
||||
"bytes": 7693
|
||||
},
|
||||
{
|
||||
"path": "src/client/components/DashboardTopBar.tsx",
|
||||
"sha256": "c7b484645d04085af6fb44988b4e8181022ebfa3e995cff97f6334900f8747c5",
|
||||
"bytes": 2783
|
||||
},
|
||||
{
|
||||
"path": "src/client/components/OperationsSettings.tsx",
|
||||
"sha256": "472ea4dbbac0a6ba587e809b5b2bac11d657cbd6729182f0733cbf77fb65aa40",
|
||||
"bytes": 4381
|
||||
},
|
||||
{
|
||||
"path": "src/client/components/SearchOverlay.tsx",
|
||||
"sha256": "dcec7ede54b3e2cbaa5912ee52d1a08dde7d497f65c7c7d3311a8d36b0ade911",
|
||||
"bytes": 12782
|
||||
},
|
||||
{
|
||||
"path": "src/client/components/ServerWidget.tsx",
|
||||
"sha256": "4b16c5f5e66a600a6bb58748f45397c5c8f557c4f8a907abd8848b63df2731da",
|
||||
"bytes": 7269
|
||||
},
|
||||
{
|
||||
"path": "src/client/components/ServerWidgetAdvancedConfigurator.tsx",
|
||||
"sha256": "500132c24d8e899f5bfd8af1d74646e8a5cc6c89ef187dbfd73b0718ac1cbb96",
|
||||
"bytes": 9734
|
||||
},
|
||||
{
|
||||
"path": "src/client/components/ServiceIcon.tsx",
|
||||
"sha256": "fc1091289c4f3d5d4d553f3c09a51b5b164ccb59f31f4cb36a4799891c151043",
|
||||
"bytes": 4065
|
||||
},
|
||||
{
|
||||
"path": "src/client/components/ServiceWidget.tsx",
|
||||
"sha256": "c4c6ae7cbfa90c75810457844176947847c03b05e547560491de45772f980cac",
|
||||
"bytes": 18095
|
||||
},
|
||||
{
|
||||
"path": "src/client/components/ServiceWidgets.tsx",
|
||||
"sha256": "3394a41b46466ef34d1dc86d21ba2964bee0c5d5e98c9b73c15c477d889bfc8a",
|
||||
"bytes": 7250
|
||||
},
|
||||
{
|
||||
"path": "src/client/components/Settings.tsx",
|
||||
"sha256": "ea8ae24de5910babb4bb0d6f10cc9b8e1b40c47bf5b8122e36383fbe1b9e2ccc",
|
||||
"bytes": 79765
|
||||
},
|
||||
{
|
||||
"path": "src/client/components/SortableList.tsx",
|
||||
"sha256": "8597b93a92966e4f9b3397e462982cdc04a282e25ef4914c9d21eabcfdd74826",
|
||||
"bytes": 9115
|
||||
},
|
||||
{
|
||||
"path": "src/client/components/WeatherBadge.tsx",
|
||||
"sha256": "0fced18c455f00263ee0e0e8bf02102f4a120584f405c08e0c8600a1f1222c73",
|
||||
"bytes": 1967
|
||||
},
|
||||
{
|
||||
"path": "src/client/components/WidgetAdvancedConfigurator.tsx",
|
||||
"sha256": "df25ae291ecc6f4473ead669cd1b622578cea0a9678e5f24cbeb3d0a2d122fb0",
|
||||
"bytes": 15259
|
||||
},
|
||||
{
|
||||
"path": "src/client/components/WidgetConfigurator.tsx",
|
||||
"sha256": "54846705973b6b74368772be8e2072891d35bbe52e392ec22d382c5fb7c82947",
|
||||
"bytes": 2489
|
||||
},
|
||||
{
|
||||
"path": "src/client/components/WidgetEditToolbar.tsx",
|
||||
"sha256": "15dd1c44ef6ceb95d2ce023e247519f40d93011b9b01b7fac84b0094b3a1b872",
|
||||
"bytes": 1747
|
||||
},
|
||||
{
|
||||
"path": "src/client/components/WidgetPresetManager.tsx",
|
||||
"sha256": "9bcd1b63ddec741e96293c53340bd0ad15a023663f3f0d4a322f064153b7bc4a",
|
||||
"bytes": 4312
|
||||
},
|
||||
{
|
||||
"path": "src/client/components/WidgetSparkline.tsx",
|
||||
"sha256": "e74569172e30421d53f45b78971fc4e290cd6e304fa4d3c722e0122ba17e0953",
|
||||
"bytes": 2198
|
||||
},
|
||||
{
|
||||
"path": "src/client/design-tokens.css",
|
||||
"sha256": "9bfcc5799773d51e49395cd7524253c05bd32001c3ed62449682e2716cad4b5f",
|
||||
"bytes": 4077
|
||||
},
|
||||
{
|
||||
"path": "src/client/launch-history.ts",
|
||||
"sha256": "329f8fad1638484c04c595cb20659a7d3404e72d8015c84fc3bad3e5a7013bdc",
|
||||
"bytes": 6088
|
||||
},
|
||||
{
|
||||
"path": "src/client/main.tsx",
|
||||
"sha256": "2eb8508f59b78afe94a0927c1d0b91f7456007d954916eb6f1b6fa4adbbbdd0e",
|
||||
"bytes": 573
|
||||
},
|
||||
{
|
||||
"path": "src/client/persist-sort-order.ts",
|
||||
"sha256": "74b6ab9071fd753b9af8fffb976674f65bc657dd1bcb3b1e3400b07983d5f4e5",
|
||||
"bytes": 879
|
||||
},
|
||||
{
|
||||
"path": "src/client/sortable-order.ts",
|
||||
"sha256": "75b2a15188ce3a96d77d0ecf4f591d59bb629c18d092ab033680d81083ddf8b4",
|
||||
"bytes": 1027
|
||||
},
|
||||
{
|
||||
"path": "src/client/stitch-direct.css",
|
||||
"sha256": "94c6d9fdccbf23d36c6e7f5da1ca54b1b3453c1996e144568686b6b03ec81c28",
|
||||
"bytes": 133967
|
||||
},
|
||||
{
|
||||
"path": "src/client/styles.css",
|
||||
"sha256": "30c77d21b449fe7c9ed6f31f8bc7f1d35b7b6af6c854559442bd2f7058665019",
|
||||
"bytes": 90373
|
||||
},
|
||||
{
|
||||
"path": "src/client/useLaunchHistory.ts",
|
||||
"sha256": "53a2bd5e31be8f4c17f75a36545cabfa9c9ed84c18e2a05213f91f8f0e878c78",
|
||||
"bytes": 1989
|
||||
},
|
||||
{
|
||||
"path": "src/client/useStoredBoolean.ts",
|
||||
"sha256": "16a40cdbd4d51dd43f040960a9987266aaba7f499c8b99dd3e4039e9ab3ac3c4",
|
||||
"bytes": 662
|
||||
},
|
||||
{
|
||||
"path": "src/server/app.ts",
|
||||
"sha256": "32e79953c549e67192678cf78363c0dd23b79dfb0da0ba1572c5e727ce4ac5f5",
|
||||
"bytes": 19741
|
||||
},
|
||||
{
|
||||
"path": "src/server/auth.ts",
|
||||
"sha256": "611f98bd3dfd172086197e500f96c8fed96da48e0c4f6e84a14664d183807a32",
|
||||
"bytes": 4920
|
||||
},
|
||||
{
|
||||
"path": "src/server/build-info.ts",
|
||||
"sha256": "108b0d586c78d5bba7beeeeef79c37836fb7a78c164b7bb41422085d310b81f7",
|
||||
"bytes": 504
|
||||
},
|
||||
{
|
||||
"path": "src/server/concurrency.ts",
|
||||
"sha256": "13533ca5d75787f7468d6451fbb5a70a82c640b9bf32a16f2f7704b64435fec5",
|
||||
"bytes": 532
|
||||
},
|
||||
{
|
||||
"path": "src/server/database.ts",
|
||||
"sha256": "f760331435e351ba7bb9e4dc91b1b1dccb436ba999c6a3fdede3e0815efebece",
|
||||
"bytes": 47522
|
||||
},
|
||||
{
|
||||
"path": "src/server/http.ts",
|
||||
"sha256": "c6563c4687e1354e377c4c2f2e0dbfb16d8bd46ddf380a847df9a4896a95a86e",
|
||||
"bytes": 1850
|
||||
},
|
||||
{
|
||||
"path": "src/server/index.ts",
|
||||
"sha256": "e319ba3da89420646d478cbeafa0adb2cbcbfb26ff74a3d960c73d3b36a8a81f",
|
||||
"bytes": 388
|
||||
},
|
||||
{
|
||||
"path": "src/server/integration-environment.ts",
|
||||
"sha256": "d6604d4f450ce291ea6e076592fbe237accd65a0beabad70664f31b0ecdd8dd7",
|
||||
"bytes": 2042
|
||||
},
|
||||
{
|
||||
"path": "src/server/integrations/discovery.ts",
|
||||
"sha256": "96b6c6bdfc391c064a734233746a3858a0c718e9ff21f76c28c6f635dd6db4f6",
|
||||
"bytes": 29064
|
||||
},
|
||||
{
|
||||
"path": "src/server/integrations/provider-widgets.ts",
|
||||
"sha256": "826b93e2915d2977845075722494ae63001dee1acde848910e1525ec64f67a69",
|
||||
"bytes": 136
|
||||
},
|
||||
{
|
||||
"path": "src/server/integrations/provider-widgets/catalog.ts",
|
||||
"sha256": "9d0fbf652278eae96add28230b1bf8cc353efab8f1fcf3ed9e1f19abd30e5e92",
|
||||
"bytes": 6919
|
||||
},
|
||||
{
|
||||
"path": "src/server/integrations/provider-widgets/detection.ts",
|
||||
"sha256": "e4c719710e989eba5ea44d3857ef1dc7419559188ff88ecdcb6d7eee7c46d919",
|
||||
"bytes": 1987
|
||||
},
|
||||
{
|
||||
"path": "src/server/integrations/provider-widgets/profiles.ts",
|
||||
"sha256": "5bf7acf1f3796ee7aa015a19d796ec6f1d491ec25d39a8037ce6b332da6438fe",
|
||||
"bytes": 26572
|
||||
},
|
||||
{
|
||||
"path": "src/server/integrations/provider-widgets/providers/arr.ts",
|
||||
"sha256": "cbd668885db64a1c199b6efdb2cc554a82e4e7094ae99a52966423aa9b90e33a",
|
||||
"bytes": 4995
|
||||
},
|
||||
{
|
||||
"path": "src/server/integrations/provider-widgets/providers/custom.ts",
|
||||
"sha256": "a8a5180c8883f3b98fa5939df2dca5eeeb53cdbe83142adb82da1ba607666a7c",
|
||||
"bytes": 2309
|
||||
},
|
||||
{
|
||||
"path": "src/server/integrations/provider-widgets/providers/home-assistant.ts",
|
||||
"sha256": "069e30cdc9901687c1b9a167a846166b1794982a77e0522eea8ba9df49dba341",
|
||||
"bytes": 2612
|
||||
},
|
||||
{
|
||||
"path": "src/server/integrations/provider-widgets/providers/media.ts",
|
||||
"sha256": "4dc4b00e9b214a87a2cb438522bf080051e2a6a4c6a0d5e0a19627e3cbcfcf1b",
|
||||
"bytes": 8838
|
||||
},
|
||||
{
|
||||
"path": "src/server/integrations/provider-widgets/providers/monitoring.ts",
|
||||
"sha256": "96a6dbc526b0fce0f2ab488d1974d9912a2d6b06aebd11380fbebc984c74d472",
|
||||
"bytes": 6135
|
||||
},
|
||||
{
|
||||
"path": "src/server/integrations/provider-widgets/providers/network.ts",
|
||||
"sha256": "f31ce6d4fc10467c71b22f7efe511944fafc8ed20b56ba102da71a44e7812dd9",
|
||||
"bytes": 8265
|
||||
},
|
||||
{
|
||||
"path": "src/server/integrations/provider-widgets/providers/ollama.ts",
|
||||
"sha256": "5662f79bfd36cd712f208d357de11918606b3dd6614767542393f148e054f62a",
|
||||
"bytes": 1853
|
||||
},
|
||||
{
|
||||
"path": "src/server/integrations/provider-widgets/providers/productivity.ts",
|
||||
"sha256": "b2b20f53d489799fd6e25683d7ab785edc0c8d1594ba75f04dc6889ddcb92d7d",
|
||||
"bytes": 5113
|
||||
},
|
||||
{
|
||||
"path": "src/server/integrations/provider-widgets/registry.ts",
|
||||
"sha256": "4ab0c4ba60bf1a1cfa0e6586e993ba04348ad223e8e4cc0a708f436ee9797326",
|
||||
"bytes": 5627
|
||||
},
|
||||
{
|
||||
"path": "src/server/integrations/provider-widgets/service.ts",
|
||||
"sha256": "33356068853f0571cdb1574e7cdca6c1339ec3aed1119600b275283767a48a5a",
|
||||
"bytes": 11514
|
||||
},
|
||||
{
|
||||
"path": "src/server/integrations/provider-widgets/shared.ts",
|
||||
"sha256": "b9c5c6429842df8d4bb11ea0e107d944212496960aa33ec5e22226c6f176ca9a",
|
||||
"bytes": 2267
|
||||
},
|
||||
{
|
||||
"path": "src/server/integrations/provider-widgets/types.ts",
|
||||
"sha256": "0ad8a9496b337860a6413fc319d59e02c389c8df752d589d0fe7aafdfe935485",
|
||||
"bytes": 647
|
||||
},
|
||||
{
|
||||
"path": "src/server/integrations/unraid-status.ts",
|
||||
"sha256": "dcc1fa5a210852cd40381a9d0a0ce3b64499b9756fe4b1bc77d5da74012dbfc5",
|
||||
"bytes": 8073
|
||||
},
|
||||
{
|
||||
"path": "src/server/schema.ts",
|
||||
"sha256": "8610b5c0656eb7871dd3bd15ab2990b6d4eb6d56224bec2a46d970191df19a1e",
|
||||
"bytes": 7995
|
||||
},
|
||||
{
|
||||
"path": "src/server/wallpaper.ts",
|
||||
"sha256": "cba7588af9f411f9d1680f96394e4ccb28a0308f4706fbd569f7748408b79b84",
|
||||
"bytes": 2465
|
||||
},
|
||||
{
|
||||
"path": "src/server/weather.ts",
|
||||
"sha256": "42093d671dbb0f3387c41a2c7f23aa6f87aaa5c8b42ce25dbad00c96e289a70e",
|
||||
"bytes": 2118
|
||||
},
|
||||
{
|
||||
"path": "src/shared/contracts.ts",
|
||||
"sha256": "fbad7f4445a5b6d82e972010f19b9abd7f1ef294560503a8665e765bbeb0df9c",
|
||||
"bytes": 27700
|
||||
},
|
||||
{
|
||||
"path": "src/shared/domain.ts",
|
||||
"sha256": "7b7c66f698eb8bf09fd6a30be4d26c80636ab3ac01336f98d452d10667b09b20",
|
||||
"bytes": 12386
|
||||
},
|
||||
{
|
||||
"path": "src/shared/personal-project.ts",
|
||||
"sha256": "2da2c07bb5d83e13d6343ca6dbda9437cb88513b773805a49b1e69b1d1ef23bb",
|
||||
"bytes": 4214
|
||||
},
|
||||
{
|
||||
"path": "src/tests/api.test.ts",
|
||||
"sha256": "8dc028a575dad10cf377ccfded171227e34df99d5289161a8b39814332f36bc6",
|
||||
"bytes": 34763
|
||||
},
|
||||
{
|
||||
"path": "src/tests/auth.test.ts",
|
||||
"sha256": "5431a46995a77cc1944efb6c0cfb556aa28368ed2554e7eba4be6f2a35e317e6",
|
||||
"bytes": 3153
|
||||
},
|
||||
{
|
||||
"path": "src/tests/bookmark-import-component.test.tsx",
|
||||
"sha256": "61cbe76d14b2534cddeef7243ecb584bd86b011afa9c2d4c75d52ddce137737a",
|
||||
"bytes": 4508
|
||||
},
|
||||
{
|
||||
"path": "src/tests/bookmark-import.test.ts",
|
||||
"sha256": "7f3c0d9bcdb70efca82f06055f6fb10c12a8e7cde82de836f13e4a61296362e8",
|
||||
"bytes": 6364
|
||||
},
|
||||
{
|
||||
"path": "src/tests/category-icons.test.ts",
|
||||
"sha256": "7e419572ff827e0a91eaef295ce88450cdc0f850cc698b66bb55e05b940eb080",
|
||||
"bytes": 1251
|
||||
},
|
||||
{
|
||||
"path": "src/tests/dashboard-launch-history.test.tsx",
|
||||
"sha256": "3f161cd329ca479dea753dc3a5fa83161c47462158ce267d39c3643b9d86cad4",
|
||||
"bytes": 2506
|
||||
},
|
||||
{
|
||||
"path": "src/tests/database.test.ts",
|
||||
"sha256": "7bcbb97ddd052d9ad1c327150f220955d22242a20b2b2366dd9d7e2e569e670d",
|
||||
"bytes": 25964
|
||||
},
|
||||
{
|
||||
"path": "src/tests/discovery.test.ts",
|
||||
"sha256": "da3832b407c9f157e30d9da22367823240c9c2197a541f5d02dd9bd731ae4fb5",
|
||||
"bytes": 4418
|
||||
},
|
||||
{
|
||||
"path": "src/tests/domain.test.ts",
|
||||
"sha256": "1bab82b7a4d59abac3320a5ae416767b2549c2fdb1bad8828f6ed06862fb8224",
|
||||
"bytes": 6913
|
||||
},
|
||||
{
|
||||
"path": "src/tests/http.test.ts",
|
||||
"sha256": "719dba37bf774ba1ffffd7afc265ec8023bf9ed8198cf89d4432631d1116f074",
|
||||
"bytes": 975
|
||||
},
|
||||
{
|
||||
"path": "src/tests/integration-environment.test.ts",
|
||||
"sha256": "be4cd06329992f93459f69efd6104fb73bd8103ceb41ac3ea9f7fcac17c83052",
|
||||
"bytes": 2014
|
||||
},
|
||||
{
|
||||
"path": "src/tests/launch-history.test.ts",
|
||||
"sha256": "01ff30147ba6a7653e7dae2b453253c1a2c90f1b951cf25dff85506e2e9c85de",
|
||||
"bytes": 4592
|
||||
},
|
||||
{
|
||||
"path": "src/tests/personal-project.test.ts",
|
||||
"sha256": "2e3deea5795e549ebce4f1662b4f8570896e4ff9fe3165322644bc6a504c4391",
|
||||
"bytes": 3865
|
||||
},
|
||||
{
|
||||
"path": "src/tests/provider-widgets.test.ts",
|
||||
"sha256": "9bd407fc3b3d067ca1cdd7c48c0d722f86d5756a05948b01f9245d9324636515",
|
||||
"bytes": 16778
|
||||
},
|
||||
{
|
||||
"path": "src/tests/security.test.ts",
|
||||
"sha256": "08178b8a489eb0709f763f48b1a6d069d6303dbec40e16f2fc68d0397bcd2689",
|
||||
"bytes": 4834
|
||||
},
|
||||
{
|
||||
"path": "src/tests/setup.ts",
|
||||
"sha256": "b54c6156e4983b5789f3ee8a9d1acc3bc2bdde83765cb381ac152ede9a269d1d",
|
||||
"bytes": 31
|
||||
},
|
||||
{
|
||||
"path": "src/tests/sortable-list.test.tsx",
|
||||
"sha256": "8ad45f4051f2813f1417b6963b13f124e47a92b1da69bb1aaef3340ffe1954ca",
|
||||
"bytes": 3661
|
||||
},
|
||||
{
|
||||
"path": "src/tests/unraid-status.test.ts",
|
||||
"sha256": "0c1ee1e49d7c9d629c7bf2b870176340aad250596a2ee93499830ad89cfea6f2",
|
||||
"bytes": 1083
|
||||
},
|
||||
{
|
||||
"path": "src/tests/use-launch-history.test.tsx",
|
||||
"sha256": "fb879ef82d22386749bc82a32ffd0979e81e8e03c278d5bfd0f30b078e2bedc8",
|
||||
"bytes": 1772
|
||||
},
|
||||
{
|
||||
"path": "src/tests/weather-wallpaper.test.ts",
|
||||
"sha256": "c679577789c76f3ec9be60a413c7e0da12959f353b9407067b27d9d16b0ad116",
|
||||
"bytes": 2278
|
||||
},
|
||||
{
|
||||
"path": "src/vite-env.d.ts",
|
||||
"sha256": "65996936fbb042915f7b74a200fcdde7e410f32a669b1ab9597cfaa4b0faddb5",
|
||||
"bytes": 38
|
||||
},
|
||||
{
|
||||
"path": "tests/dockdeck.spec.ts",
|
||||
"sha256": "43eeaa2b9b44f57ed67971bbaecfcb92f0f3d848d509d794af3473e0fed4c439",
|
||||
"bytes": 39307
|
||||
},
|
||||
{
|
||||
"path": "tsconfig.app.json",
|
||||
"sha256": "221d57c1b220839085c13b5cdfba92bcd3ef0e4ee612588c2d2c95df19576b46",
|
||||
"bytes": 618
|
||||
},
|
||||
{
|
||||
"path": "tsconfig.json",
|
||||
"sha256": "770b4140bbb581e2dfd9ea9946ffc9c75a1d86ba7d2db5f77c83e37cbdf9d808",
|
||||
"bytes": 119
|
||||
},
|
||||
{
|
||||
"path": "tsconfig.node.json",
|
||||
"sha256": "ea223528650eae080b44818ea2fbdfceb1fa5b77774f25777bc1df0c9576510c",
|
||||
"bytes": 461
|
||||
},
|
||||
{
|
||||
"path": "vite.config.ts",
|
||||
"sha256": "315dae2581287da958d68ba36adfd8af2479a059ef2fce7de3edec43998b1b39",
|
||||
"bytes": 331
|
||||
},
|
||||
{
|
||||
"path": "vitest.config.ts",
|
||||
"sha256": "88b728092fce08275752bb6ea21a2b7507a7d7087479604039cb4c9f00b62e80",
|
||||
"bytes": 329
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
# DockDeck-Public
|
||||
|
||||
DockDeck is a self-hosted start dashboard for Unraid and other home-server environments. It combines application shortcuts, favorites, server health, weather, and optional read-only service widgets in one responsive interface.
|
||||
|
||||

|
||||
|
||||
## What you get
|
||||
|
||||
- Automatic application discovery through AppOps or the optional read-only Docker socket proxy
|
||||
- Search, favorites, categories, personal-project filtering, layout presets, and custom wallpapers
|
||||
- Responsive desktop, tablet, and mobile layouts with light and dark themes
|
||||
- Unraid array, host, UPS, CPU, memory, and temperature information from read-only sources
|
||||
- Optional widgets for services such as Plex, Jellyfin, Gitea, Home Assistant, Grafana, Nextcloud, and the Arr stack
|
||||
- Server-side storage for integration credentials; exports intentionally exclude credentials
|
||||
- Optional built-in HTTP Basic authentication for trusted-LAN use
|
||||
|
||||
DockDeck never needs the Docker socket in its normal AppOps profile. The fallback proxy profile exposes only the Docker GET endpoints used for inventory and status.
|
||||
|
||||
## Quick start
|
||||
|
||||
Requirements: Docker with Compose v2, or Node.js 22 and npm for development.
|
||||
|
||||
1. Clone the repository and copy `.env.example` to `.env`:
|
||||
|
||||
```sh
|
||||
git clone https://gitea.itworx.tech/Jens/DockDeck-Public.git
|
||||
cd DockDeck-Public
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
On Windows PowerShell, use `Copy-Item .env.example .env` for the last command.
|
||||
|
||||
2. Generate an operator password hash:
|
||||
|
||||
```sh
|
||||
node scripts/hash-operator-password.mjs
|
||||
```
|
||||
|
||||
The prompt does not echo the password. Put the generated value in `DOCKDECK_OPERATOR_PASSWORD_HASH` and choose `DOCKDECK_OPERATOR_USER`.
|
||||
|
||||
3. Start DockDeck:
|
||||
|
||||
```sh
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
4. Open `http://127.0.0.1:1218`.
|
||||
|
||||
The default port publication is loopback-only. To make DockDeck reachable on a trusted LAN, set `DOCKDECK_BIND_ADDRESS` to the intended host address and keep operator authentication enabled. Use an authenticated HTTPS reverse proxy for access beyond that LAN; HTTP Basic credentials must not cross an untrusted plaintext connection.
|
||||
|
||||
## Discovery profiles
|
||||
|
||||
The recommended profile points `APPOPS_URL` at a read-only AppOps inventory endpoint. No Docker socket is mounted.
|
||||
|
||||
If AppOps is unavailable, use the isolated socket-proxy override:
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.yml -f docker-compose.socket-proxy.yml up -d --build
|
||||
```
|
||||
|
||||
DockDeck performs GET requests for Docker inventory, host information, and one-shot statistics. Nginx Proxy Manager uses a POST only to obtain its session token; resource discovery remains read-only.
|
||||
|
||||
## Configure DockDeck
|
||||
|
||||
Open Settings to:
|
||||
|
||||
- organize apps and mark personal projects;
|
||||
- create favorites and layout presets;
|
||||
- configure the Unraid server identity and polling interval;
|
||||
- add provider URLs and least-privilege credentials;
|
||||
- export or import non-secret dashboard configuration.
|
||||
|
||||
When a provider URL changes to another origin, DockDeck clears any omitted stored credentials and requires them to be supplied again. Redirects on credential-bearing integration requests are rejected.
|
||||
|
||||
Integration values entered in Settings are stored in `INTEGRATION_ENV_PATH` with owner-only permissions where the platform supports them. Back up that file and the SQLite database as secrets. JSON exports do not include integration credentials, but they can still reveal service names and topology.
|
||||
|
||||
## Unraid
|
||||
|
||||
An example Community Applications template is available at `deploy/dockdeck-unraid-template.xml`. It uses `/data/integrations.env` for persistent integration settings and requires an operator username and generated password hash. Replace the example AppOps and Unraid hostnames with values for your environment.
|
||||
|
||||
Raw Unraid DockerMan XML frequently contains private hosts and service names. Run `deploy/sanitize-unraid-templates.py` before mounting template metadata, and still review the resulting `Name`, `WebUI`, and `Icon` values before sharing them.
|
||||
|
||||

|
||||
|
||||
The screenshots show deterministic mock discovery, not a live server inventory. The interface currently uses Dutch labels.
|
||||
|
||||
## Optional browser extension
|
||||
|
||||
`browser-extension/dockdeck-new-tab` contains a Manifest V3 extension that opens DockDeck for new tabs. Load it as an unpacked extension, then set your DockDeck HTTP(S) URL on its Options page. The public source contains no operator-specific address.
|
||||
|
||||
## Development and verification
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Before contributing, run:
|
||||
|
||||
```sh
|
||||
npm run format:check
|
||||
npm run lint
|
||||
npm run typecheck
|
||||
npm test
|
||||
npm run test:security
|
||||
npm run test:e2e
|
||||
npm run build
|
||||
npm audit --audit-level=moderate
|
||||
docker compose config --quiet
|
||||
```
|
||||
|
||||
See `CONTRIBUTING.md`, `SECURITY.md`, and `docs/ARCHITECTURE.md` for contribution, disclosure, and design guidance.
|
||||
|
||||
## Security boundary
|
||||
|
||||
DockDeck is an operator dashboard, not a multi-tenant authorization system. Keep it on loopback or a trusted LAN, enable built-in authentication when it is network-reachable, use HTTPS at an external reverse proxy, and give every integration the narrowest read scope available. `/api/health` intentionally remains unauthenticated and returns only health state, not credentials.
|
||||
|
||||
## License
|
||||
|
||||
No open-source license has been selected yet. Until a license file is added, the source is publicly viewable but normal copyright restrictions remain in effect.
|
||||
|
||||
## Public source distribution
|
||||
|
||||
The private canonical repository remains the development source. Reviewed snapshots are synchronized automatically to `DockDeck-Public` after changes reach its default branch. The public export contains application source, dependency locks, generic deployment examples and selected demo visuals; private Git history, infrastructure workflows and operational records are excluded. `PUBLIC_SOURCE_MANIFEST.json` identifies the exact source revision and file hashes.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported code
|
||||
|
||||
Security fixes target the current `main` branch.
|
||||
|
||||
## Reporting vulnerabilities
|
||||
|
||||
Report suspected vulnerabilities privately to the repository owner. Do not include API keys, access tokens, passwords, private hostnames or addresses, infrastructure inventories, configuration exports, uploaded media, personal data, or exploit-sensitive diagnostics in a public issue.
|
||||
|
||||
A useful report identifies the affected commit or component, minimal reproduction conditions using synthetic data, expected and observed behaviour, and impact. Mention whether the issue affects provider credential handling, inventory ingestion, the read-only Docker/AppOps boundary, external-access assumptions, request validation, stored configuration, import/export, frontend secret masking, or deployment.
|
||||
|
||||
## Security model
|
||||
|
||||
DockDeck can expose infrastructure inventory and launch URLs. It is designed for a trusted local network and does not assume that direct internet exposure is safe. Preserve loopback-by-default publishing, read-only integrations, explicit reverse-proxy authentication, and least-privilege socket-proxy or AppOps permissions.
|
||||
|
||||
Contributions must not add Docker mutations or unrestricted socket access, expose provider secrets to API responses or browser bundles, or weaken the documented local deployment boundary without an explicit security design.
|
||||
|
||||
Never commit live `.env` files, operational credentials, private inventories, configuration exports, uploaded media, or deployment keys. Use `.env.example` for non-secret configuration examples.
|
||||
@@ -0,0 +1,33 @@
|
||||
/* global chrome */
|
||||
|
||||
const DEFAULT_DOCKDECK_URL = "http://127.0.0.1:1218/";
|
||||
const NEW_TAB_URLS = new Set([
|
||||
"chrome://newtab/",
|
||||
"opera://startpage/",
|
||||
"opera://startpageshared/",
|
||||
]);
|
||||
|
||||
async function openDockDeckForNewTab(tabId, url) {
|
||||
if (NEW_TAB_URLS.has(url)) {
|
||||
const { dockdeckUrl } = await chrome.storage.sync.get({
|
||||
dockdeckUrl: DEFAULT_DOCKDECK_URL,
|
||||
});
|
||||
chrome.tabs.update(tabId, { url: dockdeckUrl });
|
||||
}
|
||||
}
|
||||
|
||||
chrome.tabs.onCreated.addListener((tab) => {
|
||||
openDockDeckForNewTab(tab.id, tab.pendingUrl || tab.url || "");
|
||||
});
|
||||
|
||||
chrome.tabs.onUpdated.addListener((tabId, changeInfo) => {
|
||||
if (changeInfo.url) {
|
||||
openDockDeckForNewTab(tabId, changeInfo.url);
|
||||
}
|
||||
});
|
||||
|
||||
chrome.runtime.onStartup.addListener(() => {
|
||||
chrome.storage.sync
|
||||
.get({ dockdeckUrl: DEFAULT_DOCKDECK_URL })
|
||||
.then(({ dockdeckUrl }) => chrome.tabs.create({ url: dockdeckUrl }));
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "DockDeck New Tab",
|
||||
"version": "1.2.0",
|
||||
"description": "Opens DockDeck whenever a new tab is created.",
|
||||
"permissions": ["tabs", "storage"],
|
||||
"options_page": "options.html",
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"chrome_url_overrides": {
|
||||
"newtab": "newtab.html"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>DockDeck</title>
|
||||
<style>
|
||||
html {
|
||||
background: #101719;
|
||||
color: #f4f7f5;
|
||||
font:
|
||||
16px system-ui,
|
||||
sans-serif;
|
||||
}
|
||||
body {
|
||||
display: grid;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
place-items: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<p>Opening DockDeck…</p>
|
||||
<script src="newtab.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,5 @@
|
||||
/* global chrome, window */
|
||||
|
||||
chrome.storage.sync
|
||||
.get({ dockdeckUrl: "http://127.0.0.1:1218/" })
|
||||
.then(({ dockdeckUrl }) => window.location.replace(dockdeckUrl));
|
||||
@@ -0,0 +1,45 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>DockDeck New Tab settings</title>
|
||||
<style>
|
||||
body {
|
||||
max-width: 42rem;
|
||||
margin: 3rem auto;
|
||||
padding: 0 1rem;
|
||||
font:
|
||||
16px system-ui,
|
||||
sans-serif;
|
||||
}
|
||||
label,
|
||||
input,
|
||||
button {
|
||||
display: block;
|
||||
}
|
||||
input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
margin: 0.5rem 0 1rem;
|
||||
padding: 0.65rem;
|
||||
}
|
||||
#status {
|
||||
min-height: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>DockDeck New Tab</h1>
|
||||
<label for="dockdeck-url">DockDeck URL</label>
|
||||
<input
|
||||
id="dockdeck-url"
|
||||
type="url"
|
||||
required
|
||||
placeholder="http://127.0.0.1:1218/"
|
||||
/>
|
||||
<button id="save" type="button">Save</button>
|
||||
<p id="status" role="status"></p>
|
||||
<script src="options.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,21 @@
|
||||
/* global chrome, document, URL */
|
||||
|
||||
const input = document.querySelector("#dockdeck-url");
|
||||
const status = document.querySelector("#status");
|
||||
|
||||
chrome.storage.sync
|
||||
.get({ dockdeckUrl: "http://127.0.0.1:1218/" })
|
||||
.then(({ dockdeckUrl }) => {
|
||||
input.value = dockdeckUrl;
|
||||
});
|
||||
|
||||
document.querySelector("#save").addEventListener("click", async () => {
|
||||
try {
|
||||
const url = new URL(input.value.trim());
|
||||
if (!["http:", "https:"].includes(url.protocol)) throw new Error();
|
||||
await chrome.storage.sync.set({ dockdeckUrl: url.href });
|
||||
status.textContent = "Saved.";
|
||||
} catch {
|
||||
status.textContent = "Enter a valid HTTP(S) URL.";
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0"?>
|
||||
<Container version="2">
|
||||
<Name>DockDeck</Name>
|
||||
<Repository>dockdeck:local</Repository>
|
||||
<Network>bridge</Network>
|
||||
<Shell>sh</Shell>
|
||||
<Privileged>false</Privileged>
|
||||
<Support>https://gitea.itworx.tech/Jens/DockDeck-Public/issues</Support>
|
||||
<Project>https://gitea.itworx.tech/Jens/DockDeck-Public</Project>
|
||||
<Overview>DockDeck is a premium, read-only personal start dashboard for Unraid. This single-container profile uses the existing AppOps inventory API and never mounts the Docker socket.</Overview>
|
||||
<Category>Tools:Status:</Category>
|
||||
<WebUI>http://[IP]:[PORT:1218]</WebUI>
|
||||
<Icon>http://[IP]:[PORT:1218]/itworx-app-icon.png</Icon>
|
||||
<ExtraParams>--read-only --security-opt=no-new-privileges:true</ExtraParams>
|
||||
<PostArgs/>
|
||||
<CPUset/>
|
||||
<DateInstalled/>
|
||||
<DonateText/>
|
||||
<DonateLink/>
|
||||
<Requires>A read-only AppOps endpoint and an operator password hash generated with the included helper.</Requires>
|
||||
<Config Name="WebUI" Target="3000" Default="1218" Mode="tcp" Description="DockDeck WebUI" Type="Port" Display="always" Required="true" Mask="false">1218</Config>
|
||||
<Config Name="App data" Target="/data" Default="/path/to/dockdeck/data" Mode="rw" Description="Persistent database and integration settings" Type="Path" Display="always" Required="true" Mask="false">/path/to/dockdeck/data</Config>
|
||||
<Config Name="Template metadata" Target="/unraid-templates" Default="/path/to/sanitized/unraid-templates" Mode="ro" Description="Sanitized Unraid template metadata" Type="Path" Display="advanced" Required="true" Mask="false">/path/to/sanitized/unraid-templates</Config>
|
||||
<Config Name="Unraid runtime" Target="/unraid-runtime" Default="/var/local/emhttp" Mode="ro" Description="Read-only array and parity status" Type="Path" Display="advanced" Required="true" Mask="false">/var/local/emhttp</Config>
|
||||
<Config Name="Hardware sensors" Target="/host-hwmon" Default="/sys/class/hwmon" Mode="ro" Description="Read-only host temperature sensors" Type="Path" Display="advanced" Required="true" Mask="false">/sys/class/hwmon</Config>
|
||||
<Config Name="CPU information" Target="/host-proc/cpuinfo" Default="/proc/cpuinfo" Mode="ro" Description="Read-only host CPU capacity" Type="Path" Display="advanced" Required="true" Mask="false">/proc/cpuinfo</Config>
|
||||
<Config Name="Memory information" Target="/host-proc/meminfo" Default="/proc/meminfo" Mode="ro" Description="Read-only host memory capacity" Type="Path" Display="advanced" Required="true" Mask="false">/proc/meminfo</Config>
|
||||
<Config Name="Integration settings file" Target="INTEGRATION_ENV_PATH" Default="/data/integrations.env" Mode="" Description="Persistent server-side integration settings" Type="Variable" Display="advanced" Required="true" Mask="false">/data/integrations.env</Config>
|
||||
<Config Name="Operator username" Target="DOCKDECK_OPERATOR_USER" Default="operator" Mode="" Description="Required login name for every route except health" Type="Variable" Display="always" Required="true" Mask="false">operator</Config>
|
||||
<Config Name="Operator password hash" Target="DOCKDECK_OPERATOR_PASSWORD_HASH" Default="" Mode="" Description="Required salt:hash value from scripts/hash-operator-password.mjs; never enter plaintext" Type="Variable" Display="always" Required="true" Mask="true"></Config>
|
||||
<Config Name="AppOps URL" Target="APPOPS_URL" Default="http://appops.local:1216" Mode="" Description="Read-only inventory API URL" Type="Variable" Display="always" Required="true" Mask="false">http://appops.local:1216</Config>
|
||||
<Config Name="Unraid host" Target="UNRAID_HOST" Default="host.local" Mode="" Description="Unraid hostname or LAN address" Type="Variable" Display="always" Required="true" Mask="false">host.local</Config>
|
||||
<Config Name="Unraid URL" Target="UNRAID_URL" Default="http://host.local" Mode="" Description="Unraid WebUI address" Type="Variable" Display="always" Required="true" Mask="false">http://host.local</Config>
|
||||
</Container>
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
container_name="${1:-DockDeck}"
|
||||
icon_url="${2:-}"
|
||||
template_path="/boot/config/plugins/dockerMan/templates-user/my-${container_name}.xml"
|
||||
persistent_dir="/var/lib/docker/unraid/images"
|
||||
runtime_dir="/usr/local/emhttp/state/plugins/dynamix.docker.manager/images"
|
||||
|
||||
fail() {
|
||||
printf 'DockDeck icon refresh failed: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[ "$(id -u)" -eq 0 ] || fail "run this helper as root on the Unraid host"
|
||||
|
||||
case "$container_name" in
|
||||
"" | *[!A-Za-z0-9_.-]*) fail "invalid container name" ;;
|
||||
esac
|
||||
|
||||
if [ -z "$icon_url" ] && [ -f "$template_path" ]; then
|
||||
icon_url="$(sed -n 's#.*<Icon>\(.*\)</Icon>.*#\1#p' "$template_path" | head -n 1)"
|
||||
fi
|
||||
|
||||
case "$icon_url" in
|
||||
http://* | https://*) ;;
|
||||
*) fail "provide an HTTP(S) icon URL or a user template containing <Icon>" ;;
|
||||
esac
|
||||
|
||||
temporary_icon="$(mktemp /tmp/dockdeck-icon.XXXXXX)"
|
||||
trap 'rm -f "$temporary_icon"' EXIT HUP INT TERM
|
||||
|
||||
curl --fail --silent --show-error --location \
|
||||
--connect-timeout 5 --max-time 20 \
|
||||
"$icon_url" --output "$temporary_icon"
|
||||
|
||||
[ "$(file -b --mime-type "$temporary_icon")" = "image/png" ] || \
|
||||
fail "the downloaded asset is not a real PNG"
|
||||
|
||||
mkdir -p "$persistent_dir" "$runtime_dir"
|
||||
install -m 0644 -o root -g root \
|
||||
"$temporary_icon" "$persistent_dir/${container_name}-icon.png"
|
||||
install -m 0644 -o root -g root \
|
||||
"$temporary_icon" "$runtime_dir/${container_name}-icon.png"
|
||||
|
||||
printf 'Refreshed both Unraid icon caches for %s from %s\n' \
|
||||
"$container_name" "$icon_url"
|
||||
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Copy only non-sensitive DockDeck metadata from Unraid templates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
|
||||
ALLOWED_FIELDS = ("Name", "WebUI", "Icon")
|
||||
|
||||
|
||||
def sanitize(source_dir: Path, destination_dir: Path) -> int:
|
||||
destination_dir.mkdir(parents=True, exist_ok=True)
|
||||
written = 0
|
||||
|
||||
for source in sorted(source_dir.glob("*.xml")):
|
||||
try:
|
||||
root = ET.parse(source).getroot()
|
||||
except (ET.ParseError, OSError):
|
||||
continue
|
||||
|
||||
output = ET.Element("Container")
|
||||
for field in ALLOWED_FIELDS:
|
||||
value = root.findtext(field)
|
||||
if value:
|
||||
ET.SubElement(output, field).text = value.strip()
|
||||
|
||||
if output.findtext("Name"):
|
||||
destination = destination_dir / source.name
|
||||
ET.ElementTree(output).write(
|
||||
destination,
|
||||
encoding="utf-8",
|
||||
xml_declaration=True,
|
||||
)
|
||||
destination.chmod(0o644)
|
||||
written_tags = {
|
||||
child.tag for child in ET.parse(destination).getroot()
|
||||
}
|
||||
if not written_tags.issubset(ALLOWED_FIELDS):
|
||||
destination.unlink(missing_ok=True)
|
||||
raise RuntimeError(
|
||||
f"Unexpected field in sanitized template: {source.name}"
|
||||
)
|
||||
written += 1
|
||||
|
||||
return written
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("source", type=Path)
|
||||
parser.add_argument("destination", type=Path)
|
||||
args = parser.parse_args()
|
||||
count = sanitize(args.source, args.destination)
|
||||
print(f"Sanitized and verified {count} Unraid templates")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,56 @@
|
||||
services:
|
||||
dockdeck:
|
||||
depends_on:
|
||||
dockerproxy:
|
||||
condition: service_started
|
||||
environment:
|
||||
APPOPS_URL: ""
|
||||
DOCKER_PROXY_URL: http://dockerproxy:2375
|
||||
networks:
|
||||
- dockdeck_internal
|
||||
|
||||
dockerproxy:
|
||||
image: ghcr.io/tecnativa/docker-socket-proxy@sha256:1f5038b54f06c3e18422902cf00ba21803d1c97805aae032e5e6673d532d3459
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
LOG_LEVEL: warning
|
||||
CONTAINERS: 1
|
||||
INFO: 1
|
||||
PING: 1
|
||||
VERSION: 1
|
||||
POST: 0
|
||||
AUTH: 0
|
||||
BUILD: 0
|
||||
COMMIT: 0
|
||||
CONFIGS: 0
|
||||
DISTRIBUTION: 0
|
||||
EVENTS: 0
|
||||
EXEC: 0
|
||||
IMAGES: 0
|
||||
NETWORKS: 0
|
||||
NODES: 0
|
||||
PLUGINS: 0
|
||||
SECRETS: 0
|
||||
SERVICES: 0
|
||||
SESSION: 0
|
||||
SWARM: 0
|
||||
SYSTEM: 0
|
||||
TASKS: 0
|
||||
VOLUMES: 0
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
networks:
|
||||
- dockdeck_internal
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /run
|
||||
- /tmp
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
networks:
|
||||
dockdeck_internal:
|
||||
internal: true
|
||||
ipam:
|
||||
config:
|
||||
- subnet: ${DOCKDECK_INTERNAL_SUBNET:-172.31.240.0/28}
|
||||
@@ -0,0 +1,126 @@
|
||||
services:
|
||||
dockdeck:
|
||||
container_name: DockDeck
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
DOCKDECK_BUILD_VERSION: "${DOCKDECK_BUILD_VERSION:-0.1.0}"
|
||||
DOCKDECK_BUILD_REVISION: "${GITEA_COMMIT_SHA:-${APPOPS_SOURCE_REVISION:-${DOCKDECK_BUILD_REVISION:-development}}}"
|
||||
DOCKDECK_BUILD_DATE: "${APPOPS_BUILD_DATE:-${DOCKDECK_BUILD_DATE:-1970-01-01T00:00:00.000Z}}"
|
||||
DOCKDECK_BUILD_SOURCE: "${DOCKDECK_BUILD_SOURCE:-jens/dockdeck}"
|
||||
image: dockdeck:local
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
org.opencontainers.image.title: DockDeck
|
||||
org.opencontainers.image.description: Premium read-only Unraid start dashboard
|
||||
org.opencontainers.image.source: ${DOCKDECK_BUILD_SOURCE:-jens/dockdeck}
|
||||
net.unraid.docker.managed: dockerman
|
||||
net.unraid.docker.webui: "http://${UNRAID_HOST:-host.local}:${DOCKDECK_PORT:-1218}"
|
||||
net.unraid.docker.icon: "http://${UNRAID_HOST:-host.local}:${DOCKDECK_PORT:-1218}/itworx-app-icon.png"
|
||||
environment:
|
||||
HOST: 0.0.0.0
|
||||
PORT: 3000
|
||||
DATABASE_PATH: /data/dockdeck.db
|
||||
INTEGRATION_ENV_PATH: /data/integrations.env
|
||||
DOCKDECK_OPERATOR_USER: ${DOCKDECK_OPERATOR_USER:-}
|
||||
DOCKDECK_OPERATOR_PASSWORD_HASH: ${DOCKDECK_OPERATOR_PASSWORD_HASH:-}
|
||||
APPOPS_URL: ${APPOPS_URL:-}
|
||||
DOCKER_PROXY_URL: ${DOCKER_PROXY_URL:-}
|
||||
UNRAID_TEMPLATES_PATH: /unraid-templates
|
||||
UNRAID_ICONS_SOURCE_PATH: /unraid-icons-source
|
||||
UNRAID_ICONS_PATH: /unraid-icons-cache
|
||||
UNRAID_RUNTIME_PATH: /unraid-runtime
|
||||
UNRAID_HWMON_PATH: /host-hwmon
|
||||
UNRAID_CPUINFO_PATH: /host-proc/cpuinfo
|
||||
UNRAID_MEMINFO_PATH: /host-proc/meminfo
|
||||
UNRAID_UPS_STATUS_PATH: ${UNRAID_UPS_STATUS_PATH:-}
|
||||
UNRAID_HOST: ${UNRAID_HOST:-host.local}
|
||||
UNRAID_URL: ${UNRAID_URL:-}
|
||||
MOCK_DISCOVERY: "false"
|
||||
POLLING_INTERVAL_MS: ${POLLING_INTERVAL_MS:-30000}
|
||||
NPM_URL: ${NPM_URL:-}
|
||||
NPM_TOKEN: ${NPM_TOKEN:-}
|
||||
NPM_USERNAME: ${NPM_USERNAME:-}
|
||||
NPM_PASSWORD: ${NPM_PASSWORD:-}
|
||||
ADGUARD_URL: ${ADGUARD_URL:-}
|
||||
ADGUARD_USERNAME: ${ADGUARD_USERNAME:-}
|
||||
ADGUARD_PASSWORD: ${ADGUARD_PASSWORD:-}
|
||||
PLEX_URL: ${PLEX_URL:-}
|
||||
PLEX_TOKEN: ${PLEX_TOKEN:-}
|
||||
IMMICH_URL: ${IMMICH_URL:-}
|
||||
IMMICH_API_KEY: ${IMMICH_API_KEY:-}
|
||||
HOME_ASSISTANT_URL: ${HOME_ASSISTANT_URL:-}
|
||||
HOME_ASSISTANT_TOKEN: ${HOME_ASSISTANT_TOKEN:-}
|
||||
OLLAMA_URL: ${OLLAMA_URL:-}
|
||||
GLANCES_URL: ${GLANCES_URL:-}
|
||||
TAUTULLI_URL: ${TAUTULLI_URL:-}
|
||||
TAUTULLI_API_KEY: ${TAUTULLI_API_KEY:-}
|
||||
PAPERLESS_URL: ${PAPERLESS_URL:-}
|
||||
PAPERLESS_TOKEN: ${PAPERLESS_TOKEN:-}
|
||||
SONARR_URL: ${SONARR_URL:-}
|
||||
SONARR_API_KEY: ${SONARR_API_KEY:-}
|
||||
RADARR_URL: ${RADARR_URL:-}
|
||||
RADARR_API_KEY: ${RADARR_API_KEY:-}
|
||||
LIDARR_URL: ${LIDARR_URL:-}
|
||||
LIDARR_API_KEY: ${LIDARR_API_KEY:-}
|
||||
GITEA_WIDGET_URL: ${GITEA_WIDGET_URL:-}
|
||||
GITEA_WIDGET_TOKEN: ${GITEA_WIDGET_TOKEN:-}
|
||||
JELLYFIN_URL: ${JELLYFIN_URL:-}
|
||||
JELLYFIN_API_KEY: ${JELLYFIN_API_KEY:-}
|
||||
SEERR_URL: ${SEERR_URL:-}
|
||||
SEERR_API_KEY: ${SEERR_API_KEY:-}
|
||||
PROWLARR_URL: ${PROWLARR_URL:-}
|
||||
PROWLARR_API_KEY: ${PROWLARR_API_KEY:-}
|
||||
AUTHENTIK_URL: ${AUTHENTIK_URL:-}
|
||||
AUTHENTIK_TOKEN: ${AUTHENTIK_TOKEN:-}
|
||||
PORTAINER_URL: ${PORTAINER_URL:-}
|
||||
PORTAINER_API_KEY: ${PORTAINER_API_KEY:-}
|
||||
GRAFANA_URL: ${GRAFANA_URL:-}
|
||||
GRAFANA_TOKEN: ${GRAFANA_TOKEN:-}
|
||||
PROMETHEUS_URL: ${PROMETHEUS_URL:-}
|
||||
NEXTCLOUD_URL: ${NEXTCLOUD_URL:-}
|
||||
NEXTCLOUD_USERNAME: ${NEXTCLOUD_USERNAME:-}
|
||||
NEXTCLOUD_APP_PASSWORD: ${NEXTCLOUD_APP_PASSWORD:-}
|
||||
AUDIOBOOKSHELF_URL: ${AUDIOBOOKSHELF_URL:-}
|
||||
AUDIOBOOKSHELF_TOKEN: ${AUDIOBOOKSHELF_TOKEN:-}
|
||||
NETDATA_URL: ${NETDATA_URL:-}
|
||||
PEERTUBE_URL: ${PEERTUBE_URL:-}
|
||||
DELUGE_METRICS_URL: ${DELUGE_METRICS_URL:-}
|
||||
DELUGE_METRICS_TOKEN: ${DELUGE_METRICS_TOKEN:-}
|
||||
QBITTORRENT_METRICS_URL: ${QBITTORRENT_METRICS_URL:-}
|
||||
QBITTORRENT_METRICS_TOKEN: ${QBITTORRENT_METRICS_TOKEN:-}
|
||||
JDOWNLOADER_METRICS_URL: ${JDOWNLOADER_METRICS_URL:-}
|
||||
JDOWNLOADER_METRICS_TOKEN: ${JDOWNLOADER_METRICS_TOKEN:-}
|
||||
TDARR_METRICS_URL: ${TDARR_METRICS_URL:-}
|
||||
TDARR_METRICS_TOKEN: ${TDARR_METRICS_TOKEN:-}
|
||||
BAZARR_METRICS_URL: ${BAZARR_METRICS_URL:-}
|
||||
BAZARR_METRICS_TOKEN: ${BAZARR_METRICS_TOKEN:-}
|
||||
VAULTWARDEN_METRICS_URL: ${VAULTWARDEN_METRICS_URL:-}
|
||||
VAULTWARDEN_METRICS_TOKEN: ${VAULTWARDEN_METRICS_TOKEN:-}
|
||||
ports:
|
||||
- "${DOCKDECK_BIND_ADDRESS:-127.0.0.1}:${DOCKDECK_PORT:-1218}:3000"
|
||||
volumes:
|
||||
- dockdeck_data:/data
|
||||
- ${UNRAID_TEMPLATES_DIR:-./fixtures/unraid-templates}:/unraid-templates:ro
|
||||
- ${UNRAID_ICONS_DIR:-/boot/config/plugins/dockerMan/images}:/unraid-icons-source:ro
|
||||
- ${UNRAID_RUNTIME_DIR:-/var/local/emhttp}:/unraid-runtime:ro
|
||||
- ${UNRAID_HWMON_DIR:-/sys/class/hwmon}:/host-hwmon:ro
|
||||
- ${UNRAID_CPUINFO_FILE:-/proc/cpuinfo}:/host-proc/cpuinfo:ro
|
||||
- ${UNRAID_MEMINFO_FILE:-/proc/meminfo}:/host-proc/meminfo:ro
|
||||
networks:
|
||||
- dockdeck_egress
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
- /unraid-icons-cache:size=64m,mode=0755
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
networks:
|
||||
dockdeck_egress:
|
||||
ipam:
|
||||
config:
|
||||
- subnet: ${DOCKDECK_EGRESS_SUBNET:-172.31.240.16/28}
|
||||
|
||||
volumes:
|
||||
dockdeck_data:
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
icon_source="${UNRAID_ICONS_SOURCE_PATH:-/unraid-icons-source}"
|
||||
icon_cache="${UNRAID_ICONS_PATH:-/unraid-icons-cache}"
|
||||
|
||||
if [ -d "$icon_source" ] && [ -d "$icon_cache" ]; then
|
||||
find "$icon_source" -maxdepth 1 -type f \( \
|
||||
-iname '*.gif' -o -iname '*.ico' -o -iname '*.jpeg' -o \
|
||||
-iname '*.jpg' -o -iname '*.png' -o -iname '*.svg' -o \
|
||||
-iname '*.webp' \
|
||||
\) -exec cp -f '{}' "$icon_cache/" \;
|
||||
find "$icon_cache" -maxdepth 1 -type f -exec chmod 0644 '{}' \;
|
||||
chown -R dockdeck:dockdeck "$icon_cache"
|
||||
fi
|
||||
|
||||
exec su-exec dockdeck "$@"
|
||||
@@ -0,0 +1,45 @@
|
||||
# DockDeck architecture
|
||||
|
||||
## Runtime
|
||||
|
||||
DockDeck is one deployable Node.js process. Fastify owns the REST API and serves the compiled React client in production. React/Vite provides the development UI. SQLite is mounted at `/data`; Drizzle ORM owns typed reads and writes while a small idempotent SQL migration creates the first schema before Fastify starts.
|
||||
|
||||
```text
|
||||
Browser → Fastify API → domain rules → Drizzle → SQLite
|
||||
↘ discovery service → AppOps inventory (GET only)
|
||||
→ Unraid XML/runtime mounts (read only)
|
||||
→ NPM API (read only, optional)
|
||||
```
|
||||
|
||||
## Trust boundaries
|
||||
|
||||
- The `dockdeck` container never receives `/var/run/docker.sock`.
|
||||
- The standard deployment is exactly one `DockDeck` container. A separate `dockerproxy` exists only in the optional fallback overlay; there it is private, has `POST=0` and disables all mutation families.
|
||||
- Unraid templates are mounted read-only.
|
||||
- NPM, Gitea and provider credentials exist only as server-side process environment. Values entered in Settings are persisted in the permission-restricted environment file and remain excluded from API responses, SQLite and JSON exports.
|
||||
- Fastify validates every user-controlled mutation with Zod. External integration errors are reduced to non-sensitive connection states.
|
||||
- New discoveries are inserted with `visible=false`. Subsequent syncs update runtime fields but preserve user choices and overrides.
|
||||
- A DockDeck-only app removal records the technical name in `removed_apps`; later discovery ignores that name and never calls a Docker mutation endpoint.
|
||||
|
||||
## Modules
|
||||
|
||||
- `src/shared`: Zod contracts and pure URL/search/matching/sorting rules.
|
||||
- URL resolution prefers an explicit `br0` LAN address, then the configured Unraid host, and uses a single unambiguous published TCP port when template metadata is stale.
|
||||
- Dashboard search is a derived client index over the latest payload, so renames, removals, category changes and URL changes require no separate search persistence.
|
||||
- `src/server/database.ts`: persistence, migration, import/export and user preference invariants.
|
||||
- Widget layout is stored as validated size/content, metric order/visibility/style, statistic limit, warning and chart fields on app and preference records. Legacy rows receive safe standard/all-content defaults; version 1 backups remain compatible because added backup fields are optional on import.
|
||||
- `src/server/integrations`: read-only external adapters, provider capability metadata and deterministic mocks. Provider URLs default to an already discovered local app origin; explicit environment overrides remain available. Provider recognition is token-based and excludes known support-container roles.
|
||||
- `src/server/integrations/unraid-status.ts`: parseert een minimale read-only view van Unraid runtime- en hwmondata naar array-, parity-, opslag-, temperatuur- en optionele UPS-metrics.
|
||||
- Provideradapters bewaren alleen de laatst succesvolle gereduceerde metricresponse en een korte tijdreeks in procesgeheugen; geen van beide overleeft een procesrestart.
|
||||
- `src/server/app.ts`: narrow REST surface; no Docker control routes.
|
||||
- `src/client`: accessible responsive UI and design tokens.
|
||||
|
||||
## Layout and offline model
|
||||
|
||||
Widgetvolgorde staat los van apptegelvolgorde. App- en hostrecords bewaren hun eigen volgorde; layoutpresets bevatten alleen presentatievelden, inclusief metricselectie, value/gauge/progress, waarschuwing en lijn-/vlak-/staafgrafiek. Provider- en hosthistoriek bevat maximaal zestig punten per numerieke reeks en blijft uitsluitend in procesgeheugen. Het toepassen van een preset kan geen appzichtbaarheid, navigatie-URL, credential of Dockerstaat wijzigen.
|
||||
|
||||
De serviceworker cachet alleen statische applicatieshellassets. Requests onder `/api/` worden volledig overgeslagen, zodat offline content nooit als actuele runtime- of providerdata wordt gepresenteerd.
|
||||
|
||||
## Failure behavior
|
||||
|
||||
Saved data is always returned independently of integration health. A failed AppOps inventory, missing Unraid mount or unavailable NPM instance produces a degraded integration card/banner without blocking navigation. Polling runs only while the browser tab is visible. AppOps inventory is read immediately; its slower all-container resource sample refreshes in the background so it cannot delay navigation.
|
||||
@@ -0,0 +1,14 @@
|
||||
# External access
|
||||
|
||||
DockDeck defaults to loopback publication. Keep that default unless network access is intentional.
|
||||
|
||||
For a trusted LAN:
|
||||
|
||||
1. Generate an operator password hash with `node scripts/hash-operator-password.mjs`.
|
||||
2. Set both `DOCKDECK_OPERATOR_USER` and `DOCKDECK_OPERATOR_PASSWORD_HASH`.
|
||||
3. Set `DOCKDECK_BIND_ADDRESS` to the intended host interface.
|
||||
4. Restrict access with host or network firewall rules.
|
||||
|
||||
For access beyond the trusted LAN, place DockDeck behind an authenticated HTTPS reverse proxy. Do not expose the optional Docker socket proxy, AppOps, or provider APIs through that proxy. HTTP Basic credentials must not be sent over plaintext internet connections.
|
||||
|
||||
JSON configuration exports exclude credentials but can identify services and URLs. Treat them as sensitive operational data.
|
||||
|
After Width: | Height: | Size: 602 KiB |
|
After Width: | Height: | Size: 152 KiB |
@@ -0,0 +1,34 @@
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist", "coverage", "playwright-report", "test-results"] },
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
files: ["scripts/**/*.mjs"],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
globals: { ...globals.node, fetch: "readonly" },
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
globals: { ...globals.browser, ...globals.node },
|
||||
},
|
||||
plugins: { "react-hooks": reactHooks, "react-refresh": reactRefresh },
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"react-refresh/only-export-components": [
|
||||
"warn",
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
# Unraid template fixture mount
|
||||
|
||||
This empty directory makes local Compose validation possible. On Unraid, set `UNRAID_TEMPLATES_DIR=/boot/config/plugins/dockerMan/templates-user` so DockDeck can read the real XML templates.
|
||||
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0b1326" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="icon" href="/dockdeck-mark-v2.svg" type="image/svg+xml" />
|
||||
<meta
|
||||
name="description"
|
||||
content="ITWorx DockDeck — je rustige startpunt voor al je self-hosted apps."
|
||||
/>
|
||||
<title>ITWorx DockDeck</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/client/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"name": "dockdeck",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently -k -n api,web -c cyan,magenta \"tsx watch src/server/index.ts\" \"vite --host 127.0.0.1\"",
|
||||
"dev:server": "tsx watch src/server/index.ts",
|
||||
"dev:web": "vite --host 127.0.0.1",
|
||||
"build": "node scripts/build.mjs",
|
||||
"start": "node dist/server/index.js",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"lint": "eslint . --max-warnings=0",
|
||||
"typecheck": "tsc -b --pretty false",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"test:security": "vitest run src/tests/security.test.ts",
|
||||
"gitea:init": "node scripts/init-gitea.mjs"
|
||||
},
|
||||
"overrides": {
|
||||
"brace-expansion": "1.1.21",
|
||||
"glob": {
|
||||
"brace-expansion": "5.0.12"
|
||||
},
|
||||
"@typescript-eslint/typescript-estree": {
|
||||
"brace-expansion": "5.0.12"
|
||||
},
|
||||
"fast-uri": "3.1.8",
|
||||
"fast-json-stringify": {
|
||||
"fast-uri": "4.1.5"
|
||||
},
|
||||
"baseline-browser-mapping": "2.11.23",
|
||||
"browserslist": "4.28.9",
|
||||
"find-my-way": "9.7.0",
|
||||
"nanoid": "3.3.18",
|
||||
"@eslint/eslintrc": {
|
||||
"js-yaml": "5.4.1"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/static": "^10.1.0",
|
||||
"@fontsource-variable/geist": "^5.3.0",
|
||||
"@fontsource/newsreader": "^5.2.10",
|
||||
"@icons-pack/react-simple-icons": "13.8.0",
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"entities": "^6.0.1",
|
||||
"fastify": "^5.12.4",
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"zod": "^4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.35.0",
|
||||
"@playwright/test": "^1.55.0",
|
||||
"@testing-library/jest-dom": "^6.8.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@vitejs/plugin-react": "^5.0.2",
|
||||
"concurrently": "^9.2.1",
|
||||
"esbuild": "^0.25.9",
|
||||
"eslint": "^9.35.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"prettier": "^3.6.2",
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.42.0",
|
||||
"vite": "^7.1.4",
|
||||
"vitest": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const apiPort = process.env.E2E_API_PORT ?? "3101";
|
||||
const clientPort = process.env.E2E_CLIENT_PORT ?? "5174";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
reporter: "list",
|
||||
use: {
|
||||
baseURL: `http://127.0.0.1:${clientPort}`,
|
||||
trace: "retain-on-failure",
|
||||
},
|
||||
webServer: {
|
||||
command: "npm run dev",
|
||||
url: `http://127.0.0.1:${apiPort}/api/health`,
|
||||
reuseExistingServer: false,
|
||||
timeout: 120_000,
|
||||
env: {
|
||||
MOCK_DISCOVERY: "true",
|
||||
MOCK_WEATHER: "true",
|
||||
DATABASE_PATH: "./data/e2e-canon-v1.db",
|
||||
HOST: "127.0.0.1",
|
||||
PORT: apiPort,
|
||||
VITE_API_PORT: apiPort,
|
||||
VITE_PORT: clientPort,
|
||||
},
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "desktop-chromium",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
viewport: { width: 1440, height: 900 },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tablet-chromium",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
viewport: { width: 768, height: 1024 },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mobile-chromium",
|
||||
use: {
|
||||
...devices["Pixel 7"],
|
||||
viewport: { width: 390, height: 844 },
|
||||
deviceScaleFactor: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ultrawide-chromium",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
viewport: { width: 3440, height: 1440 },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fullhd-chromium",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
After Width: | Height: | Size: 98 KiB |
@@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-labelledby="title">
|
||||
<title id="title">DockDeck</title>
|
||||
<defs>
|
||||
<linearGradient id="shell" x1="64" y1="42" x2="448" y2="470" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#243732"/>
|
||||
<stop offset="1" stop-color="#111b19"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="deck" x1="142" y1="134" x2="370" y2="372" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#ff8b6e"/>
|
||||
<stop offset="1" stop-color="#d9563d"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="20" y="20" width="472" height="472" rx="122" fill="url(#shell)"/>
|
||||
<rect x="42" y="42" width="428" height="428" rx="103" fill="none" stroke="#fff" stroke-opacity=".08" stroke-width="4"/>
|
||||
<path d="M126 151c0-20 16-36 36-36h103c80 0 145 65 145 145v3c0 74-60 134-134 134H162c-20 0-36-16-36-36V151Z" fill="#f5f4ed"/>
|
||||
<path d="M187 181h78c44 0 80 36 80 80s-36 80-80 80h-78V181Z" fill="url(#deck)"/>
|
||||
<rect x="213" y="214" width="82" height="14" rx="7" fill="#fff" fill-opacity=".9"/>
|
||||
<rect x="213" y="249" width="59" height="14" rx="7" fill="#fff" fill-opacity=".72"/>
|
||||
<rect x="213" y="284" width="71" height="14" rx="7" fill="#fff" fill-opacity=".55"/>
|
||||
<circle cx="378" cy="135" r="26" fill="#8bd5b5" stroke="#17221f" stroke-width="10"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-labelledby="title">
|
||||
<title id="title">DockDeck beeldmerk voor donkere achtergrond</title>
|
||||
<path d="M9 7h13.5C33 7 41 15 41 25.5S33 44 22.5 44H9V7Z" fill="none" stroke="#818cf8" stroke-width="3.5" stroke-linejoin="round"/>
|
||||
<path d="M7.5 27.5h33M11 27.5l9-11 18 11M20 17v17" fill="none" stroke="#818cf8" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 36c4-2.5 8-2.5 12 0s8 2.5 12 0 6-2.5 9-1" fill="none" stroke="#818cf8" stroke-width="3" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 584 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-labelledby="title">
|
||||
<title id="title">DockDeck beeldmerk voor lichte achtergrond</title>
|
||||
<path d="M9 7h13.5C33 7 41 15 41 25.5S33 44 22.5 44H9V7Z" fill="none" stroke="#4f56cf" stroke-width="3.5" stroke-linejoin="round"/>
|
||||
<path d="M7.5 27.5h33M11 27.5l9-11 18 11M20 17v17" fill="none" stroke="#4f56cf" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 36c4-2.5 8-2.5 12 0s8 2.5 12 0 6-2.5 9-1" fill="none" stroke="#4f56cf" stroke-width="3" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 583 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-labelledby="title">
|
||||
<title id="title">DockDeck monochroom beeldmerk</title>
|
||||
<path d="M9 7h13.5C33 7 41 15 41 25.5S33 44 22.5 44H9V7Z" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linejoin="round"/>
|
||||
<path d="M7.5 27.5h33M11 27.5l9-11 18 11M20 17v17" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 36c4-2.5 8-2.5 12 0s8 2.5 12 0 6-2.5 9-1" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 585 B |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title desc">
|
||||
<title id="title">DockDeck</title>
|
||||
<desc id="desc">Een geometrische D met een aanlegkanaal en twee gestapelde dekken.</desc>
|
||||
<path fill="#36558f" d="M8 7h22c16.6 0 27 9.6 27 25S46.6 57 30 57H8V43h13V21H8V7Zm25 14v22c6.5-.8 10-4.4 10-11s-3.5-10.2-10-11Z"/>
|
||||
<path fill="#12bff3" d="m18 28 10-6v18l-10 6-10-6V28l10 6 10-6Z"/>
|
||||
<path fill="#17d7b0" d="m18 46 13-7 13 7-13 7-13-7Zm13-3-5.5 3 5.5 3 5.5-3-5.5-3Z"/>
|
||||
<path fill="#12bff3" d="m31 52 13-7v7l-13 7-13-7v-7l13 7Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 589 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-labelledby="title">
|
||||
<title id="title">DockDeck</title>
|
||||
<rect width="48" height="48" rx="12" fill="#818cf8"/>
|
||||
<path d="M13 13h9.5C30 13 36 19 36 26.5S30 40 22.5 40H13V13Z" fill="none" stroke="#0b1326" stroke-width="3.4" stroke-linejoin="round"/>
|
||||
<path d="M11.5 28.5h25M15 28.5l7-8 13 8M21 21v13" fill="none" stroke="#0b1326" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12 35c3-2 6-2 9 0s6 2 9 0 5-2 7-.8" fill="none" stroke="#0b1326" stroke-width="2.8" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 602 B |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 236 48" role="img" aria-labelledby="title">
|
||||
<title id="title">DockDeck woordmerk</title>
|
||||
<g transform="translate(0)">
|
||||
<path d="M9 7h13.5C33 7 41 15 41 25.5S33 44 22.5 44H9V7Z" fill="none" stroke="#818cf8" stroke-width="3.5" stroke-linejoin="round"/>
|
||||
<path d="M7.5 27.5h33M11 27.5l9-11 18 11M20 17v17" fill="none" stroke="#818cf8" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 36c4-2.5 8-2.5 12 0s8 2.5 12 0 6-2.5 9-1" fill="none" stroke="#818cf8" stroke-width="3" stroke-linecap="round"/>
|
||||
</g>
|
||||
<text x="57" y="32" fill="#eef0ff" font-family="Geist, Inter, system-ui, sans-serif" font-size="26" font-weight="650" letter-spacing="-1">DockDeck</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 760 B |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
width="904" height="198" viewBox="0 0 904 198"
|
||||
role="img" aria-labelledby="title desc">
|
||||
<title id="title">ITWorx.tech logo</title>
|
||||
<desc id="desc">Cyan cloud, green checkmark, navy ITWorx wordmark and cyan dot-tech suffix.</desc>
|
||||
<image width="904" height="198"
|
||||
href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAA4gAAADGCAYAAACQCq5PAAEAAElEQVR42ux9ebxlR1X12rvOufe+see5O0lnIiQdwjzJFKYAGoYgQUThiwIqk4qt8In4AaIoRsABBEFRZDIyg8wKChLCTOjMc7rT6bn7ze/ec2qv74+qc+55Nw8SoJN0umvl13nv3eHcOlV17qlVe++1BAkJhxFLPn1omRRc0psvlom51dg7cR+Q68tCRkEeL6WNoTTQWEKcsWP7hBwiAPVi6rCPwr1oYQ81260tbWXjw9uK0fyWzohMHnzC8onUywkJCQkJCQkJCQl3DiR1QcJPjPfc0FkyNLyhmOFjejunnq6+2FzSrZGs3WaeDVs7U223TIfaarlCFSItV/g8I8Q7QLyJyzSH2jRM1ERoBEkADiThjeKhnC8p3XnBbNcLy9IV8zTKdFb6Q9Jqfw2r29ullX8kH23ddOgZyw6lwUlISEhISEhISEhIBDHhTsJxnz60bPLg3Fm9Xd2X9Ca6D2fmVkhnyFlnKJNOG7ZMgeF2iZZ4tiEQlK6AeAIo4CQDafAgFSaCnASFYfZRAREYDAIFoXAoUUKhMBBOHT2dEARE0DLCwwAp4bLZ0uVTsyVnewXnempFd16KciIfdp/SVa1/nXzepkvSCCYkJCQkJCQkJCQkgpjwM2DtRVOr5qYnn9Y7NPWSkqP3pRumLBkxG8o8MnVoAXQAPYxteNCLmAAFjCoOpAeRi6qnwEGMEAhMSxU4AwAYASWADALCQeGBSA09SABCKcoSAqUQYkJkUIpSMnrCqXQ9JJdSuhBXUqniON+jTE6LnzrUa/nyxvZw+wNu+ZL3HviVFTvS6CYkJCQkJCQkJCQkgpjwY7DkY7tPmtvZvaC8dfY3pTO03K9bRlk9qmxLAYWDh4IADABhQP23IVA/QAGABoqCkfwpEF+RgQCI8JiAsPCOgRlo8WgA40+DhwLwdMjEw+K7GN/v6uhj+B0gPAVk+OycxERXsHMKMjHVbeX6Hbe68zuzv7Hhu2nkExISEhISEhISEhJBTACw7KIDSyZ3Hnwrd889C0uWdrh+pXJ9u4RGAlhC4j8HwsNFumeR4gEAQ94nFGUgbiQgGSS+TmEAFRQfKZ+AyID4LCMNZKSdWaSD4RkHAlD6+LxBRAA6qEh4JwPt9BLeVRHMirIGIkq0YWiBKCFyYD7XWyfAfRPzbgifHDm+9cZDz938/TQjEhISEhISEhISEhJBPOYw+k97H93dOfFuZqMnY/lScH1mvuMMBYgeBS0BPBUmhIPAAAgEHgaFQCHwDTLHeh4ZGP9yUFiM9QXeqBCpKg0VAkMVX6yO16d3Bhd/JwRCD4o0ZmsglRKPwppOCnx8LlDDQCslRhoJQwYgB5Ejl3kC++eYHZxgNjkzkaF8D05Y8sapX16/L82ShISEhISEhISERBATjlos/+D+TXPbD71tvsSjuXrluFs5XvocigxEEeaACJRV5E/gQQogVdyvImiB5AkJEY2EsIoWBoKmNYnrxweljjp6WE3vAqnsUz0HwMdqRCKkkQIWJWsCNe3BI4vHEoAGiEHrRNPQAqnTYANhDe8HPEoQGYwGJ0AOE6NlXZIzc23sPtTrHDx0WWfj8hft+831304zJyEhISEhISEhIRHEhKMGo/900+nzN8x/GEuXnsrV484vbXu0xNCDwkfKBZTQSLoYH+3/jprkVRV+WfwLkcQZykj1QhJqRe2krjqsHnORpIVXh0RSaySwsn5XpH/hvSxCHiuySCHRoKIVKaw+ox9jlEgRJcYRw/F0QYsASoYeiQw5vGQoZdo6un2C2Y6dB9ublz134oUbvphmUkJCQkJCQkJCQiKICfdYjP3L3lO7V+3/SjG+bB2PW15iWSbIUKJEpgZnVVVfBtegT2gQqVAz6Gr6JQsicxKlZ1jTskAsLVQNxplVpX1aTdoqSijxlVJTyopUaoxHEh6KUM1YRFrqIbEuUeDhkcVPskgtpZ7PEs/ARcJZRS8BF/8qY+tEiXlTqCshHgRGADgp8wk47p7MdMf2Q+2NQ8+fetFJn04zKyEhISEhISEhIRHEhHsMlly0a/PsVYe+gvGl6+S4VdYbUXUl6B1y9CIpQ02lghxNDg8PrRM9pSZdgMLVtXxBzbRK2QyvDATSIrV08RglqopCREGafmwwa1QOoiZsfkEiK+PphBawtrxAbHU/cRRR9kZqPdVAOKt6xtDGPuEl8jruGSmpZPSgGAAokWkJb5mZz5Tahgzv6pW2czL3kwdvGT5x/NyDz1u7Lc20hISEhISEhISERBATjliM/cuOFTM3Hfy8ay09Uzeu836J0xJmmqkzQ44CVQwwkLYqJkiUMYrYVygVhGTSItKxKtWzkqHRmshVNNKAGBUMR8jq2KLGRFXWVhQLI30aj+hruhf9EuPf4Sg+1idKI3IZ6iAhVbqoi69mrYEaKKuv4559ytlPYCW8ODOKiBgIisSoI2EUCujYK7JOZjIN9dceMH9g13XLtyx9yr7nHLczzbyEhISEhISEhIREEBOOKLjXbf+Kpz4aJy4H1nQCnQq0KcTQCjiAAid9MkcaIApDGSsDo6VETL1EJUYTCV+fsAVaGJ6RWAvoGuSropKVWAzrisMw2yzWAVotRbMQVlO4vgZqGQlgX0sVDZJaKZgyvjZERqv4JBtOjdI4vo+prr4mlWjUTFr8WyL5bKGqiyxxyDS74iaHmemrOvda9cjpX1u3N83ChISEhISEhISERBAT7laMv/WKc+Ym8ZFi84YRbBgrUULQrUlRP0roCahYHf1jpIQVoatSSPu6oZXi6MKawyrJ1KL0SyUCU0Sqhfo4WaBiUSlUCFAQiZjAw0W6F6KLUscvAUQDDcSIowHR5oJ9YRoqRAK9NAog1qCKoZX9JNJqlvv6iEQWz1vr90j0YaxrEy2DqUFJUDIIeiAEbaCu27x5xsmV10t+yvirey884c/SjExISEhISEhISEgEMeFuwfCbrvx8b2j5E8uNy4FRBxQAejC4qA7qG2IxaMT6FISrLSGsJlH9lNMq3lelZUojLTSQMGmYUEikVFoTRAVQRB9EiZFGXxPOEDl0CJE+F1NL+8IyfVJXxRbD8RgJbz8WyQWOif2zwAIdVC4gnH3DDSwgiKzpKesk1GChISI15a4/h6H2si1dzBL5DfsyzB364cjqVWcfumDZoTQ7ExISEhISEhISEkFMuGuI4T/e/MC5Pfxvrl81jFVDwddPa/IE9H0F2aBSfR1PwiB0MeqmMXqGBhlrWtb3j6axTlBq2lcZ1AOIUUUdELnROvKImMQZUk99TO2UOjGU/brCGOfUBkW0mDYa/pWRFFbRUICRbParF5sUs6+VGv5f1v1SnW+VjFoiRBArItxvpcYYY3hVIKuBbg/BBCjcrXPCG3a0h1a0nz390uMvSjM1ISEhISEhISEhEcSEOxXtN1//pbLbeZy/12qPMWfoWY6uAG0paxGXPj2qdEEVjEbzlURNZQ1REnBS+SAGn0Fr2NhbXYNX0UZXU6u+YA1i8iZqXVKpn63ijxYpZkhQZV3fV1UyaiP9tD8jK6kZo0Ekq6N/oS1W+zM2I4Z1lSEJSiVXU0Yyq5HaokpejQQy2F9UdZt94tgX1WGkif3e6dcrOpSigBgVl++VfG7yU93XnPKMNGMTEhISEhISEhISQUw47Bj76OSK7mV7ru+tWTOCE0cDOZk2BwqkJZ5s2MH3R7QSqnF1HSHRgzBEG1UAIyFSpWiifpfUIjA+PqY1FevrmFptlRFichXhWkghK21UxHgf62Mb+mmg/ZRWB0ERo4/9o5RQKiCBTPr4f48syuNYfb5SU082YowS+8fXcjZ9qiwAipqkVgI6WJBWq7E9vu4LH601FC5qtpoKzFqgbp/N8+u37+qcuPThE89be0OawQkJCQkJCQkJCfckaOqCIxejb7/uN2cuO3RL7/QTRrFp1GOGihkonBZQKejrGkGrjeaxgL5VcbWoNioEJSRaimgkkRLJkTQSRSsvQ22kf/br9qoYYOV0WNEz6dtH1ISrbylRzbiq6i8QU7eg+hELTDQQ/w4iN1Wsr/I8tJqMykAFIxbEFwmiqLVatabSVZzTxd/6gjlSJ7X2ayR97Iui0Z9Vgm4GmEHQhdm64fnygfdaNr19/tLhv77+RUfDPFzxBy999fiLfvlLJ7znLfdNV2VCQkJCQkJCwtGNFEE8QjHypm0fKuc6z+6ddZzncGboiQNRwqEFDxOEuFot3tK3oMgah+l7G1Y1eezTLlRqof06QMZE0srmonqXNpRPsSD+5huJq4SHRlEXaSSN9j/VYjps00ZCFnwSappZvbNf41h9fhmfAwmVcCyrlVVR1wxq/Uqt5XUsWmxYTSTZcFAM7esL83gQGbKYSNuvaOwT2CxGFkPNYglBBjVTg9crbsmGZg68d+qP7vtr99R5uOkdf3HBoS9+5m/czGx7uut765/5tGfc/NL/98V0hSYkJCQkJCQkJIKYcBdg+HMz67rfvOUyv3HNMmwcL9GL9XMaNTn7aZRY4O7X/FnV6knTLgJY4P8ncDAUUeBFo/RLWad6MhKkQIhc/d5AokLkTVHAMw+RPYGYkRRBRgIqEHgRgKQTHwKBQpBkIKKeHk4lfj6j4mlWU0XCXMbCgj6rUxWCEKMZBQ7UEMcDFRRpEFJEKhdkfJoklLWQTlVp2a+eZF1nWPWXAcgbdNVHClz1jWuQ0r4aauhboKR3h6Ydrr7+QOu+K+4zd/6mW+5Jc3HjRe9evuO979qx5Bce1WmdfmJZXHmtzvzH/6J91pkfn379u34xXa0JCQkJCQkJCYkgJtyJWP2B3Wv2Xzt5PTZv7PiNHcGsFfCaA9F9kI06v6b1RDOFsykY0xdZ8TWZZK0RWkUFfU2Mspp8VlYWVbytrJNL+1HAylcQKCw8ngEQVSFLFqYwR2l5YwlKLqSog/Line truncated
|
||||
preserveAspectRatio="xMidYMid meet"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 113 KiB |
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "ITWorx DockDeck",
|
||||
"short_name": "ITWorx",
|
||||
"description": "ITWorx.tech startpunt voor apps en favorieten.",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0b1326",
|
||||
"theme_color": "#818cf8",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/dockdeck-mark-v2.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/* global self, caches, fetch, URL */
|
||||
|
||||
const CACHE = "dockdeck-shell-v3";
|
||||
const SHELL = ["/", "/manifest.webmanifest", "/dockdeck-mark-v2.svg"];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(SHELL)));
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) =>
|
||||
Promise.all(
|
||||
keys.filter((key) => key !== CACHE).map((key) => caches.delete(key)),
|
||||
),
|
||||
),
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const request = event.request;
|
||||
const url = new URL(request.url);
|
||||
if (
|
||||
request.method !== "GET" ||
|
||||
url.origin !== self.location.origin ||
|
||||
url.pathname.startsWith("/api/")
|
||||
)
|
||||
return;
|
||||
if (request.mode === "navigate") {
|
||||
event.respondWith(
|
||||
fetch(request)
|
||||
.then((response) => {
|
||||
const copy = response.clone();
|
||||
caches.open(CACHE).then((cache) => cache.put("/", copy));
|
||||
return response;
|
||||
})
|
||||
.catch(() => caches.match("/")),
|
||||
);
|
||||
return;
|
||||
}
|
||||
event.respondWith(
|
||||
caches.match(request).then(
|
||||
(cached) =>
|
||||
cached ||
|
||||
fetch(request).then((response) => {
|
||||
if (response.ok)
|
||||
caches
|
||||
.open(CACHE)
|
||||
.then((cache) => cache.put(request, response.clone()));
|
||||
return response;
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { mkdirSync, readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { build } from "esbuild";
|
||||
|
||||
const projectRoot = fileURLToPath(new URL("../", import.meta.url));
|
||||
const packageJson = JSON.parse(
|
||||
readFileSync(resolve(projectRoot, "package.json"), "utf8"),
|
||||
);
|
||||
|
||||
function getRevision() {
|
||||
const suppliedRevision = process.env.DOCKDECK_BUILD_REVISION?.trim();
|
||||
if (suppliedRevision) return normalizeRevision(suppliedRevision);
|
||||
|
||||
try {
|
||||
return normalizeRevision(
|
||||
execFileSync("git", ["rev-parse", "--verify", "HEAD"], {
|
||||
cwd: projectRoot,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim(),
|
||||
);
|
||||
} catch {
|
||||
return "development";
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRevision(revision) {
|
||||
if (revision === "development") return revision;
|
||||
|
||||
const normalizedRevision = revision.toLowerCase();
|
||||
if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(normalizedRevision)) {
|
||||
throw new Error(
|
||||
"DOCKDECK_BUILD_REVISION must be a complete 40- or 64-character Git revision.",
|
||||
);
|
||||
}
|
||||
|
||||
return normalizedRevision;
|
||||
}
|
||||
|
||||
function getBuildDate() {
|
||||
const buildDate = process.env.DOCKDECK_BUILD_DATE ?? new Date().toISOString();
|
||||
const parsedDate = new Date(buildDate);
|
||||
const hasMilliseconds = /\.\d{3}Z$/.test(buildDate);
|
||||
const canonicalBuildDate = hasMilliseconds
|
||||
? buildDate
|
||||
: `${buildDate.slice(0, -1)}.000Z`;
|
||||
if (
|
||||
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(buildDate) ||
|
||||
Number.isNaN(parsedDate.getTime()) ||
|
||||
parsedDate.toISOString() !== canonicalBuildDate
|
||||
) {
|
||||
throw new Error(
|
||||
"DOCKDECK_BUILD_DATE must be a canonical UTC ISO timestamp with optional milliseconds.",
|
||||
);
|
||||
}
|
||||
|
||||
return buildDate;
|
||||
}
|
||||
|
||||
const suppliedVersion = process.env.DOCKDECK_BUILD_VERSION?.trim();
|
||||
if (suppliedVersion && suppliedVersion !== packageJson.version) {
|
||||
throw new Error(
|
||||
`DOCKDECK_BUILD_VERSION (${suppliedVersion}) does not match package.json (${packageJson.version}).`,
|
||||
);
|
||||
}
|
||||
|
||||
const revision = getRevision();
|
||||
const buildDate = getBuildDate();
|
||||
if (revision !== "development" && Date.parse(buildDate) === 0) {
|
||||
throw new Error(
|
||||
"DOCKDECK_BUILD_DATE must be a real UTC build timestamp when a full Git revision is provided.",
|
||||
);
|
||||
}
|
||||
|
||||
const buildInfo = {
|
||||
version: packageJson.version,
|
||||
revision,
|
||||
shortRevision: revision === "development" ? "dev" : revision.slice(0, 12),
|
||||
buildDate,
|
||||
};
|
||||
|
||||
function runNodeScript(scriptPath, args = []) {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[resolve(projectRoot, scriptPath), ...args],
|
||||
{ cwd: projectRoot, stdio: "inherit" },
|
||||
);
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
runNodeScript("node_modules/typescript/bin/tsc", ["-b"]);
|
||||
runNodeScript("node_modules/vite/bin/vite.js", ["build"]);
|
||||
|
||||
const serverOutput = resolve(projectRoot, "dist/server/index.js");
|
||||
mkdirSync(dirname(serverOutput), { recursive: true });
|
||||
await build({
|
||||
entryPoints: [resolve(projectRoot, "src/server/index.ts")],
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
format: "esm",
|
||||
packages: "external",
|
||||
outfile: serverOutput,
|
||||
define: {
|
||||
__DOCKDECK_BUILD_INFO__: JSON.stringify(buildInfo),
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env node
|
||||
// Prints a DOCKDECK_OPERATOR_PASSWORD_HASH value ("salt:hash", scrypt,
|
||||
// hex-encoded) for a password read from standard input. Run locally, set the
|
||||
// printed value as an env var, and never place the plaintext in process args.
|
||||
import { randomBytes, scryptSync } from "node:crypto";
|
||||
|
||||
if (process.argv.length > 2) {
|
||||
console.error(
|
||||
"Do not pass a password as an argument; provide it on standard input.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function readPassword() {
|
||||
if (!process.stdin.isTTY) {
|
||||
let value = "";
|
||||
for await (const chunk of process.stdin) value += chunk;
|
||||
return value.replace(/[\r\n]+$/, "");
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
let value = "";
|
||||
const finish = (error) => {
|
||||
process.stdin.setRawMode(false);
|
||||
process.stdin.pause();
|
||||
process.stdin.removeListener("data", onData);
|
||||
process.stderr.write("\n");
|
||||
if (error) reject(error);
|
||||
else resolve(value);
|
||||
};
|
||||
const onData = (chunk) => {
|
||||
for (const character of String(chunk)) {
|
||||
if (character === "\u0003") return finish(new Error("Cancelled"));
|
||||
if (character === "\r" || character === "\n") return finish();
|
||||
if (character === "\u007f" || character === "\b")
|
||||
value = value.slice(0, -1);
|
||||
else value += character;
|
||||
}
|
||||
};
|
||||
process.stderr.write("Operator password: ");
|
||||
process.stdin.setRawMode(true);
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.resume();
|
||||
process.stdin.on("data", onData);
|
||||
});
|
||||
}
|
||||
|
||||
const password = await readPassword();
|
||||
if (!password) {
|
||||
console.error("Usage: provide the password on standard input.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const salt = randomBytes(16).toString("hex");
|
||||
const hash = scryptSync(password, salt, 32).toString("hex");
|
||||
console.log(`${salt}:${hash}`);
|
||||
@@ -0,0 +1,238 @@
|
||||
import {
|
||||
Check,
|
||||
Grid2X2,
|
||||
LayoutDashboard,
|
||||
Settings as SettingsIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { DashboardPayload } from "../shared/contracts";
|
||||
import { api } from "./api";
|
||||
import { persistSortOrder, type SortableEntity } from "./persist-sort-order";
|
||||
import { Dashboard, DashboardSkeleton } from "./components/Dashboard";
|
||||
import { Settings, type SettingsSection } from "./components/Settings";
|
||||
import { Brand } from "./components/Brand";
|
||||
|
||||
type Page = "dashboard" | "settings";
|
||||
|
||||
export function App() {
|
||||
const [page, setPage] = useState<Page>("dashboard");
|
||||
const [settingsSection, setSettingsSection] =
|
||||
useState<SettingsSection>("apps");
|
||||
const [data, setData] = useState<DashboardPayload | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
const toastTimerRef = useRef<number | undefined>(undefined);
|
||||
const syncInFlightRef = useRef<Promise<void> | null>(null);
|
||||
const theme = data?.preferences.theme;
|
||||
const accent = data?.preferences.accent;
|
||||
const density = data?.preferences.density;
|
||||
const contentWidth = data?.preferences.contentWidth;
|
||||
const backgroundStyle = data?.preferences.backgroundStyle;
|
||||
const reducedMotion = data?.preferences.reducedMotion;
|
||||
const pollingIntervalMs = data
|
||||
? Math.min(
|
||||
data.preferences.pollingIntervalMs,
|
||||
...data.apps
|
||||
.filter((app) => app.widgetEnabled)
|
||||
.map((app) => app.widgetRefreshIntervalMs),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const payload = await api.dashboard();
|
||||
setData(payload);
|
||||
setError(null);
|
||||
} catch (loadError) {
|
||||
setError(
|
||||
loadError instanceof Error
|
||||
? loadError.message
|
||||
: "DockDeck is niet bereikbaar.",
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const sync = useCallback(async () => {
|
||||
if (syncInFlightRef.current) return syncInFlightRef.current;
|
||||
const request = (async () => {
|
||||
setSyncing(true);
|
||||
try {
|
||||
await api.sync();
|
||||
await load();
|
||||
} catch (syncError) {
|
||||
setError(
|
||||
syncError instanceof Error
|
||||
? syncError.message
|
||||
: "De actuele status kon niet worden vernieuwd.",
|
||||
);
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
syncInFlightRef.current = null;
|
||||
}
|
||||
})();
|
||||
syncInFlightRef.current = request;
|
||||
return request;
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
void load().then(sync);
|
||||
}, [load, sync]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!theme || !accent || !density || !contentWidth || !backgroundStyle)
|
||||
return;
|
||||
const root = document.documentElement;
|
||||
root.dataset.theme = theme;
|
||||
root.dataset.accent = accent;
|
||||
root.dataset.density = density;
|
||||
root.dataset.contentWidth = contentWidth;
|
||||
root.dataset.background = backgroundStyle;
|
||||
root.dataset.reducedMotion = String(Boolean(reducedMotion));
|
||||
}, [accent, backgroundStyle, contentWidth, density, reducedMotion, theme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pollingIntervalMs) return;
|
||||
const refreshWhenVisible = () => {
|
||||
if (document.visibilityState === "visible") void sync();
|
||||
};
|
||||
const timer = window.setInterval(refreshWhenVisible, pollingIntervalMs);
|
||||
document.addEventListener("visibilitychange", refreshWhenVisible);
|
||||
window.addEventListener("online", refreshWhenVisible);
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
document.removeEventListener("visibilitychange", refreshWhenVisible);
|
||||
window.removeEventListener("online", refreshWhenVisible);
|
||||
};
|
||||
}, [pollingIntervalMs, sync]);
|
||||
|
||||
const notify = (message: string) => {
|
||||
window.clearTimeout(toastTimerRef.current);
|
||||
setToast(message);
|
||||
toastTimerRef.current = window.setTimeout(() => setToast(null), 2400);
|
||||
};
|
||||
|
||||
const reorderDashboardItems = async (
|
||||
entity: SortableEntity,
|
||||
items: readonly { id: string }[],
|
||||
) => {
|
||||
try {
|
||||
await persistSortOrder(entity, items);
|
||||
await load();
|
||||
notify(
|
||||
entity === "app"
|
||||
? "Appvolgorde bijgewerkt"
|
||||
: entity === "category"
|
||||
? "Categorievolgorde bijgewerkt"
|
||||
: "Favorietenvolgorde bijgewerkt",
|
||||
);
|
||||
return true;
|
||||
} catch (reorderError) {
|
||||
await load();
|
||||
notify(
|
||||
reorderError instanceof Error
|
||||
? reorderError.message
|
||||
: "De nieuwe volgorde kon niet worden opgeslagen.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
window.clearTimeout(toastTimerRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
if (!data && !error) return <DashboardSkeleton />;
|
||||
if (!data && error)
|
||||
return (
|
||||
<div className="fatal-state">
|
||||
<Brand />
|
||||
<p className="eyebrow">VERBINDING VERBROKEN</p>
|
||||
<h1>DockDeck is even niet bereikbaar.</h1>
|
||||
<p>{error}</p>
|
||||
<button className="primary-button" onClick={() => void load()}>
|
||||
Opnieuw proberen
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<a className="skip-link" href="#main-content">
|
||||
Naar inhoud
|
||||
</a>
|
||||
{page === "dashboard" ? (
|
||||
<Dashboard
|
||||
data={data}
|
||||
syncing={syncing}
|
||||
onSync={sync}
|
||||
onReorder={reorderDashboardItems}
|
||||
onSettings={(section = "apps") => {
|
||||
setSettingsSection(section);
|
||||
setPage("settings");
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Settings
|
||||
data={data}
|
||||
initialSection={settingsSection}
|
||||
onBack={() => setPage("dashboard")}
|
||||
syncing={syncing}
|
||||
onSync={sync}
|
||||
onChanged={load}
|
||||
notify={notify}
|
||||
/>
|
||||
)}
|
||||
{toast && (
|
||||
<div className="toast" role="status">
|
||||
<Check size={16} />
|
||||
{toast}
|
||||
</div>
|
||||
)}
|
||||
<nav className="mobile-nav" aria-label="Mobiele navigatie">
|
||||
<button
|
||||
className={page === "dashboard" ? "active" : ""}
|
||||
aria-current={page === "dashboard" ? "page" : undefined}
|
||||
onClick={() => setPage("dashboard")}
|
||||
>
|
||||
<LayoutDashboard size={20} />
|
||||
<span>Dashboard</span>
|
||||
</button>
|
||||
<button
|
||||
className=""
|
||||
onClick={() => {
|
||||
setPage("dashboard");
|
||||
window.setTimeout(
|
||||
() =>
|
||||
document
|
||||
.getElementById("category-navigation")
|
||||
?.scrollIntoView({ behavior: "smooth" }),
|
||||
0,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Grid2X2 size={20} />
|
||||
<span>Categorieën</span>
|
||||
</button>
|
||||
<button
|
||||
className={page === "settings" ? "active" : ""}
|
||||
aria-current={page === "settings" ? "page" : undefined}
|
||||
onClick={() => setPage("settings")}
|
||||
>
|
||||
<SettingsIcon size={20} />
|
||||
<span>Instellingen</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import type {
|
||||
AppPatch,
|
||||
Category,
|
||||
CategoryOrganizationResult,
|
||||
CategoryInput,
|
||||
DashboardPayload,
|
||||
Favorite,
|
||||
FavoriteInput,
|
||||
LayoutPreset,
|
||||
LayoutPresetImport,
|
||||
PreferencesPatch,
|
||||
SystemDiagnostics,
|
||||
WallpaperDescriptor,
|
||||
WeatherMetric,
|
||||
WidgetLayoutInput,
|
||||
} from "../shared/contracts";
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
...init,
|
||||
headers: init?.body
|
||||
? { "content-type": "application/json", ...init.headers }
|
||||
: init?.headers,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as {
|
||||
error?: string;
|
||||
} | null;
|
||||
throw new Error(body?.error ?? "DockDeck kon de wijziging niet opslaan.");
|
||||
}
|
||||
return response.status === 204
|
||||
? (undefined as T)
|
||||
: ((await response.json()) as T);
|
||||
}
|
||||
|
||||
export const api = {
|
||||
dashboard: () => request<DashboardPayload>("/api/dashboard"),
|
||||
weather: () => request<WeatherMetric>("/api/weather"),
|
||||
uploadWallpaper: async (file: File) => {
|
||||
const response = await fetch("/api/wallpaper", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": file.type },
|
||||
body: file,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as {
|
||||
error?: string;
|
||||
} | null;
|
||||
throw new Error(body?.error ?? "Wallpaper kon niet worden opgeslagen.");
|
||||
}
|
||||
return (await response.json()) as WallpaperDescriptor;
|
||||
},
|
||||
removeWallpaper: () => request<void>("/api/wallpaper", { method: "DELETE" }),
|
||||
sync: () => request("/api/discovery/sync", { method: "POST" }),
|
||||
auditWidgets: () =>
|
||||
request<{ integrations: DashboardPayload["integrations"] }>(
|
||||
"/api/widgets/audit",
|
||||
{ method: "POST" },
|
||||
),
|
||||
enableAllWidgets: () =>
|
||||
request<{ apps: DashboardPayload["apps"] }>("/api/widgets/enable-all", {
|
||||
method: "POST",
|
||||
}),
|
||||
updateApp: (id: string, patch: AppPatch) =>
|
||||
request(`/api/apps/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(patch),
|
||||
}),
|
||||
deleteApp: (id: string) => request(`/api/apps/${id}`, { method: "DELETE" }),
|
||||
createCategory: (input: CategoryInput) =>
|
||||
request<Category>("/api/categories", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
updateCategory: (id: string, input: Partial<CategoryInput>) =>
|
||||
request<Category>(`/api/categories/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
deleteCategory: (id: string) =>
|
||||
request(`/api/categories/${id}`, { method: "DELETE" }),
|
||||
organizeCategories: () =>
|
||||
request<CategoryOrganizationResult>("/api/categories/organize", {
|
||||
method: "POST",
|
||||
}),
|
||||
createFavorite: (input: FavoriteInput) =>
|
||||
request<Favorite>("/api/favorites", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
updateFavorite: (id: string, input: Partial<FavoriteInput>) =>
|
||||
request<Favorite>(`/api/favorites/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
deleteFavorite: (id: string) =>
|
||||
request(`/api/favorites/${id}`, { method: "DELETE" }),
|
||||
updatePreferences: (patch: PreferencesPatch) =>
|
||||
request("/api/preferences", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(patch),
|
||||
}),
|
||||
updateWidgetLayout: (input: WidgetLayoutInput) =>
|
||||
request<{ saved: true }>("/api/widget-layout", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
createLayoutPreset: (name: string) =>
|
||||
request<LayoutPreset>("/api/layout-presets", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name }),
|
||||
}),
|
||||
applyLayoutPreset: (id: string) =>
|
||||
request<{ applied: true }>(`/api/layout-presets/${id}/apply`, {
|
||||
method: "POST",
|
||||
}),
|
||||
deleteLayoutPreset: (id: string) =>
|
||||
request(`/api/layout-presets/${id}`, { method: "DELETE" }),
|
||||
importLayoutPreset: (payload: LayoutPresetImport) =>
|
||||
request<LayoutPreset>("/api/layout-presets/import", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
diagnostics: () => request<SystemDiagnostics>("/api/diagnostics"),
|
||||
updateIntegrationCredentials: (id: string, values: Record<string, string>) =>
|
||||
request<{ saved: true }>(
|
||||
`/api/integrations/${encodeURIComponent(id)}/credentials`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ values }),
|
||||
},
|
||||
),
|
||||
importBackup: (payload: unknown) =>
|
||||
request("/api/import", { method: "POST", body: JSON.stringify(payload) }),
|
||||
};
|
||||
@@ -0,0 +1,466 @@
|
||||
import { decodeHTML } from "entities";
|
||||
|
||||
export const MAX_BOOKMARK_FILE_BYTES = 2 * 1024 * 1024;
|
||||
export const MAX_BOOKMARKS = 500;
|
||||
export const MAX_BOOKMARK_NODES = 20_000;
|
||||
export const MAX_BOOKMARK_FOLDER_DEPTH = 8;
|
||||
export const MAX_BOOKMARK_URL_LENGTH = 2_048;
|
||||
export const MAX_BOOKMARK_TITLE_LENGTH = 80;
|
||||
export const MAX_BOOKMARK_CATEGORY_LENGTH = 48;
|
||||
|
||||
export interface ParsedBookmark {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string | null;
|
||||
normalizedUrl: string | null;
|
||||
folderPath: string[];
|
||||
categoryName: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface BookmarkParseResult {
|
||||
bookmarks: ParsedBookmark[];
|
||||
warnings: string[];
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
type HtmlToken =
|
||||
| { type: "text"; value: string }
|
||||
| {
|
||||
type: "tag";
|
||||
name: string;
|
||||
closing: boolean;
|
||||
selfClosing: boolean;
|
||||
attributes: Record<string, string>;
|
||||
};
|
||||
|
||||
interface TokenizeResult {
|
||||
tokens: HtmlToken[];
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
interface FolderFrame {
|
||||
name: string;
|
||||
invalid: boolean;
|
||||
}
|
||||
|
||||
interface ListFrame {
|
||||
pushedFolder: boolean;
|
||||
invalidDepth: boolean;
|
||||
}
|
||||
|
||||
interface ActiveText {
|
||||
kind: "bookmark" | "folder";
|
||||
chunks: string[];
|
||||
length: number;
|
||||
tooLong: boolean;
|
||||
href?: string;
|
||||
folderPath?: FolderFrame[];
|
||||
invalidFolder?: boolean;
|
||||
}
|
||||
|
||||
function readTagEnd(source: string, start: number): number {
|
||||
let quote = "";
|
||||
for (let index = start; index < source.length; index += 1) {
|
||||
const character = source[index];
|
||||
if (quote) {
|
||||
if (character === quote) quote = "";
|
||||
} else if (character === '"' || character === "'") {
|
||||
quote = character;
|
||||
} else if (character === ">") {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function parseAttributes(source: string): Record<string, string> {
|
||||
const attributes: Record<string, string> = {};
|
||||
let index = 0;
|
||||
let count = 0;
|
||||
|
||||
while (index < source.length && count < 256) {
|
||||
while (/\s/.test(source[index] ?? "")) index += 1;
|
||||
if (source[index] === "/") break;
|
||||
|
||||
const nameStart = index;
|
||||
while (index < source.length && !/[\s=/>]/.test(source[index] ?? "")) {
|
||||
index += 1;
|
||||
}
|
||||
if (nameStart === index) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = source.slice(nameStart, index).toLowerCase();
|
||||
while (/\s/.test(source[index] ?? "")) index += 1;
|
||||
let value = "";
|
||||
if (source[index] === "=") {
|
||||
index += 1;
|
||||
while (/\s/.test(source[index] ?? "")) index += 1;
|
||||
const quote = source[index] === '"' || source[index] === "'";
|
||||
if (quote) {
|
||||
const delimiter = source[index]!;
|
||||
index += 1;
|
||||
const valueStart = index;
|
||||
const closingQuote = source.indexOf(delimiter, index);
|
||||
if (closingQuote === -1) {
|
||||
value = source.slice(valueStart);
|
||||
index = source.length;
|
||||
} else {
|
||||
value = source.slice(valueStart, closingQuote);
|
||||
index = closingQuote + 1;
|
||||
}
|
||||
} else {
|
||||
const valueStart = index;
|
||||
while (index < source.length && !/[\s>]/.test(source[index] ?? "")) {
|
||||
index += 1;
|
||||
}
|
||||
value = source.slice(valueStart, index);
|
||||
}
|
||||
}
|
||||
|
||||
if (!(name in attributes)) attributes[name] = decodeHTML(value);
|
||||
count += 1;
|
||||
}
|
||||
|
||||
return attributes;
|
||||
}
|
||||
|
||||
function findRawTextClosingTag(
|
||||
source: string,
|
||||
lowercaseSource: string,
|
||||
tagName: string,
|
||||
start: number,
|
||||
): number {
|
||||
const closingPrefix = `</${tagName}`;
|
||||
let candidate = lowercaseSource.indexOf(closingPrefix, start);
|
||||
while (candidate !== -1) {
|
||||
const next = lowercaseSource[candidate + closingPrefix.length] ?? "";
|
||||
if (!next || /[\s/>]/.test(next)) return candidate;
|
||||
candidate = lowercaseSource.indexOf(closingPrefix, candidate + 2);
|
||||
}
|
||||
return source.length;
|
||||
}
|
||||
|
||||
function tokenizeBookmarkHtml(source: string): TokenizeResult {
|
||||
const tokens: HtmlToken[] = [];
|
||||
const lowercaseSource = source.toLowerCase();
|
||||
let index = 0;
|
||||
let nodes = 0;
|
||||
let rawTextTag: string | null = null;
|
||||
|
||||
while (index < source.length && nodes < MAX_BOOKMARK_NODES) {
|
||||
if (rawTextTag) {
|
||||
index = findRawTextClosingTag(source, lowercaseSource, rawTextTag, index);
|
||||
rawTextTag = null;
|
||||
if (index >= source.length) break;
|
||||
}
|
||||
|
||||
if (source[index] !== "<") {
|
||||
const nextTag = source.indexOf("<", index);
|
||||
const end = nextTag === -1 ? source.length : nextTag;
|
||||
tokens.push({ type: "text", value: source.slice(index, end) });
|
||||
index = end;
|
||||
nodes += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (source.startsWith("<!--", index)) {
|
||||
const commentEnd = source.indexOf("-->", index + 4);
|
||||
index = commentEnd === -1 ? source.length : commentEnd + 3;
|
||||
nodes += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const tagEnd = readTagEnd(source, index + 1);
|
||||
if (tagEnd === -1) {
|
||||
tokens.push({ type: "text", value: source.slice(index) });
|
||||
index = source.length;
|
||||
nodes += 1;
|
||||
break;
|
||||
}
|
||||
|
||||
const contents = source.slice(index + 1, tagEnd);
|
||||
index = tagEnd + 1;
|
||||
nodes += 1;
|
||||
|
||||
const trimmed = contents.trim();
|
||||
if (!trimmed || trimmed.startsWith("!") || trimmed.startsWith("?")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const closing = trimmed.startsWith("/");
|
||||
const tagContents = closing ? trimmed.slice(1).trimStart() : trimmed;
|
||||
let nameEnd = 0;
|
||||
while (
|
||||
nameEnd < tagContents.length &&
|
||||
!/[\s/>]/.test(tagContents[nameEnd] ?? "")
|
||||
) {
|
||||
nameEnd += 1;
|
||||
}
|
||||
if (nameEnd === 0) continue;
|
||||
|
||||
const name = tagContents.slice(0, nameEnd).toLowerCase();
|
||||
const remainder = tagContents.slice(nameEnd);
|
||||
const selfClosing = !closing && /\/\s*$/.test(remainder);
|
||||
tokens.push({
|
||||
type: "tag",
|
||||
name,
|
||||
closing,
|
||||
selfClosing,
|
||||
attributes: closing ? {} : parseAttributes(remainder),
|
||||
});
|
||||
if (!closing && !selfClosing && (name === "script" || name === "style")) {
|
||||
rawTextTag = name;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tokens,
|
||||
truncated: nodes >= MAX_BOOKMARK_NODES && index < source.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeBookmarkUrl(value: string): {
|
||||
url: string;
|
||||
key: string;
|
||||
} {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) throw new Error("De bladwijzer heeft geen URL.");
|
||||
if (trimmed.length > MAX_BOOKMARK_URL_LENGTH) {
|
||||
throw new Error("De URL is langer dan 2.048 tekens.");
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(trimmed);
|
||||
} catch {
|
||||
throw new Error("De URL is ongeldig.");
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error("Alleen HTTP- en HTTPS-bladwijzers worden geïmporteerd.");
|
||||
}
|
||||
if (parsed.username || parsed.password) {
|
||||
throw new Error("URL's met gebruikersgegevens worden overgeslagen.");
|
||||
}
|
||||
if (parsed.href.length > MAX_BOOKMARK_URL_LENGTH) {
|
||||
throw new Error("De URL is langer dan 2.048 tekens.");
|
||||
}
|
||||
|
||||
const duplicateCheck = new URL(parsed.href);
|
||||
duplicateCheck.hash = "";
|
||||
return { url: parsed.href, key: duplicateCheck.href };
|
||||
}
|
||||
|
||||
function makeBookmark(
|
||||
id: number,
|
||||
active: ActiveText,
|
||||
malformed: boolean,
|
||||
folders: readonly FolderFrame[],
|
||||
hasOverflow: boolean,
|
||||
): ParsedBookmark {
|
||||
const title = decodeHTML(active.chunks.join("")).trim();
|
||||
const folderPath = (active.folderPath ?? folders).map(
|
||||
(folder) => folder.name,
|
||||
);
|
||||
const categoryName = folderPath.length > 0 ? folderPath.join(" / ") : null;
|
||||
let url: string | null = null;
|
||||
let normalizedUrl: string | null = null;
|
||||
let error: string | null = null;
|
||||
|
||||
if (malformed) error = "De bladwijzer-tag is niet correct afgesloten.";
|
||||
else if (active.tooLong)
|
||||
error = "De bladwijzertitel is langer dan 80 tekens.";
|
||||
else if (!title) error = "De bladwijzer heeft geen titel.";
|
||||
else if (title.length > MAX_BOOKMARK_TITLE_LENGTH) {
|
||||
error = "De bladwijzertitel is langer dan 80 tekens.";
|
||||
} else if (!active.href) error = "De bladwijzer heeft geen URL.";
|
||||
else {
|
||||
try {
|
||||
const normalized = normalizeBookmarkUrl(active.href);
|
||||
url = normalized.url;
|
||||
normalizedUrl = normalized.key;
|
||||
} catch (urlError) {
|
||||
error =
|
||||
urlError instanceof Error ? urlError.message : "De URL is ongeldig.";
|
||||
}
|
||||
}
|
||||
|
||||
if (!error && hasOverflow) {
|
||||
error = `De bladwijzermap is dieper dan ${MAX_BOOKMARK_FOLDER_DEPTH} niveaus.`;
|
||||
}
|
||||
if (
|
||||
!error &&
|
||||
(active.folderPath ?? folders).some((folder) => folder.invalid)
|
||||
) {
|
||||
error = "Een mapnaam is langer dan 80 tekens.";
|
||||
}
|
||||
if (
|
||||
!error &&
|
||||
categoryName &&
|
||||
categoryName.length > MAX_BOOKMARK_CATEGORY_LENGTH
|
||||
) {
|
||||
error = "De volledige mapnaam is langer dan 48 tekens.";
|
||||
}
|
||||
|
||||
return {
|
||||
id: `bookmark-${id}`,
|
||||
title: title.slice(0, MAX_BOOKMARK_TITLE_LENGTH),
|
||||
url: error ? null : url,
|
||||
normalizedUrl: error ? null : normalizedUrl,
|
||||
folderPath,
|
||||
categoryName: error ? null : categoryName,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseBookmarkHtml(source: string): BookmarkParseResult {
|
||||
if (source.length > MAX_BOOKMARK_FILE_BYTES) {
|
||||
throw new Error("Het bladwijzerbestand mag maximaal 2 MB groot zijn.");
|
||||
}
|
||||
if (
|
||||
!/<!doctype\s+netscape-bookmark-file-1(?:\s|>)/i.test(source.slice(0, 4096))
|
||||
) {
|
||||
throw new Error("Dit is geen Netscape Bookmark HTML-bestand.");
|
||||
}
|
||||
|
||||
const tokenized = tokenizeBookmarkHtml(source);
|
||||
const bookmarks: ParsedBookmark[] = [];
|
||||
const warnings: string[] = [];
|
||||
const folders: FolderFrame[] = [];
|
||||
const listFrames: ListFrame[] = [];
|
||||
let active: ActiveText | null = null;
|
||||
let pendingFolder: FolderFrame | null = null;
|
||||
let truncated = tokenized.truncated;
|
||||
|
||||
const finishActive = (malformed: boolean) => {
|
||||
if (!active) return;
|
||||
if (active.kind === "folder") {
|
||||
const name = decodeHTML(active.chunks.join("")).trim();
|
||||
if (name) {
|
||||
pendingFolder = {
|
||||
name: name.slice(0, MAX_BOOKMARK_TITLE_LENGTH),
|
||||
invalid: active.tooLong || name.length > MAX_BOOKMARK_TITLE_LENGTH,
|
||||
};
|
||||
}
|
||||
active = null;
|
||||
return;
|
||||
}
|
||||
|
||||
bookmarks.push(
|
||||
makeBookmark(
|
||||
bookmarks.length,
|
||||
active,
|
||||
malformed,
|
||||
active.folderPath ?? folders,
|
||||
listFrames.some((frame) => frame.invalidDepth) ||
|
||||
Boolean(active.invalidFolder),
|
||||
),
|
||||
);
|
||||
active = null;
|
||||
if (bookmarks.length >= MAX_BOOKMARKS) truncated = true;
|
||||
};
|
||||
|
||||
for (const token of tokenized.tokens) {
|
||||
if (bookmarks.length >= MAX_BOOKMARKS) break;
|
||||
if (token.type === "text") {
|
||||
if (active && active.length <= MAX_BOOKMARK_TITLE_LENGTH) {
|
||||
const remaining = MAX_BOOKMARK_TITLE_LENGTH + 1 - active.length;
|
||||
const chunk = token.value.slice(0, remaining);
|
||||
active.chunks.push(chunk);
|
||||
active.length += chunk.length;
|
||||
if (chunk.length < token.value.length) active.tooLong = true;
|
||||
} else if (active) {
|
||||
active.tooLong = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (active) {
|
||||
if (
|
||||
token.closing &&
|
||||
token.name === (active.kind === "folder" ? "h3" : "a")
|
||||
) {
|
||||
finishActive(false);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
!token.closing &&
|
||||
token.name === (active.kind === "folder" ? "h3" : "a")
|
||||
) {
|
||||
finishActive(true);
|
||||
} else if (
|
||||
active.kind === "folder" &&
|
||||
token.name === "dl" &&
|
||||
!token.closing
|
||||
) {
|
||||
finishActive(true);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (token.closing && token.name === "dl") {
|
||||
const frame = listFrames.pop();
|
||||
if (frame?.pushedFolder) folders.pop();
|
||||
pendingFolder = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!token.closing && token.name === "dl" && !token.selfClosing) {
|
||||
let pushedFolder = false;
|
||||
let invalidDepth = false;
|
||||
if (pendingFolder) {
|
||||
if (folders.length >= MAX_BOOKMARK_FOLDER_DEPTH) {
|
||||
invalidDepth = true;
|
||||
} else {
|
||||
folders.push(pendingFolder);
|
||||
pushedFolder = true;
|
||||
}
|
||||
} else if (listFrames.length >= MAX_BOOKMARK_FOLDER_DEPTH + 1) {
|
||||
invalidDepth = true;
|
||||
}
|
||||
listFrames.push({ pushedFolder, invalidDepth });
|
||||
pendingFolder = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.closing) continue;
|
||||
if (token.name === "h3" && !token.selfClosing) {
|
||||
pendingFolder = null;
|
||||
active = {
|
||||
kind: "folder",
|
||||
chunks: [],
|
||||
length: 0,
|
||||
tooLong: false,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (token.name === "a" && !token.selfClosing) {
|
||||
active = {
|
||||
kind: "bookmark",
|
||||
chunks: [],
|
||||
length: 0,
|
||||
tooLong: false,
|
||||
href: token.attributes.href,
|
||||
folderPath: [...folders],
|
||||
invalidFolder: folders.some((folder) => folder.invalid),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (active?.kind === "bookmark" && bookmarks.length < MAX_BOOKMARKS) {
|
||||
finishActive(true);
|
||||
}
|
||||
if (tokenized.truncated) {
|
||||
warnings.push(
|
||||
"Het bestand bevat te veel HTML-elementen; de preview is afgekapt.",
|
||||
);
|
||||
}
|
||||
if (bookmarks.length >= MAX_BOOKMARKS) {
|
||||
warnings.push(`Maximaal ${MAX_BOOKMARKS} bladwijzers worden verwerkt.`);
|
||||
}
|
||||
|
||||
return { bookmarks, warnings, truncated };
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
Activity,
|
||||
Archive,
|
||||
Bot,
|
||||
Box,
|
||||
BriefcaseBusiness,
|
||||
Code2,
|
||||
Database,
|
||||
Download,
|
||||
FileText,
|
||||
Gamepad2,
|
||||
Home,
|
||||
Image as ImageIcon,
|
||||
Layers3,
|
||||
Network,
|
||||
Play,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
Star,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
|
||||
export const categoryIconOptions = [
|
||||
{ id: "Layers3", label: "Lagen" },
|
||||
{ id: "Play", label: "Media" },
|
||||
{ id: "Download", label: "Downloads" },
|
||||
{ id: "FileText", label: "Documenten" },
|
||||
{ id: "Image", label: "Foto's" },
|
||||
{ id: "Home", label: "Thuis & automatisering" },
|
||||
{ id: "ShieldCheck", label: "Beveiliging & identiteit" },
|
||||
{ id: "Network", label: "Netwerk & beheer" },
|
||||
{ id: "Activity", label: "Monitoring" },
|
||||
{ id: "Code2", label: "Ontwikkeling" },
|
||||
{ id: "Bot", label: "AI & machine learning" },
|
||||
{ id: "Database", label: "Databases & opslag" },
|
||||
{ id: "Server", label: "Infrastructuur" },
|
||||
{ id: "Gamepad2", label: "Games & emulatie" },
|
||||
{ id: "Wrench", label: "Gereedschap" },
|
||||
{ id: "Archive", label: "Experimenten & archief" },
|
||||
{ id: "Star", label: "Favorieten" },
|
||||
{ id: "BriefcaseBusiness", label: "Werk" },
|
||||
{ id: "Box", label: "Algemeen" },
|
||||
] as const;
|
||||
|
||||
const categoryIconComponents = {
|
||||
Layers3,
|
||||
Play,
|
||||
Download,
|
||||
FileText,
|
||||
Image: ImageIcon,
|
||||
Home,
|
||||
ShieldCheck,
|
||||
Network,
|
||||
Activity,
|
||||
Code2,
|
||||
Bot,
|
||||
Database,
|
||||
Server,
|
||||
Gamepad2,
|
||||
Wrench,
|
||||
Archive,
|
||||
Star,
|
||||
BriefcaseBusiness,
|
||||
Box,
|
||||
} as const;
|
||||
|
||||
export function resolveCategoryIcon(name: string) {
|
||||
return (
|
||||
categoryIconComponents[name as keyof typeof categoryIconComponents] ??
|
||||
Layers3
|
||||
);
|
||||
}
|
||||
|
||||
export function nextAvailableCategoryIcon(usedIcons: Iterable<string>) {
|
||||
const used = new Set(usedIcons);
|
||||
return (
|
||||
categoryIconOptions.find((option) => !used.has(option.id))?.id ?? "Layers3"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { ArrowUpRight, Box, Globe2 } from "lucide-react";
|
||||
import type { DockApp, Favorite } from "../../shared/contracts";
|
||||
import { effectiveAppUrl, favoriteIconUrl } from "../../shared/domain";
|
||||
import { makeLaunchKey, type LaunchKey } from "../launch-history";
|
||||
import { ServiceIcon } from "./ServiceIcon";
|
||||
|
||||
const serviceDescriptions: Array<[RegExp, string]> = [
|
||||
[/plex/i, "Media server & streaming"],
|
||||
[/jellyfin/i, "De vrije software mediaserver"],
|
||||
[/sonarr/i, "PVR voor tv-series"],
|
||||
[/radarr/i, "PVR voor films"],
|
||||
[/home.?assistant|ha-core/i, "Centrale automatisering"],
|
||||
[/adguard|pihole|pi-hole/i, "Netwerkbeveiliging & filter"],
|
||||
[/nextcloud/i, "Bestandshosting"],
|
||||
[/gitea|github|gitlab/i, "Code en samenwerking"],
|
||||
[/immich/i, "Foto's en herinneringen"],
|
||||
[/paperless/i, "Documenten en archief"],
|
||||
];
|
||||
|
||||
function describeService(app: DockApp, categoryName: string) {
|
||||
const haystack = `${app.displayName} ${app.technicalName}`;
|
||||
return (
|
||||
serviceDescriptions.find(([pattern]) => pattern.test(haystack))?.[1] ??
|
||||
`${categoryName} service`
|
||||
);
|
||||
}
|
||||
|
||||
function statusLabel(status: DockApp["status"]) {
|
||||
return status === "online"
|
||||
? "Online"
|
||||
: status === "offline"
|
||||
? "Offline"
|
||||
: "Status onbekend";
|
||||
}
|
||||
|
||||
export function AppTile({
|
||||
app,
|
||||
externalAccess,
|
||||
showStatus,
|
||||
index,
|
||||
categoryName,
|
||||
variant = "standard",
|
||||
onLaunch,
|
||||
}: {
|
||||
app: DockApp;
|
||||
externalAccess: boolean;
|
||||
showStatus: boolean;
|
||||
index: number;
|
||||
categoryName: string;
|
||||
variant?: "standard" | "feature";
|
||||
onLaunch?: (key: LaunchKey) => void;
|
||||
}) {
|
||||
const url = effectiveAppUrl(app, externalAccess);
|
||||
const content = (
|
||||
<>
|
||||
<ServiceIcon
|
||||
name={app.displayName}
|
||||
icon={app.icon}
|
||||
hint={`${app.technicalName} ${app.image ?? ""}`}
|
||||
index={index}
|
||||
/>
|
||||
<span className="tile-copy">
|
||||
<strong>{app.displayName}</strong>
|
||||
<small>{describeService(app, categoryName)}</small>
|
||||
</span>
|
||||
{showStatus && (
|
||||
<span className={`tile-status ${app.status}`}>
|
||||
<span /> {statusLabel(app.status)}
|
||||
</span>
|
||||
)}
|
||||
<ArrowUpRight className="tile-arrow" size={17} aria-hidden="true" />
|
||||
</>
|
||||
);
|
||||
return url ? (
|
||||
<a
|
||||
className={`app-tile ${variant}`}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-testid="app-tile"
|
||||
onClick={() => onLaunch?.(makeLaunchKey("app", app.id))}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
) : (
|
||||
<div
|
||||
className={`app-tile ${variant} disabled`}
|
||||
aria-label={`${app.displayName} heeft geen URL`}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FavoriteTile({
|
||||
favorite,
|
||||
index,
|
||||
onLaunch,
|
||||
}: {
|
||||
favorite: Favorite;
|
||||
index: number;
|
||||
onLaunch?: (key: LaunchKey) => void;
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
className="app-tile"
|
||||
href={favorite.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-testid="favorite-tile"
|
||||
onClick={() => onLaunch?.(makeLaunchKey("favorite", favorite.id))}
|
||||
>
|
||||
<ServiceIcon
|
||||
name={favorite.name}
|
||||
icon={favoriteIconUrl(favorite)}
|
||||
index={index + 2}
|
||||
/>
|
||||
<span className="tile-copy">
|
||||
<strong>{favorite.name}</strong>
|
||||
<small>Favoriet</small>
|
||||
</span>
|
||||
<Globe2 className="status-icon" size={15} aria-hidden="true" />
|
||||
<ArrowUpRight className="tile-arrow" size={17} aria-hidden="true" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyIcon() {
|
||||
return (
|
||||
<div className="empty-icon" aria-hidden="true">
|
||||
<Box size={28} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,811 @@
|
||||
import {
|
||||
Accessibility,
|
||||
AlignJustify,
|
||||
Check,
|
||||
CloudSun,
|
||||
Eye,
|
||||
GalleryHorizontalEnd,
|
||||
Gauge,
|
||||
Grid3X3,
|
||||
Layers3,
|
||||
Maximize2,
|
||||
MonitorCog,
|
||||
Image as ImageIcon,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type {
|
||||
Accent,
|
||||
BackgroundStyle,
|
||||
ContentWidth,
|
||||
DashboardPayload,
|
||||
Density,
|
||||
Theme,
|
||||
ViewMode,
|
||||
WallpaperFit,
|
||||
} from "../../shared/contracts";
|
||||
import { api } from "../api";
|
||||
|
||||
type Performer = (
|
||||
action: () => Promise<unknown>,
|
||||
message: string,
|
||||
) => Promise<boolean>;
|
||||
|
||||
interface AppearanceSettingsProps {
|
||||
data: DashboardPayload;
|
||||
perform: Performer;
|
||||
}
|
||||
|
||||
const themes: { id: Theme; label: string; copy: string }[] = [
|
||||
{
|
||||
id: "dark",
|
||||
label: "Donker",
|
||||
copy: "Diep navy en rustig contrast.",
|
||||
},
|
||||
{ id: "light", label: "Licht", copy: "Helder, rustig en verfijnd." },
|
||||
{ id: "harbor", label: "Haven", copy: "Koel marineblauw voor late avonden." },
|
||||
];
|
||||
|
||||
const accents: { id: Accent; label: string }[] = [
|
||||
{ id: "coral", label: "Koraal" },
|
||||
{ id: "ocean", label: "Oceaan" },
|
||||
{ id: "violet", label: "Violet" },
|
||||
{ id: "lime", label: "Limoen" },
|
||||
];
|
||||
|
||||
const views: { id: ViewMode; label: string; copy: string }[] = [
|
||||
{
|
||||
id: "overview",
|
||||
label: "Volledig overzicht",
|
||||
copy: "Alle verzamelingen op één doorlopende pagina.",
|
||||
},
|
||||
{
|
||||
id: "categories",
|
||||
label: "Categorienavigatie",
|
||||
copy: "Wissel tussen gerichte verzamelingstabbladen.",
|
||||
},
|
||||
];
|
||||
|
||||
const widths: {
|
||||
id: ContentWidth;
|
||||
label: string;
|
||||
copy: string;
|
||||
icon: typeof Maximize2;
|
||||
}[] = [
|
||||
{
|
||||
id: "focused",
|
||||
label: "Gericht",
|
||||
copy: "Rustig en gecentreerd tot 1120 px.",
|
||||
icon: AlignJustify,
|
||||
},
|
||||
{
|
||||
id: "wide",
|
||||
label: "Breed",
|
||||
copy: "Evenwichtig voor alledaagse desktops.",
|
||||
icon: GalleryHorizontalEnd,
|
||||
},
|
||||
{
|
||||
id: "fluid",
|
||||
label: "Vloeiend",
|
||||
copy: "Benut ultrabrede schermen volledig.",
|
||||
icon: Maximize2,
|
||||
},
|
||||
];
|
||||
|
||||
const densities: {
|
||||
id: Density;
|
||||
label: string;
|
||||
copy: string;
|
||||
icon: typeof Gauge;
|
||||
}[] = [
|
||||
{
|
||||
id: "compact",
|
||||
label: "Compact",
|
||||
copy: "Meer services per rij.",
|
||||
icon: Grid3X3,
|
||||
},
|
||||
{
|
||||
id: "comfortable",
|
||||
label: "Comfortabel",
|
||||
copy: "Een evenwichtige standaard voor dagelijks gebruik.",
|
||||
icon: Gauge,
|
||||
},
|
||||
{
|
||||
id: "spacious",
|
||||
label: "Ruim",
|
||||
copy: "Grotere, rustigere starttegels.",
|
||||
icon: Layers3,
|
||||
},
|
||||
];
|
||||
|
||||
const backgrounds: { id: BackgroundStyle; label: string; copy: string }[] = [
|
||||
{ id: "aurora", label: "Aurora", copy: "Zachte omgevingskleur en diepte." },
|
||||
{ id: "grid", label: "Blauwdruk", copy: "Een subtiel technisch raster." },
|
||||
{ id: "minimal", label: "Minimaal", copy: "Zuivere kleur zonder textuur." },
|
||||
];
|
||||
|
||||
export function AppearanceSettings({ data, perform }: AppearanceSettingsProps) {
|
||||
const preferences = data.preferences;
|
||||
const wallpaperInput = useRef<HTMLInputElement>(null);
|
||||
const [wallpaperDraft, setWallpaperDraft] = useState<{
|
||||
fit: WallpaperFit;
|
||||
positionX: number;
|
||||
positionY: number;
|
||||
zoom: number;
|
||||
}>(() => ({
|
||||
fit: preferences.wallpaperFit,
|
||||
positionX: preferences.wallpaperPositionX,
|
||||
positionY: preferences.wallpaperPositionY,
|
||||
zoom: preferences.wallpaperZoom,
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
setWallpaperDraft({
|
||||
fit: preferences.wallpaperFit,
|
||||
positionX: preferences.wallpaperPositionX,
|
||||
positionY: preferences.wallpaperPositionY,
|
||||
zoom: preferences.wallpaperZoom,
|
||||
});
|
||||
}, [
|
||||
preferences.wallpaperFit,
|
||||
preferences.wallpaperPositionX,
|
||||
preferences.wallpaperPositionY,
|
||||
preferences.wallpaperZoom,
|
||||
]);
|
||||
|
||||
const wallpaperDraftChanged =
|
||||
wallpaperDraft.fit !== preferences.wallpaperFit ||
|
||||
wallpaperDraft.positionX !== preferences.wallpaperPositionX ||
|
||||
wallpaperDraft.positionY !== preferences.wallpaperPositionY ||
|
||||
wallpaperDraft.zoom !== preferences.wallpaperZoom;
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="settings-heading">
|
||||
<p className="eyebrow">Instellingen › Weergave</p>
|
||||
<h2>Thema</h2>
|
||||
<p>Kies de visuele stijl die het beste bij je workflow past.</p>
|
||||
</header>
|
||||
<div className="theme-grid">
|
||||
{themes.map((theme) => (
|
||||
<button
|
||||
key={theme.id}
|
||||
className={`theme-card ${preferences.theme === theme.id ? "selected" : ""}`}
|
||||
aria-pressed={preferences.theme === theme.id}
|
||||
onClick={() =>
|
||||
perform(
|
||||
() => api.updatePreferences({ theme: theme.id }),
|
||||
`Thema ${theme.label} toegepast`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className={`theme-preview ${theme.id}`}>
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
<span>
|
||||
<strong>{theme.label}</strong>
|
||||
<small>{theme.copy}</small>
|
||||
</span>
|
||||
{preferences.theme === theme.id && <Check size={17} />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="canon-preference-columns">
|
||||
<section>
|
||||
<h3 className="setting-label">Dashboardmodus</h3>
|
||||
<div className="view-grid">
|
||||
{views.map((view) => (
|
||||
<button
|
||||
key={view.id}
|
||||
className={`view-card ${preferences.viewMode === view.id ? "selected" : ""}`}
|
||||
aria-pressed={preferences.viewMode === view.id}
|
||||
onClick={() =>
|
||||
perform(
|
||||
() => api.updatePreferences({ viewMode: view.id }),
|
||||
`${view.label} geselecteerd`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<MonitorCog size={22} />
|
||||
<span>
|
||||
<strong>{view.label}</strong>
|
||||
<small>{view.copy}</small>
|
||||
</span>
|
||||
{preferences.viewMode === view.id && <Check size={17} />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h3 className="setting-label">Grid-dichtheid</h3>
|
||||
<div className="choice-grid density-grid">
|
||||
{densities.map((density) => {
|
||||
const Icon = density.icon;
|
||||
return (
|
||||
<button
|
||||
key={density.id}
|
||||
className={`option-card ${preferences.density === density.id ? "selected" : ""}`}
|
||||
aria-pressed={preferences.density === density.id}
|
||||
onClick={() =>
|
||||
perform(
|
||||
() => api.updatePreferences({ density: density.id }),
|
||||
`Tegels ${density.label} geselecteerd`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon size={20} />
|
||||
<strong>{density.label}</strong>
|
||||
<small>{density.copy}</small>
|
||||
{preferences.density === density.id && <Check size={15} />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<details className="appearance-advanced">
|
||||
<summary>
|
||||
<span>
|
||||
<strong>Meer personalisatie</strong>
|
||||
<small>
|
||||
Accent, canvasbreedte, achtergrond en dashboarddetails
|
||||
</small>
|
||||
</span>
|
||||
</summary>
|
||||
<div className="appearance-advanced-content">
|
||||
<h3 className="setting-label">Accentkleur</h3>
|
||||
<div className="accent-grid">
|
||||
{accents.map((accent) => (
|
||||
<button
|
||||
key={accent.id}
|
||||
className={`accent-choice ${preferences.accent === accent.id ? "selected" : ""}`}
|
||||
onClick={() =>
|
||||
perform(
|
||||
() => api.updatePreferences({ accent: accent.id }),
|
||||
`Accent ${accent.label} toegepast`,
|
||||
)
|
||||
}
|
||||
aria-label={`Accent ${accent.label}`}
|
||||
aria-pressed={preferences.accent === accent.id}
|
||||
>
|
||||
<span className={`accent-swatch ${accent.id}`} />
|
||||
<span>{accent.label}</span>
|
||||
{preferences.accent === accent.id && <Check size={15} />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h3 className="setting-label">Ruimtegebruik</h3>
|
||||
<div className="choice-grid three-up">
|
||||
{widths.map((width) => {
|
||||
const Icon = width.icon;
|
||||
return (
|
||||
<button
|
||||
key={width.id}
|
||||
className={`option-card ${preferences.contentWidth === width.id ? "selected" : ""}`}
|
||||
aria-pressed={preferences.contentWidth === width.id}
|
||||
onClick={() =>
|
||||
perform(
|
||||
() => api.updatePreferences({ contentWidth: width.id }),
|
||||
`Canvas ${width.label} geselecteerd`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon size={20} />
|
||||
<strong>{width.label}</strong>
|
||||
<small>{width.copy}</small>
|
||||
{preferences.contentWidth === width.id && <Check size={15} />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<h3 className="setting-label">Achtergrond</h3>
|
||||
<div className="background-grid">
|
||||
{backgrounds.map((background) => (
|
||||
<button
|
||||
key={background.id}
|
||||
className={`background-card ${background.id} ${preferences.backgroundStyle === background.id ? "selected" : ""}`}
|
||||
aria-pressed={preferences.backgroundStyle === background.id}
|
||||
onClick={() =>
|
||||
perform(
|
||||
() =>
|
||||
api.updatePreferences({ backgroundStyle: background.id }),
|
||||
`Achtergrond ${background.label} geselecteerd`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<span />
|
||||
<strong>{background.label}</strong>
|
||||
<small>{background.copy}</small>
|
||||
{preferences.backgroundStyle === background.id && (
|
||||
<Check size={15} />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="wallpaper-settings">
|
||||
<header className="wallpaper-editor-heading">
|
||||
<span className="wallpaper-editor-icon">
|
||||
<ImageIcon size={20} />
|
||||
</span>
|
||||
<span>
|
||||
<strong>Wallpaperstudio</strong>
|
||||
<small>
|
||||
Stem de uitsnede af op het zichtbare centrale vlak. De preview
|
||||
reageert direct; pas toe wanneer het beeld goed staat.
|
||||
</small>
|
||||
</span>
|
||||
<div className="wallpaper-actions">
|
||||
<input
|
||||
ref={wallpaperInput}
|
||||
className="sr-only"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
void perform(
|
||||
() => api.uploadWallpaper(file),
|
||||
"Wallpaper bijgewerkt",
|
||||
);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
onClick={() => wallpaperInput.current?.click()}
|
||||
>
|
||||
<Upload size={16} />{" "}
|
||||
{data.wallpaper ? "Vervangen" : "Uploaden"}
|
||||
</button>
|
||||
{data.wallpaper && (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-danger-button"
|
||||
onClick={() =>
|
||||
void perform(
|
||||
() => api.removeWallpaper(),
|
||||
"Wallpaper verwijderd",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 size={16} /> Verwijderen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
className={`wallpaper-editor-preview ${preferences.backgroundStyle} ${data.wallpaper ? "has-wallpaper" : ""}`}
|
||||
aria-label="Voorbeeld van de dashboardwallpaper"
|
||||
>
|
||||
{data.wallpaper ? (
|
||||
<>
|
||||
<img
|
||||
className="wallpaper-preview-image"
|
||||
src={data.wallpaper.url}
|
||||
alt="Huidige dashboardwallpaper"
|
||||
style={{
|
||||
objectFit: wallpaperDraft.fit,
|
||||
objectPosition: `${wallpaperDraft.positionX}% ${wallpaperDraft.positionY}%`,
|
||||
transform: `scale(${wallpaperDraft.zoom / 100})`,
|
||||
transformOrigin: `${wallpaperDraft.positionX}% ${wallpaperDraft.positionY}%`,
|
||||
}}
|
||||
/>
|
||||
<img
|
||||
className="wallpaper-preview-ambient"
|
||||
src={data.wallpaper.url}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
objectPosition: `${wallpaperDraft.positionX}% ${wallpaperDraft.positionY}%`,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<span className="wallpaper-preview-empty">
|
||||
<ImageIcon size={30} />
|
||||
<small>Upload eerst een PNG, JPEG of WebP tot 8 MB.</small>
|
||||
</span>
|
||||
)}
|
||||
<span className="wallpaper-preview-overlay" />
|
||||
<span className="wallpaper-preview-sidebar" />
|
||||
<span className="wallpaper-preview-topbar" />
|
||||
<span className="wallpaper-preview-card one" />
|
||||
<span className="wallpaper-preview-card two" />
|
||||
<span className="wallpaper-preview-card three" />
|
||||
</div>
|
||||
|
||||
<div className="wallpaper-editor-controls">
|
||||
<div className="wallpaper-fit-control">
|
||||
<span>
|
||||
<strong>Schaalmethode</strong>
|
||||
<small>
|
||||
Passend toont de volledige afbeelding; Vullend benut het
|
||||
volledige vlak.
|
||||
</small>
|
||||
</span>
|
||||
<div className="segmented-control" aria-label="Schaalmethode">
|
||||
{(
|
||||
[
|
||||
["contain", "Passend"],
|
||||
["cover", "Vullend"],
|
||||
] as const
|
||||
).map(([fit, label]) => (
|
||||
<button
|
||||
key={fit}
|
||||
type="button"
|
||||
className={wallpaperDraft.fit === fit ? "active" : ""}
|
||||
aria-pressed={wallpaperDraft.fit === fit}
|
||||
onClick={() =>
|
||||
setWallpaperDraft((current) => ({ ...current, fit }))
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wallpaper-slider-grid">
|
||||
<label>
|
||||
<span>
|
||||
Horizontale focus{" "}
|
||||
<strong>{wallpaperDraft.positionX}%</strong>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={wallpaperDraft.positionX}
|
||||
aria-label="Horizontale wallpaperfocus"
|
||||
onChange={(event) =>
|
||||
setWallpaperDraft((current) => ({
|
||||
...current,
|
||||
positionX: Number(event.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>
|
||||
Verticale focus <strong>{wallpaperDraft.positionY}%</strong>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={wallpaperDraft.positionY}
|
||||
aria-label="Verticale wallpaperfocus"
|
||||
onChange={(event) =>
|
||||
setWallpaperDraft((current) => ({
|
||||
...current,
|
||||
positionY: Number(event.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>
|
||||
Zoom <strong>{wallpaperDraft.zoom}%</strong>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="80"
|
||||
max="160"
|
||||
value={wallpaperDraft.zoom}
|
||||
aria-label="Wallpaperzoom"
|
||||
onChange={(event) =>
|
||||
setWallpaperDraft((current) => ({
|
||||
...current,
|
||||
zoom: Number(event.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="wallpaper-editor-footer">
|
||||
<small>
|
||||
De dashboardwallpaper blijft vast in het zichtbare centrale
|
||||
vlak terwijl de inhoud scrolt.
|
||||
</small>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
onClick={() =>
|
||||
setWallpaperDraft({
|
||||
fit: "contain",
|
||||
positionX: 50,
|
||||
positionY: 50,
|
||||
zoom: 100,
|
||||
})
|
||||
}
|
||||
>
|
||||
Herstellen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button"
|
||||
disabled={!data.wallpaper || !wallpaperDraftChanged}
|
||||
onClick={() =>
|
||||
void perform(
|
||||
() =>
|
||||
api.updatePreferences({
|
||||
wallpaperFit: wallpaperDraft.fit,
|
||||
wallpaperPositionX: wallpaperDraft.positionX,
|
||||
wallpaperPositionY: wallpaperDraft.positionY,
|
||||
wallpaperZoom: wallpaperDraft.zoom,
|
||||
}),
|
||||
"Wallpaperweergave toegepast",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Check size={16} /> Toepassen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<h3 className="setting-label">Dashboarddetails</h3>
|
||||
<div className="toggle-list">
|
||||
<article>
|
||||
<span className="toggle-icon">
|
||||
<CloudSun size={18} />
|
||||
</span>
|
||||
<span>
|
||||
<strong>Weer in de topbalk</strong>
|
||||
<small>
|
||||
Toon actuele temperatuur en weersituatie voor Mol.
|
||||
</small>
|
||||
</span>
|
||||
<button
|
||||
className={`switch ${preferences.weatherEnabled ? "on" : ""}`}
|
||||
role="switch"
|
||||
aria-label="Weer in de topbalk tonen"
|
||||
aria-checked={preferences.weatherEnabled}
|
||||
onClick={() =>
|
||||
perform(
|
||||
() =>
|
||||
api.updatePreferences({
|
||||
weatherEnabled: !preferences.weatherEnabled,
|
||||
}),
|
||||
preferences.weatherEnabled
|
||||
? "Weer verborgen"
|
||||
: "Weer getoond",
|
||||
)
|
||||
}
|
||||
>
|
||||
<span />
|
||||
</button>
|
||||
</article>
|
||||
<article className="weather-location-row">
|
||||
<span className="toggle-icon">
|
||||
<CloudSun size={18} />
|
||||
</span>
|
||||
<span>
|
||||
<strong>Weerlocatie</strong>
|
||||
<small>Naam en coördinaten voor de weerkaart.</small>
|
||||
</span>
|
||||
<div className="weather-location-fields">
|
||||
<input
|
||||
aria-label="Naam weerlocatie"
|
||||
defaultValue={preferences.weatherLocation}
|
||||
onBlur={(event) => {
|
||||
const value = event.target.value.trim();
|
||||
if (value && value !== preferences.weatherLocation)
|
||||
void perform(
|
||||
() => api.updatePreferences({ weatherLocation: value }),
|
||||
"Weerlocatie bijgewerkt",
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
aria-label="Breedtegraad"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="-90"
|
||||
max="90"
|
||||
defaultValue={preferences.weatherLatitude}
|
||||
onBlur={(event) => {
|
||||
const value = Number(event.target.value);
|
||||
if (Number.isFinite(value))
|
||||
void perform(
|
||||
() => api.updatePreferences({ weatherLatitude: value }),
|
||||
"Breedtegraad bijgewerkt",
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
aria-label="Lengtegraad"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="-180"
|
||||
max="180"
|
||||
defaultValue={preferences.weatherLongitude}
|
||||
onBlur={(event) => {
|
||||
const value = Number(event.target.value);
|
||||
if (Number.isFinite(value))
|
||||
void perform(
|
||||
() =>
|
||||
api.updatePreferences({ weatherLongitude: value }),
|
||||
"Lengtegraad bijgewerkt",
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
<article>
|
||||
<span className="toggle-icon">
|
||||
<Sparkles size={18} />
|
||||
</span>
|
||||
<span>
|
||||
<strong>Welkomstblok</strong>
|
||||
<small>
|
||||
Toon de begroeting, het overzicht en snelle statistieken.
|
||||
</small>
|
||||
</span>
|
||||
<button
|
||||
className={`switch ${preferences.showHero ? "on" : ""}`}
|
||||
role="switch"
|
||||
aria-label="Welkomstblok tonen"
|
||||
aria-checked={preferences.showHero}
|
||||
onClick={() =>
|
||||
perform(
|
||||
() =>
|
||||
api.updatePreferences({
|
||||
showHero: !preferences.showHero,
|
||||
}),
|
||||
preferences.showHero
|
||||
? "Welkomstblok verborgen"
|
||||
: "Welkomstblok getoond",
|
||||
)
|
||||
}
|
||||
>
|
||||
<span />
|
||||
</button>
|
||||
</article>
|
||||
<article>
|
||||
<span className="toggle-icon">
|
||||
<Eye size={18} />
|
||||
</span>
|
||||
<span>
|
||||
<strong>Servicestatus</strong>
|
||||
<small>
|
||||
Toon beschikbaarheidstekst en statusindicatoren op tegels.
|
||||
</small>
|
||||
</span>
|
||||
<button
|
||||
className={`switch ${preferences.showStatus ? "on" : ""}`}
|
||||
role="switch"
|
||||
aria-label="Servicestatus tonen"
|
||||
aria-checked={preferences.showStatus}
|
||||
onClick={() =>
|
||||
perform(
|
||||
() =>
|
||||
api.updatePreferences({
|
||||
showStatus: !preferences.showStatus,
|
||||
}),
|
||||
preferences.showStatus
|
||||
? "Servicestatus verborgen"
|
||||
: "Servicestatus getoond",
|
||||
)
|
||||
}
|
||||
>
|
||||
<span />
|
||||
</button>
|
||||
</article>
|
||||
<article>
|
||||
<span className="toggle-icon">
|
||||
<Server size={18} />
|
||||
</span>
|
||||
<span>
|
||||
<strong>Unraid-serverpaneel</strong>
|
||||
<small>
|
||||
Toon hostcapaciteit, containerstatus en Docker-details.
|
||||
</small>
|
||||
</span>
|
||||
<button
|
||||
className={`switch ${preferences.showServerWidget ? "on" : ""}`}
|
||||
role="switch"
|
||||
aria-label="Unraid-serverpaneel tonen"
|
||||
aria-checked={preferences.showServerWidget}
|
||||
onClick={() =>
|
||||
perform(
|
||||
() =>
|
||||
api.updatePreferences({
|
||||
showServerWidget: !preferences.showServerWidget,
|
||||
}),
|
||||
preferences.showServerWidget
|
||||
? "Unraid-paneel verborgen"
|
||||
: "Unraid-paneel getoond",
|
||||
)
|
||||
}
|
||||
>
|
||||
<span />
|
||||
</button>
|
||||
</article>
|
||||
<article>
|
||||
<span className="toggle-icon">
|
||||
<Accessibility size={18} />
|
||||
</span>
|
||||
<span>
|
||||
<strong>Verminderde beweging</strong>
|
||||
<small>
|
||||
Schakel decoratieve animaties en vloeiende overgangen uit.
|
||||
</small>
|
||||
</span>
|
||||
<button
|
||||
className={`switch ${preferences.reducedMotion ? "on" : ""}`}
|
||||
role="switch"
|
||||
aria-label="Verminderde beweging"
|
||||
aria-checked={preferences.reducedMotion}
|
||||
onClick={() =>
|
||||
perform(
|
||||
() =>
|
||||
api.updatePreferences({
|
||||
reducedMotion: !preferences.reducedMotion,
|
||||
}),
|
||||
preferences.reducedMotion
|
||||
? "Animaties ingeschakeld"
|
||||
: "Beweging verminderd",
|
||||
)
|
||||
}
|
||||
>
|
||||
<span />
|
||||
</button>
|
||||
</article>
|
||||
<article>
|
||||
<span className="toggle-icon">
|
||||
<RefreshCw size={18} />
|
||||
</span>
|
||||
<span>
|
||||
<strong>Verversingsinterval</strong>
|
||||
<small>
|
||||
Hoe vaak DockDeck statussen en actieve panelen opnieuw
|
||||
ophaalt.
|
||||
</small>
|
||||
</span>
|
||||
<label className="inline-select-control">
|
||||
<span className="sr-only">Verversingsinterval</span>
|
||||
<select
|
||||
name="pollingIntervalMs"
|
||||
value={preferences.pollingIntervalMs}
|
||||
aria-label="Verversingsinterval"
|
||||
onChange={(event) =>
|
||||
void perform(
|
||||
() =>
|
||||
api.updatePreferences({
|
||||
pollingIntervalMs: Number(event.target.value),
|
||||
}),
|
||||
"Verversingsinterval bijgewerkt",
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value={10000}>10 seconden</option>
|
||||
<option value={30000}>30 seconden</option>
|
||||
<option value={60000}>1 minuut</option>
|
||||
<option value={120000}>2 minuten</option>
|
||||
<option value={300000}>5 minuten</option>
|
||||
</select>
|
||||
</label>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
import { useMemo, useRef, useState, type DragEvent } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
BookmarkPlus,
|
||||
Check,
|
||||
CircleCheck,
|
||||
CircleX,
|
||||
FileUp,
|
||||
Folder,
|
||||
ShieldCheck,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import type { Category, Favorite } from "../../shared/contracts";
|
||||
import { api } from "../api";
|
||||
import { nextAvailableCategoryIcon } from "../categoryIcons";
|
||||
import {
|
||||
MAX_BOOKMARK_FILE_BYTES,
|
||||
normalizeBookmarkUrl,
|
||||
parseBookmarkHtml,
|
||||
type ParsedBookmark,
|
||||
} from "../bookmark-import";
|
||||
|
||||
type ImportStatus = "ready" | "duplicate" | "error" | "imported";
|
||||
|
||||
interface ImportRow extends ParsedBookmark {
|
||||
status: ImportStatus;
|
||||
statusMessage: string;
|
||||
}
|
||||
|
||||
type Performer = (
|
||||
action: () => Promise<unknown>,
|
||||
message: string,
|
||||
) => Promise<boolean>;
|
||||
|
||||
interface Props {
|
||||
categories: Category[];
|
||||
favorites: Favorite[];
|
||||
perform: Performer;
|
||||
}
|
||||
|
||||
function categoryKey(name: string): string {
|
||||
return name.trim().toLocaleLowerCase("nl");
|
||||
}
|
||||
|
||||
function lastSortOrder(items: readonly { sortOrder: number }[]): number {
|
||||
return items.reduce((maximum, item) => Math.max(maximum, item.sortOrder), 0);
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error
|
||||
? error.message
|
||||
: "Deze bladwijzer kon niet worden opgeslagen.";
|
||||
}
|
||||
|
||||
export function BookmarkImport({ categories, favorites, perform }: Props) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [fileName, setFileName] = useState<string | null>(null);
|
||||
const [rows, setRows] = useState<ImportRow[]>([]);
|
||||
const [warnings, setWarnings] = useState<string[]>([]);
|
||||
const [parseError, setParseError] = useState<string | null>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [applying, setApplying] = useState(false);
|
||||
|
||||
const counts = useMemo(
|
||||
() =>
|
||||
rows.reduce(
|
||||
(total, row) => ({ ...total, [row.status]: total[row.status] + 1 }),
|
||||
{ ready: 0, duplicate: 0, error: 0, imported: 0 },
|
||||
),
|
||||
[rows],
|
||||
);
|
||||
|
||||
const readFile = async (file: File | undefined) => {
|
||||
if (!file) return;
|
||||
setFileName(file.name);
|
||||
setRows([]);
|
||||
setWarnings([]);
|
||||
setParseError(null);
|
||||
|
||||
if (file.size > MAX_BOOKMARK_FILE_BYTES) {
|
||||
setParseError("Het bladwijzerbestand mag maximaal 2 MB groot zijn.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = parseBookmarkHtml(await file.text());
|
||||
const knownCategories = new Map(
|
||||
categories.map((category) => [categoryKey(category.name), category]),
|
||||
);
|
||||
const existingByUrl = new Map<string, string>();
|
||||
for (const favorite of favorites) {
|
||||
try {
|
||||
existingByUrl.set(
|
||||
normalizeBookmarkUrl(favorite.url).key,
|
||||
favorite.name,
|
||||
);
|
||||
} catch {
|
||||
// Existing favorites are validated by the API; skip unexpected legacy values.
|
||||
}
|
||||
}
|
||||
|
||||
const seenUrls = new Set<string>();
|
||||
const previewRows = parsed.bookmarks.map<ImportRow>((bookmark) => {
|
||||
if (bookmark.error || !bookmark.normalizedUrl || !bookmark.url) {
|
||||
return {
|
||||
...bookmark,
|
||||
status: "error",
|
||||
statusMessage: bookmark.error ?? "De bladwijzer is ongeldig.",
|
||||
};
|
||||
}
|
||||
|
||||
const existingName = existingByUrl.get(bookmark.normalizedUrl);
|
||||
if (existingName) {
|
||||
return {
|
||||
...bookmark,
|
||||
status: "duplicate",
|
||||
statusMessage: `Deze URL bestaat al als ${existingName}.`,
|
||||
};
|
||||
}
|
||||
if (seenUrls.has(bookmark.normalizedUrl)) {
|
||||
return {
|
||||
...bookmark,
|
||||
status: "duplicate",
|
||||
statusMessage: "Deze URL staat al eerder in dit bestand.",
|
||||
};
|
||||
}
|
||||
seenUrls.add(bookmark.normalizedUrl);
|
||||
|
||||
const categoryExists =
|
||||
bookmark.categoryName !== null &&
|
||||
knownCategories.has(categoryKey(bookmark.categoryName));
|
||||
return {
|
||||
...bookmark,
|
||||
status: "ready",
|
||||
statusMessage: bookmark.categoryName
|
||||
? categoryExists
|
||||
? `Map: ${knownCategories.get(categoryKey(bookmark.categoryName))!.name}`
|
||||
: `Nieuwe map: ${bookmark.categoryName}`
|
||||
: "Zonder map",
|
||||
};
|
||||
});
|
||||
|
||||
setRows(previewRows);
|
||||
setWarnings(parsed.warnings);
|
||||
if (previewRows.length === 0) {
|
||||
setParseError(
|
||||
"In dit bestand zijn geen Netscape-bladwijzers gevonden.",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
setParseError(errorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
const applyImport = async () => {
|
||||
const readyRows = rows.filter((row) => row.status === "ready");
|
||||
if (readyRows.length === 0 || applying) return;
|
||||
setApplying(true);
|
||||
try {
|
||||
await perform(async () => {
|
||||
const categoryIds = new Map<string, string>();
|
||||
const categoryErrors = new Map<string, string>();
|
||||
let favoriteSortOrder = lastSortOrder(favorites);
|
||||
const favoriteSlots = Math.max(0, 10_000 - favoriteSortOrder);
|
||||
const rowsWithOrder = readyRows.slice(0, favoriteSlots);
|
||||
const outcomes = new Map<string, ImportRow>();
|
||||
for (const row of readyRows.slice(favoriteSlots)) {
|
||||
outcomes.set(row.id, {
|
||||
...row,
|
||||
status: "error",
|
||||
statusMessage: "Er is geen ruimte meer voor een favorietvolgorde.",
|
||||
});
|
||||
}
|
||||
for (const category of categories) {
|
||||
categoryIds.set(categoryKey(category.name), category.id);
|
||||
}
|
||||
|
||||
const categoryNames = [
|
||||
...new Set(
|
||||
rowsWithOrder
|
||||
.map((row) => row.categoryName)
|
||||
.filter((name): name is string => Boolean(name)),
|
||||
),
|
||||
];
|
||||
const usedIcons = categories.map((category) => category.icon);
|
||||
let categorySortOrder = lastSortOrder(categories);
|
||||
|
||||
for (const name of categoryNames) {
|
||||
const key = categoryKey(name);
|
||||
if (categoryIds.has(key)) continue;
|
||||
categorySortOrder += 1;
|
||||
if (categorySortOrder > 10_000) {
|
||||
categoryErrors.set(
|
||||
key,
|
||||
"Er is geen ruimte meer voor een nieuwe map.",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const icon = nextAvailableCategoryIcon(usedIcons);
|
||||
usedIcons.push(icon);
|
||||
try {
|
||||
const category = await api.createCategory({
|
||||
name,
|
||||
icon,
|
||||
sortOrder: categorySortOrder,
|
||||
});
|
||||
categoryIds.set(key, category.id);
|
||||
} catch (error) {
|
||||
categoryErrors.set(key, errorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of rowsWithOrder) {
|
||||
const key = row.categoryName ? categoryKey(row.categoryName) : null;
|
||||
if (key && categoryErrors.has(key)) {
|
||||
outcomes.set(row.id, {
|
||||
...row,
|
||||
status: "error",
|
||||
statusMessage: `Map niet aangemaakt: ${categoryErrors.get(key)}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
favoriteSortOrder += 1;
|
||||
try {
|
||||
await api.createFavorite({
|
||||
name: row.title,
|
||||
url: row.url!,
|
||||
icon: null,
|
||||
categoryId: key ? (categoryIds.get(key) ?? null) : null,
|
||||
sortOrder: favoriteSortOrder,
|
||||
});
|
||||
outcomes.set(row.id, {
|
||||
...row,
|
||||
status: "imported",
|
||||
statusMessage: "Geïmporteerd",
|
||||
});
|
||||
} catch (error) {
|
||||
outcomes.set(row.id, {
|
||||
...row,
|
||||
status: "error",
|
||||
statusMessage: errorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setRows((current) => current.map((row) => outcomes.get(row.id) ?? row));
|
||||
}, "Bladwijzerimport afgerond");
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
setDragging(false);
|
||||
void readFile(event.dataTransfer.files[0]);
|
||||
};
|
||||
|
||||
const clearPreview = () => {
|
||||
setFileName(null);
|
||||
setRows([]);
|
||||
setWarnings([]);
|
||||
setParseError(null);
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="settings-heading">
|
||||
<p className="eyebrow">FAVORIETEN EN LINKS</p>
|
||||
<h2>Bladwijzers importeren</h2>
|
||||
<p>
|
||||
Importeer een Netscape Bookmark HTML-export. Mappen worden categorieën
|
||||
en bladwijzers worden favorieten. Bekijk eerst wat wordt toegevoegd.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="bookmark-privacy-note">
|
||||
<ShieldCheck size={19} />
|
||||
<span>
|
||||
<strong>Het bestand blijft in deze browser</strong>
|
||||
<small>
|
||||
DockDeck bezoekt, controleert of crawlt de geïmporteerde URL’s niet.
|
||||
Cookies, geschiedenis en wachtwoorden worden niet ingelezen.
|
||||
</small>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<section
|
||||
className={`bookmark-upload ${dragging ? "is-dragging" : ""}`}
|
||||
aria-label="Bladwijzerbestand uploaden"
|
||||
onDragEnter={(event) => {
|
||||
event.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDragLeave={(event) => {
|
||||
if (
|
||||
!event.currentTarget.contains(event.relatedTarget as Node | null)
|
||||
) {
|
||||
setDragging(false);
|
||||
}
|
||||
}}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="bookmark-file-input"
|
||||
type="file"
|
||||
accept=".html,.htm,text/html,application/xhtml+xml"
|
||||
aria-label="Netscape Bookmark HTML-bestand kiezen"
|
||||
onChange={(event) => {
|
||||
void readFile(event.currentTarget.files?.[0]);
|
||||
event.currentTarget.value = "";
|
||||
}}
|
||||
/>
|
||||
<span className="bookmark-upload-icon">
|
||||
<FileUp size={23} />
|
||||
</span>
|
||||
<strong>{fileName ?? "Kies een bookmarkbestand"}</strong>
|
||||
<span>
|
||||
Sleep het Netscape HTML-bestand hierheen of blader naar een bestand
|
||||
</span>
|
||||
<small>HTML · maximaal 2 MB · tot 500 bladwijzers</small>
|
||||
<button
|
||||
className="secondary-button"
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
<Upload size={16} /> Bestand kiezen
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{parseError && (
|
||||
<p className="bookmark-import-message error" role="alert">
|
||||
<CircleX size={16} /> {parseError}
|
||||
</p>
|
||||
)}
|
||||
{warnings.map((warning) => (
|
||||
<p
|
||||
className="bookmark-import-message warning"
|
||||
role="status"
|
||||
key={warning}
|
||||
>
|
||||
<AlertCircle size={16} /> {warning}
|
||||
</p>
|
||||
))}
|
||||
|
||||
{rows.length > 0 && (
|
||||
<section
|
||||
className="bookmark-preview"
|
||||
aria-labelledby="bookmark-preview-title"
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<p className="eyebrow">VOORBEELD</p>
|
||||
<h3 id="bookmark-preview-title">Controleer de import</h3>
|
||||
<p>
|
||||
Bestaande favorieten blijven behouden. Dubbelen en ongeldige
|
||||
items worden overgeslagen.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
onClick={clearPreview}
|
||||
disabled={applying}
|
||||
>
|
||||
Ander bestand kiezen
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="bookmark-import-summary" aria-label="Importoverzicht">
|
||||
<span>
|
||||
<Check size={15} /> {counts.ready} klaar
|
||||
</span>
|
||||
<span>
|
||||
<AlertCircle size={15} /> {counts.duplicate} dubbelen
|
||||
</span>
|
||||
<span>
|
||||
<CircleX size={15} /> {counts.error} fouten
|
||||
</span>
|
||||
<span>
|
||||
<CircleCheck size={15} /> {counts.imported} toegevoegd
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ol
|
||||
className="bookmark-preview-list"
|
||||
aria-label="Bladwijzers in de preview"
|
||||
>
|
||||
{rows.map((row) => (
|
||||
<li className={`bookmark-preview-row ${row.status}`} key={row.id}>
|
||||
<span className="bookmark-row-icon" aria-hidden="true">
|
||||
{row.categoryName ? (
|
||||
<Folder size={16} />
|
||||
) : (
|
||||
<BookmarkPlus size={16} />
|
||||
)}
|
||||
</span>
|
||||
<span className="bookmark-row-copy">
|
||||
<strong>{row.title || "Zonder titel"}</strong>
|
||||
{row.status === "error" ? (
|
||||
<small>{row.statusMessage}</small>
|
||||
) : (
|
||||
<>
|
||||
<code>{row.url}</code>
|
||||
<small>{row.statusMessage}</small>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<span className={`bookmark-row-status ${row.status}`}>
|
||||
{row.status === "ready"
|
||||
? "Klaar"
|
||||
: row.status === "duplicate"
|
||||
? "Dubbel"
|
||||
: row.status === "imported"
|
||||
? "Toegevoegd"
|
||||
: "Fout"}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<footer className="bookmark-preview-footer">
|
||||
<span>
|
||||
{counts.ready > 0
|
||||
? `${counts.ready} favorieten worden toegevoegd na bevestiging.`
|
||||
: "Er staan geen nieuwe geldige favorieten klaar."}
|
||||
</span>
|
||||
<button
|
||||
className="primary-button"
|
||||
type="button"
|
||||
onClick={() => void applyImport()}
|
||||
disabled={counts.ready === 0 || applying}
|
||||
>
|
||||
<Check size={16} />
|
||||
{applying
|
||||
? "Importeren…"
|
||||
: `${counts.ready} favorieten toevoegen`}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
interface BrandProps {
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function Brand({ compact = false }: BrandProps) {
|
||||
return (
|
||||
<span className={compact ? "brand-lockup compact" : "brand-lockup"}>
|
||||
<img className="dockdeck-mark" src="/dockdeck-mark-v2.svg" alt="" />
|
||||
<span className="brand-product">
|
||||
<strong>DockDeck</strong>
|
||||
{!compact && <small>Jouw digitale thuisbasis</small>}
|
||||
</span>
|
||||
<span
|
||||
className="brand-owner"
|
||||
aria-label="Ontwikkeld door Jens van ITWorx.tech"
|
||||
>
|
||||
<span className="brand-credit">
|
||||
<small>Ontwikkeld door</small>
|
||||
<strong>Jens</strong>
|
||||
</span>
|
||||
<img src="/itworx-wordmark.svg" alt="ITWorx.tech" />
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { resolveCategoryIcon } from "../categoryIcons";
|
||||
|
||||
interface CategoryIconProps {
|
||||
name: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export function CategoryIcon({ name, size = 16 }: CategoryIconProps) {
|
||||
const Icon = resolveCategoryIcon(name);
|
||||
return <Icon size={size} aria-hidden="true" />;
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
import {
|
||||
ChevronRight,
|
||||
Code2,
|
||||
PanelsTopLeft,
|
||||
Settings2,
|
||||
Star,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
type CSSProperties,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { DashboardPayload } from "../../shared/contracts";
|
||||
import { effectiveAppUrl, sortByPosition } from "../../shared/domain";
|
||||
import { AppTile, EmptyIcon, FavoriteTile } from "./AppTile";
|
||||
import { CategoryIcon } from "./CategoryIcon";
|
||||
import { DashboardHero } from "./DashboardHero";
|
||||
import {
|
||||
DashboardLaunchHistory,
|
||||
type LaunchHistoryItem,
|
||||
} from "./DashboardLaunchHistory";
|
||||
import { ServiceWidgets } from "./ServiceWidgets";
|
||||
import { DashboardTopBar } from "./DashboardTopBar";
|
||||
import { SearchOverlay } from "./SearchOverlay";
|
||||
import { DashboardSidebar } from "./DashboardSidebar";
|
||||
import { SortableList } from "./SortableList";
|
||||
import { useStoredBoolean } from "../useStoredBoolean";
|
||||
import type { SortableEntity } from "../persist-sort-order";
|
||||
import { reorderWithinSlots } from "../sortable-order";
|
||||
import {
|
||||
makeLaunchKey,
|
||||
selectLaunchHistory,
|
||||
type LaunchKey,
|
||||
} from "../launch-history";
|
||||
import { useLaunchHistory } from "../useLaunchHistory";
|
||||
|
||||
interface Props {
|
||||
data: DashboardPayload;
|
||||
syncing: boolean;
|
||||
onSync: () => Promise<void>;
|
||||
onReorder: (
|
||||
entity: SortableEntity,
|
||||
items: readonly { id: string }[],
|
||||
) => Promise<boolean | void>;
|
||||
onSettings: (section?: "general" | "apps" | "favorites") => void;
|
||||
}
|
||||
|
||||
const featureCategory = (name: string) =>
|
||||
/smart|home|domotica|netwerk|network|security|beveilig/i.test(name);
|
||||
|
||||
const compactService = (technicalName: string) =>
|
||||
/homeassistant|authentik|vaultwarden|nginxproxymanager/i.test(
|
||||
technicalName.replace(/[^a-z0-9]/gi, ""),
|
||||
);
|
||||
|
||||
export function Dashboard({
|
||||
data,
|
||||
syncing,
|
||||
onSync,
|
||||
onReorder,
|
||||
onSettings,
|
||||
}: Props) {
|
||||
const [activeCategory, setActiveCategory] = useState<string>("all");
|
||||
const [leftRailCollapsed, setLeftRailCollapsed] = useStoredBoolean(
|
||||
"dockdeck.ui.v1.dashboard.leftRailCollapsed",
|
||||
() => false,
|
||||
);
|
||||
const [widgetPanelOpen, setWidgetPanelOpen] = useStoredBoolean(
|
||||
"dockdeck.ui.v1.dashboard.widgetPanelOpen",
|
||||
() => window.matchMedia("(min-width: 2200px)").matches,
|
||||
);
|
||||
const [personalProjectsOnly, setPersonalProjectsOnly] = useStoredBoolean(
|
||||
"dockdeck.ui.v1.dashboard.personalProjectsOnly",
|
||||
() => false,
|
||||
);
|
||||
const {
|
||||
entries: launchEntries,
|
||||
record: recordLaunchHistory,
|
||||
clear: clearLaunchHistory,
|
||||
retainAvailable,
|
||||
} = useLaunchHistory();
|
||||
const recordLaunch = useCallback(
|
||||
(key: LaunchKey) => {
|
||||
// Let the anchor's native target=_blank action run before rerendering its
|
||||
// parent. This keeps tracked launches behaving like ordinary links.
|
||||
window.setTimeout(() => recordLaunchHistory(key), 0);
|
||||
},
|
||||
[recordLaunchHistory],
|
||||
);
|
||||
const focusedApps = useMemo(
|
||||
() =>
|
||||
personalProjectsOnly
|
||||
? data.apps.filter((app) => app.personalProject)
|
||||
: data.apps,
|
||||
[data.apps, personalProjectsOnly],
|
||||
);
|
||||
const focusedData = personalProjectsOnly
|
||||
? { ...data, apps: focusedApps }
|
||||
: data;
|
||||
const visibleApps = useMemo(
|
||||
() => sortByPosition(focusedApps.filter((app) => app.visible)),
|
||||
[focusedApps],
|
||||
);
|
||||
const categories = sortByPosition(data.categories);
|
||||
const grouped = categories
|
||||
.map((category) => ({
|
||||
category,
|
||||
apps: visibleApps.filter((app) => app.categoryId === category.id),
|
||||
}))
|
||||
.filter((group) => group.apps.length);
|
||||
const ungroupedApps = visibleApps.filter((app) => !app.categoryId);
|
||||
const allGroups = [
|
||||
...grouped,
|
||||
...(ungroupedApps.length
|
||||
? [
|
||||
{
|
||||
category: {
|
||||
id: "uncategorized",
|
||||
name: "Overig",
|
||||
icon: "Box",
|
||||
sortOrder: 9999,
|
||||
},
|
||||
apps: ungroupedApps,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
const shownGroups =
|
||||
activeCategory === "all"
|
||||
? allGroups
|
||||
: allGroups.filter((group) => group.category.id === activeCategory);
|
||||
const degraded = data.integrations.filter(
|
||||
(integration) => integration.state === "degraded",
|
||||
);
|
||||
const onlineApps = visibleApps.filter(
|
||||
(app) => app.status === "online",
|
||||
).length;
|
||||
const newApps = focusedApps.filter((app) => app.isNew).length;
|
||||
const personalProjectCount = data.apps.filter(
|
||||
(app) => app.visible && app.personalProject,
|
||||
).length;
|
||||
const hasVisibleApps = data.apps.some((app) => app.visible);
|
||||
const widgetCount =
|
||||
focusedApps.filter((app) => app.widgetEnabled).length +
|
||||
Number(data.preferences.showServerWidget);
|
||||
const sortedFavorites = sortByPosition(data.favorites);
|
||||
const visibleFavorites =
|
||||
activeCategory === "all"
|
||||
? sortedFavorites
|
||||
: sortedFavorites.filter(
|
||||
(favorite) => favorite.categoryId === activeCategory,
|
||||
);
|
||||
const availableLaunchKeys = useMemo(
|
||||
() =>
|
||||
new Set<string>([
|
||||
...data.apps
|
||||
.filter(
|
||||
(app) =>
|
||||
app.visible &&
|
||||
effectiveAppUrl(app, data.preferences.externalAccess) !== null,
|
||||
)
|
||||
.map((app) => makeLaunchKey("app", app.id)),
|
||||
...data.favorites.map((favorite) =>
|
||||
makeLaunchKey("favorite", favorite.id),
|
||||
),
|
||||
]),
|
||||
[data.apps, data.favorites, data.preferences.externalAccess],
|
||||
);
|
||||
useEffect(() => {
|
||||
retainAvailable(availableLaunchKeys);
|
||||
}, [availableLaunchKeys, retainAvailable]);
|
||||
|
||||
const historyItems = useMemo(() => {
|
||||
const launchables = new Map<
|
||||
LaunchKey,
|
||||
Omit<LaunchHistoryItem, "count" | "lastOpenedAt">
|
||||
>();
|
||||
for (const app of visibleApps) {
|
||||
const href = effectiveAppUrl(app, data.preferences.externalAccess);
|
||||
if (!href) continue;
|
||||
const key = makeLaunchKey("app", app.id);
|
||||
launchables.set(key, {
|
||||
key,
|
||||
name: app.displayName,
|
||||
href,
|
||||
icon: app.icon,
|
||||
hint: `${app.technicalName} ${app.image ?? ""}`,
|
||||
});
|
||||
}
|
||||
for (const favorite of data.favorites) {
|
||||
const key = makeLaunchKey("favorite", favorite.id);
|
||||
launchables.set(key, {
|
||||
key,
|
||||
name: favorite.name,
|
||||
href: favorite.url,
|
||||
icon: favorite.icon,
|
||||
hint: favorite.url,
|
||||
});
|
||||
}
|
||||
|
||||
const currentKeys = new Set(launchables.keys());
|
||||
const select = (mode: "recent" | "most-used") =>
|
||||
selectLaunchHistory(launchEntries, currentKeys, mode).flatMap((entry) => {
|
||||
const launchable = launchables.get(entry.key);
|
||||
return launchable ? [{ ...entry, ...launchable }] : [];
|
||||
});
|
||||
|
||||
return { recent: select("recent"), mostUsed: select("most-used") };
|
||||
}, [
|
||||
data.favorites,
|
||||
data.preferences.externalAccess,
|
||||
launchEntries,
|
||||
visibleApps,
|
||||
]);
|
||||
|
||||
return (
|
||||
<main
|
||||
className={`dashboard ${data.wallpaper ? "has-wallpaper" : ""}`}
|
||||
id="main-content"
|
||||
style={
|
||||
data.wallpaper
|
||||
? ({
|
||||
"--dashboard-wallpaper-fit": data.preferences.wallpaperFit,
|
||||
"--dashboard-wallpaper-position-x": `${data.preferences.wallpaperPositionX}%`,
|
||||
"--dashboard-wallpaper-position-y": `${data.preferences.wallpaperPositionY}%`,
|
||||
"--dashboard-wallpaper-zoom": String(
|
||||
data.preferences.wallpaperZoom / 100,
|
||||
),
|
||||
} as CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<DashboardTopBar
|
||||
apps={focusedApps}
|
||||
favorites={data.favorites}
|
||||
categories={data.categories}
|
||||
server={data.serverWidget}
|
||||
externalAccess={data.preferences.externalAccess}
|
||||
weatherEnabled={data.preferences.weatherEnabled}
|
||||
weatherLocation={data.preferences.weatherLocation}
|
||||
syncing={syncing}
|
||||
onSync={() => void onSync()}
|
||||
onSettings={() => onSettings("apps")}
|
||||
onLaunch={recordLaunch}
|
||||
/>
|
||||
{data.wallpaper && (
|
||||
<div className="dashboard-wallpaper-stage" aria-hidden="true">
|
||||
<img
|
||||
className="dashboard-wallpaper-ambient"
|
||||
src={data.wallpaper.url}
|
||||
alt=""
|
||||
/>
|
||||
<img
|
||||
className="dashboard-wallpaper-image"
|
||||
src={data.wallpaper.url}
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`dashboard-workspace ${leftRailCollapsed ? "rail-collapsed" : ""}`}
|
||||
>
|
||||
<DashboardSidebar
|
||||
data={focusedData}
|
||||
syncing={syncing}
|
||||
onSync={() => void onSync()}
|
||||
onSettings={onSettings}
|
||||
collapsed={leftRailCollapsed}
|
||||
onToggleCollapsed={() => setLeftRailCollapsed(!leftRailCollapsed)}
|
||||
onLaunch={recordLaunch}
|
||||
/>
|
||||
<div className="dashboard-canvas" id="dashboard-home">
|
||||
<DashboardHero
|
||||
showHero={data.preferences.showHero}
|
||||
newApps={newApps}
|
||||
syncing={syncing}
|
||||
onSync={() => void onSync()}
|
||||
onSettings={() => onSettings("apps")}
|
||||
/>
|
||||
<DashboardLaunchHistory
|
||||
recent={historyItems.recent}
|
||||
mostUsed={historyItems.mostUsed}
|
||||
hasHistory={launchEntries.length > 0}
|
||||
onLaunch={recordLaunch}
|
||||
onClear={clearLaunchHistory}
|
||||
/>
|
||||
<div className="mobile-dashboard-search">
|
||||
<SearchOverlay
|
||||
apps={focusedApps}
|
||||
favorites={data.favorites}
|
||||
categories={data.categories}
|
||||
server={data.serverWidget}
|
||||
externalAccess={data.preferences.externalAccess}
|
||||
enableShortcut={false}
|
||||
ariaLabel="Zoek apps en favorieten mobiel"
|
||||
placeholder="Snel zoeken in services..."
|
||||
onLaunch={recordLaunch}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{degraded.length > 0 && (
|
||||
<div className="degraded-banner" role="status">
|
||||
<span className="status-pulse" />
|
||||
<span>
|
||||
<strong>Beperkte livegegevens.</strong>{" "}
|
||||
{degraded.map((item) => item.label).join(", ")} is niet
|
||||
bereikbaar; je opgeslagen apps blijven bruikbaar.
|
||||
</span>
|
||||
<button onClick={() => void onSync()}>Opnieuw proberen</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasVisibleApps && (
|
||||
<div
|
||||
className="category-tabs"
|
||||
id="category-navigation"
|
||||
aria-label="Categorieën"
|
||||
>
|
||||
<button
|
||||
className={personalProjectsOnly ? "active" : ""}
|
||||
aria-pressed={personalProjectsOnly}
|
||||
onClick={() => {
|
||||
setPersonalProjectsOnly(!personalProjectsOnly);
|
||||
setActiveCategory("all");
|
||||
}}
|
||||
>
|
||||
<Code2 size={14} />
|
||||
Mijn projecten
|
||||
<span>{personalProjectCount}</span>
|
||||
</button>
|
||||
<button
|
||||
className={activeCategory === "all" ? "active" : ""}
|
||||
onClick={() => setActiveCategory("all")}
|
||||
>
|
||||
<CategoryIcon name="Layers3" size={14} />
|
||||
Alles
|
||||
</button>
|
||||
{allGroups.map((group) => (
|
||||
<button
|
||||
key={group.category.id}
|
||||
className={
|
||||
activeCategory === group.category.id ? "active" : ""
|
||||
}
|
||||
onClick={() => setActiveCategory(group.category.id)}
|
||||
>
|
||||
<CategoryIcon name={group.category.icon} size={14} />
|
||||
{group.category.name}
|
||||
<span>{group.apps.length}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{widgetCount > 0 && (
|
||||
<details
|
||||
className={`dashboard-insights-panel ${widgetPanelOpen ? "is-expanded" : "is-collapsed"}`}
|
||||
id="dashboard-widgets"
|
||||
open={widgetPanelOpen}
|
||||
onToggle={(event) => setWidgetPanelOpen(event.currentTarget.open)}
|
||||
>
|
||||
<summary>
|
||||
<span className="insights-summary-icon">
|
||||
<PanelsTopLeft size={18} />
|
||||
</span>
|
||||
<span>
|
||||
<strong>Live inzicht</strong>
|
||||
<small>
|
||||
{widgetCount} {widgetCount === 1 ? "paneel" : "panelen"} ·
|
||||
alleen-lezen
|
||||
</small>
|
||||
</span>
|
||||
<ChevronRight className="insights-chevron" size={18} />
|
||||
</summary>
|
||||
<div className="dashboard-widgets">
|
||||
<ServiceWidgets
|
||||
data={focusedData}
|
||||
syncing={syncing}
|
||||
onSync={onSync}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{data.favorites.length > 0 && (
|
||||
<section className="favorites-section" id="favorites-section">
|
||||
<header>
|
||||
<h2>
|
||||
<Star size={22} /> Favorieten
|
||||
</h2>
|
||||
</header>
|
||||
{visibleFavorites.length > 0 ? (
|
||||
<SortableList
|
||||
items={visibleFavorites}
|
||||
label="Favorietenvolgorde"
|
||||
className="favorite-grid"
|
||||
orientation="grid"
|
||||
getItemLabel={(favorite) => favorite.name}
|
||||
onReorder={(items) => {
|
||||
const fullOrder = reorderWithinSlots(
|
||||
sortedFavorites,
|
||||
visibleFavorites.map((favorite) => favorite.id),
|
||||
items,
|
||||
);
|
||||
return onReorder("favorite", fullOrder);
|
||||
}}
|
||||
renderItem={(favorite, index, controls) => (
|
||||
<div className="dashboard-sortable-card favorite-sortable-card">
|
||||
<div className="sortable-card-tools dashboard-sort-tools">
|
||||
{controls.dragHandle}
|
||||
{controls.moveUpButton}
|
||||
{controls.moveDownButton}
|
||||
</div>
|
||||
<FavoriteTile
|
||||
favorite={favorite}
|
||||
index={index}
|
||||
onLaunch={recordLaunch}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div className="soft-empty compact-empty">
|
||||
<Star size={20} />
|
||||
<strong>Geen favorieten in deze categorie</strong>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{allGroups.length === 0 ? (
|
||||
<section className="empty-state">
|
||||
<EmptyIcon />
|
||||
<p className="eyebrow">
|
||||
{personalProjectsOnly ? "FOCUSFILTER ACTIEF" : "EEN FRIS BEGIN"}
|
||||
</p>
|
||||
<h2>
|
||||
{personalProjectsOnly
|
||||
? "Nog geen eigen projecten gemarkeerd."
|
||||
: "Je DockDeck wacht op zijn eerste app."}
|
||||
</h2>
|
||||
<p>
|
||||
{personalProjectsOnly
|
||||
? "Markeer je zelfgebouwde containers in Instellingen → Apps als eigen project."
|
||||
: `We vonden ${data.apps.length} apps. Kies welke je op je dashboard wilt zien.`}
|
||||
</p>
|
||||
<button
|
||||
className="primary-button"
|
||||
onClick={() => onSettings("apps")}
|
||||
>
|
||||
<Settings2 size={17} />
|
||||
{personalProjectsOnly
|
||||
? "Eigen projecten kiezen"
|
||||
: "Apps kiezen"}
|
||||
</button>
|
||||
</section>
|
||||
) : (
|
||||
<div className="dashboard-groups">
|
||||
{shownGroups.map((group) => (
|
||||
<section className="app-section" key={group.category.id}>
|
||||
<header>
|
||||
<h2>
|
||||
<CategoryIcon name={group.category.icon} size={22} />
|
||||
{group.category.name}
|
||||
</h2>
|
||||
</header>
|
||||
<SortableList
|
||||
items={group.apps}
|
||||
label={`Apps in ${group.category.name}`}
|
||||
className={`app-grid ${
|
||||
featureCategory(group.category.name) &&
|
||||
group.apps.some(
|
||||
(app) => !compactService(app.technicalName),
|
||||
)
|
||||
? "feature-grid"
|
||||
: ""
|
||||
}`}
|
||||
orientation="grid"
|
||||
getItemLabel={(app) => app.displayName}
|
||||
onReorder={(items) => {
|
||||
const categoryId =
|
||||
group.category.id === "uncategorized"
|
||||
? null
|
||||
: group.category.id;
|
||||
const fullCategoryOrder = sortByPosition(
|
||||
data.apps.filter(
|
||||
(app) => app.categoryId === categoryId,
|
||||
),
|
||||
);
|
||||
const orderedCategoryApps = reorderWithinSlots(
|
||||
fullCategoryOrder,
|
||||
group.apps.map((app) => app.id),
|
||||
items,
|
||||
);
|
||||
return onReorder("app", orderedCategoryApps);
|
||||
}}
|
||||
renderItem={(app, index, controls) => (
|
||||
<div className="dashboard-sortable-card">
|
||||
<div className="sortable-card-tools dashboard-sort-tools">
|
||||
{controls.dragHandle}
|
||||
{controls.moveUpButton}
|
||||
{controls.moveDownButton}
|
||||
</div>
|
||||
<AppTile
|
||||
app={app}
|
||||
externalAccess={data.preferences.externalAccess}
|
||||
showStatus={data.preferences.showStatus}
|
||||
index={index}
|
||||
categoryName={group.category.name}
|
||||
onLaunch={recordLaunch}
|
||||
variant={
|
||||
featureCategory(group.category.name) &&
|
||||
!compactService(app.technicalName)
|
||||
? "feature"
|
||||
: "standard"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<footer className="dashboard-footer">
|
||||
<span>
|
||||
<span className="online-mark" /> {onlineApps} apps online
|
||||
</span>
|
||||
<button onClick={() => onSettings("apps")}>
|
||||
Dashboard beheren <ChevronRight size={15} />
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardSkeleton() {
|
||||
return (
|
||||
<main
|
||||
className="dashboard loading-screen"
|
||||
aria-busy="true"
|
||||
aria-label="DockDeck laden"
|
||||
>
|
||||
<div className="skeleton topbar" />
|
||||
<div className="skeleton title" />
|
||||
<div className="skeleton search" />
|
||||
<div className="skeleton section" />
|
||||
<div className="skeleton-grid">
|
||||
{[1, 2, 3, 4].map((item) => (
|
||||
<div className="skeleton card" key={item} />
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { ChevronRight, RefreshCw, Sparkles } from "lucide-react";
|
||||
|
||||
interface DashboardHeroProps {
|
||||
showHero: boolean;
|
||||
newApps: number;
|
||||
syncing: boolean;
|
||||
onSync: () => void;
|
||||
onSettings: () => void;
|
||||
}
|
||||
|
||||
function getGreeting() {
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 12) return "Goedemorgen";
|
||||
if (hour < 18) return "Goedemiddag";
|
||||
return "Goedenavond";
|
||||
}
|
||||
|
||||
export function DashboardHero({
|
||||
showHero,
|
||||
newApps,
|
||||
syncing,
|
||||
onSync,
|
||||
onSettings,
|
||||
}: DashboardHeroProps) {
|
||||
if (!showHero)
|
||||
return (
|
||||
<header className="dashboard-toolbar">
|
||||
<div>
|
||||
<p className="eyebrow">JOUW DOCK</p>
|
||||
<h1>Dashboard</h1>
|
||||
</div>
|
||||
<button
|
||||
className="icon-button sync-button"
|
||||
onClick={onSync}
|
||||
disabled={syncing}
|
||||
title="Statussen vernieuwen"
|
||||
aria-label="Statussen vernieuwen"
|
||||
>
|
||||
<RefreshCw size={18} className={syncing ? "spin" : ""} />
|
||||
</button>
|
||||
</header>
|
||||
);
|
||||
|
||||
return (
|
||||
<header className="dashboard-hero">
|
||||
<div className="hero-copy">
|
||||
<h1>{getGreeting()}</h1>
|
||||
<p className="hero-intro">
|
||||
Welkom terug bij je digitale hoofdkwartier.
|
||||
</p>
|
||||
</div>
|
||||
{newApps > 0 && (
|
||||
<button className="discovery-banner" type="button" onClick={onSettings}>
|
||||
<Sparkles size={21} />
|
||||
<strong>
|
||||
{newApps} {newApps === 1 ? "nieuwe app" : "nieuwe apps"} ontdekt
|
||||
</strong>
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { Clock3, Eye, EyeOff, History, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import type { LaunchHistoryEntry, LaunchKey } from "../launch-history";
|
||||
import { ServiceIcon } from "./ServiceIcon";
|
||||
|
||||
export interface LaunchHistoryItem extends LaunchHistoryEntry {
|
||||
name: string;
|
||||
href: string;
|
||||
icon: string | null;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
recent: LaunchHistoryItem[];
|
||||
mostUsed: LaunchHistoryItem[];
|
||||
hasHistory: boolean;
|
||||
onLaunch: (key: LaunchKey) => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
function formatLastOpened(timestamp: number) {
|
||||
const minutes = Math.max(0, Math.floor((Date.now() - timestamp) / 60_000));
|
||||
if (minutes < 1) return "Zojuist geopend";
|
||||
if (minutes < 60) return `${minutes} min geleden`;
|
||||
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours} uur geleden`;
|
||||
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days} ${days === 1 ? "dag" : "dagen"} geleden`;
|
||||
}
|
||||
|
||||
export function DashboardLaunchHistory({
|
||||
recent,
|
||||
mostUsed,
|
||||
hasHistory,
|
||||
onLaunch,
|
||||
onClear,
|
||||
}: Props) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [mode, setMode] = useState<"recent" | "most-used">("recent");
|
||||
const items = mode === "recent" ? recent : mostUsed;
|
||||
|
||||
return (
|
||||
<section
|
||||
className="dashboard-launch-history"
|
||||
aria-labelledby="launch-history-title"
|
||||
data-testid="launch-history"
|
||||
>
|
||||
<header className="launch-history-header">
|
||||
<div className="launch-history-heading">
|
||||
<h2 id="launch-history-title">
|
||||
<History size={19} aria-hidden="true" /> Snel opnieuw openen
|
||||
</h2>
|
||||
<small>
|
||||
<Clock3 size={13} aria-hidden="true" /> Alleen in deze browser
|
||||
</small>
|
||||
</div>
|
||||
<div className="launch-history-actions">
|
||||
<div
|
||||
className="launch-history-tabs"
|
||||
role="group"
|
||||
aria-label="Snelstartvolgorde"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={mode === "recent"}
|
||||
onClick={() => setMode("recent")}
|
||||
>
|
||||
Recent
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={mode === "most-used"}
|
||||
onClick={() => setMode("most-used")}
|
||||
>
|
||||
Veel gebruikt
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="launch-history-action"
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
disabled={!hasHistory}
|
||||
aria-label="Recente geschiedenis wissen"
|
||||
title="Geschiedenis wissen"
|
||||
>
|
||||
<Trash2 size={15} aria-hidden="true" />
|
||||
<span>Wissen</span>
|
||||
</button>
|
||||
<button
|
||||
className="launch-history-action launch-history-toggle"
|
||||
type="button"
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{expanded ? (
|
||||
<EyeOff size={15} aria-hidden="true" />
|
||||
) : (
|
||||
<Eye size={15} aria-hidden="true" />
|
||||
)}
|
||||
<span>{expanded ? "Verbergen" : "Tonen"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
{expanded && (
|
||||
<div id="launch-history-items">
|
||||
{items.length > 0 ? (
|
||||
<div className="launch-history-grid">
|
||||
{items.map((item, index) => (
|
||||
<a
|
||||
key={item.key}
|
||||
className="launch-history-card"
|
||||
href={item.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-launch-key={item.key}
|
||||
onClick={() => onLaunch(item.key)}
|
||||
aria-label={`${item.name}, ${item.count} keer geopend`}
|
||||
>
|
||||
<ServiceIcon
|
||||
name={item.name}
|
||||
icon={item.icon}
|
||||
hint={item.hint}
|
||||
index={index}
|
||||
className="launch-history-icon"
|
||||
/>
|
||||
<span className="launch-history-copy">
|
||||
<strong>{item.name}</strong>
|
||||
<small>
|
||||
{mode === "most-used"
|
||||
? `${item.count} keer geopend`
|
||||
: formatLastOpened(item.lastOpenedAt)}
|
||||
</small>
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="launch-history-empty">
|
||||
Je geopende apps en favorieten verschijnen hier.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import {
|
||||
ArrowUpRight,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Folder,
|
||||
PanelsTopLeft,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
Star,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { DashboardPayload } from "../../shared/contracts";
|
||||
import { makeLaunchKey, type LaunchKey } from "../launch-history";
|
||||
import {
|
||||
favoriteIconUrl,
|
||||
groupFavoritesIntoFolders,
|
||||
} from "../../shared/domain";
|
||||
import { ServiceIcon } from "./ServiceIcon";
|
||||
|
||||
interface DashboardSidebarProps {
|
||||
data: DashboardPayload;
|
||||
syncing: boolean;
|
||||
onSync: () => void;
|
||||
onSettings: (section: "general" | "favorites") => void;
|
||||
collapsed: boolean;
|
||||
onToggleCollapsed: () => void;
|
||||
onLaunch?: (key: LaunchKey) => void;
|
||||
}
|
||||
|
||||
export function DashboardSidebar({
|
||||
data,
|
||||
syncing,
|
||||
onSync,
|
||||
onSettings,
|
||||
collapsed,
|
||||
onToggleCollapsed,
|
||||
onLaunch,
|
||||
}: DashboardSidebarProps) {
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
const [openFolders, setOpenFolders] = useState<Set<string>>(() => new Set());
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNow(new Date()), 30_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const favoriteFolders = useMemo(
|
||||
() => groupFavoritesIntoFolders(data.favorites, data.categories),
|
||||
[data.categories, data.favorites],
|
||||
);
|
||||
const visibleApps = data.apps.filter((app) => app.visible);
|
||||
const onlineApps = visibleApps.filter((app) => app.status === "online");
|
||||
const onlinePercent = visibleApps.length
|
||||
? Math.round((onlineApps.length / visibleApps.length) * 100)
|
||||
: 0;
|
||||
const widgetCount =
|
||||
data.apps.filter((app) => app.widgetEnabled).length +
|
||||
Number(data.preferences.showServerWidget);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`dashboard-rail ${collapsed ? "is-collapsed" : ""}`}
|
||||
aria-label="Favorieten en deckstatus"
|
||||
>
|
||||
<section className="rail-intro">
|
||||
<div className="rail-intro-copy">
|
||||
<span>
|
||||
{now.toLocaleDateString("nl-BE", {
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
})}
|
||||
</span>
|
||||
<strong>
|
||||
{now.toLocaleTimeString("nl-BE", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</strong>
|
||||
</div>
|
||||
<button
|
||||
className="rail-collapse-button"
|
||||
type="button"
|
||||
onClick={onToggleCollapsed}
|
||||
aria-label={
|
||||
collapsed ? "Linkerzijbalk uitklappen" : "Linkerzijbalk inklappen"
|
||||
}
|
||||
title={
|
||||
collapsed ? "Linkerzijbalk uitklappen" : "Linkerzijbalk inklappen"
|
||||
}
|
||||
aria-expanded={!collapsed}
|
||||
>
|
||||
{collapsed ? (
|
||||
<PanelLeftOpen size={18} />
|
||||
) : (
|
||||
<PanelLeftClose size={18} />
|
||||
)}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="rail-favorites" id="favorite-sidebar">
|
||||
<header>
|
||||
<span>
|
||||
<Star size={15} /> Favorieten
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSettings("favorites")}
|
||||
aria-label="Favorieten en mappen beheren"
|
||||
title="Favorieten en mappen beheren"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</header>
|
||||
{favoriteFolders.length > 0 ? (
|
||||
<nav aria-label="Favorietenmappen">
|
||||
{favoriteFolders.map((folder, folderIndex) => {
|
||||
const isOpen = openFolders.has(folder.id);
|
||||
return (
|
||||
<section className="rail-favorite-folder" key={folder.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="rail-folder-toggle"
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={`favorite-folder-${folder.id}`}
|
||||
onClick={() =>
|
||||
setOpenFolders((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(folder.id)) next.delete(folder.id);
|
||||
else next.add(folder.id);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
>
|
||||
<Folder size={16} aria-hidden="true" />
|
||||
<span>{folder.name}</span>
|
||||
<small>{folder.favorites.length}</small>
|
||||
<ChevronDown size={15} aria-hidden="true" />
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div
|
||||
className="rail-folder-links"
|
||||
id={`favorite-folder-${folder.id}`}
|
||||
>
|
||||
{folder.favorites.map((favorite, favoriteIndex) => (
|
||||
<a
|
||||
key={favorite.id}
|
||||
href={favorite.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() =>
|
||||
onLaunch?.(makeLaunchKey("favorite", favorite.id))
|
||||
}
|
||||
>
|
||||
<ServiceIcon
|
||||
name={favorite.name}
|
||||
icon={favoriteIconUrl(favorite)}
|
||||
hint={favorite.url}
|
||||
index={folderIndex + favoriteIndex}
|
||||
className="rail-app-icon"
|
||||
/>
|
||||
<span>
|
||||
<strong>{favorite.name}</strong>
|
||||
<small>{new URL(favorite.url).hostname}</small>
|
||||
</span>
|
||||
<ArrowUpRight size={14} aria-hidden="true" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
) : (
|
||||
<button
|
||||
className="rail-empty-action"
|
||||
type="button"
|
||||
onClick={() => onSettings("favorites")}
|
||||
>
|
||||
<Star size={18} />
|
||||
<span>
|
||||
<strong>Maak je eerste favoriet of map</strong>
|
||||
<small>
|
||||
Groepeer je snelle links rechtstreeks in de favorietenbalk
|
||||
</small>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rail-deck-health" aria-label="Deckstatus">
|
||||
<header>
|
||||
<span>
|
||||
<Sparkles size={14} /> Deckstatus
|
||||
</span>
|
||||
<strong>{onlinePercent}%</strong>
|
||||
</header>
|
||||
<div className="rail-health-bar" aria-hidden="true">
|
||||
<span style={{ width: `${onlinePercent}%` }} />
|
||||
</div>
|
||||
<p>
|
||||
<CheckCircle2 size={13} /> {onlineApps.length} van{" "}
|
||||
{visibleApps.length} zichtbare apps online
|
||||
</p>
|
||||
<button type="button" onClick={onSync} disabled={syncing}>
|
||||
<RefreshCw size={13} className={syncing ? "spin" : ""} />
|
||||
{syncing ? "Status ophalen" : "Nu vernieuwen"}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<button
|
||||
className="rail-widget-link"
|
||||
type="button"
|
||||
onClick={() => onSettings("general")}
|
||||
>
|
||||
<span className="rail-widget-icon">
|
||||
<PanelsTopLeft size={19} />
|
||||
</span>
|
||||
<span>
|
||||
<strong>Widgets</strong>
|
||||
<small>
|
||||
{widgetCount > 0
|
||||
? `${widgetCount} livepane${widgetCount === 1 ? "el" : "len"}`
|
||||
: "Kies je livepanelen"}
|
||||
</small>
|
||||
</span>
|
||||
<ChevronRight size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { RefreshCw, Settings } from "lucide-react";
|
||||
import type {
|
||||
Category,
|
||||
DockApp,
|
||||
Favorite,
|
||||
ServerWidgetMetric,
|
||||
} from "../../shared/contracts";
|
||||
import type { LaunchKey } from "../launch-history";
|
||||
import { Brand } from "./Brand";
|
||||
import { SearchOverlay } from "./SearchOverlay";
|
||||
import { WeatherBadge } from "./WeatherBadge";
|
||||
|
||||
interface DashboardTopBarProps {
|
||||
apps: DockApp[];
|
||||
favorites: Favorite[];
|
||||
categories: Category[];
|
||||
server: ServerWidgetMetric | null;
|
||||
externalAccess: boolean;
|
||||
weatherEnabled: boolean;
|
||||
weatherLocation: string;
|
||||
syncing: boolean;
|
||||
onSync: () => void;
|
||||
onSettings: () => void;
|
||||
onLaunch?: (key: LaunchKey) => void;
|
||||
}
|
||||
|
||||
export function DashboardTopBar({
|
||||
apps,
|
||||
favorites,
|
||||
categories,
|
||||
server,
|
||||
externalAccess,
|
||||
weatherEnabled,
|
||||
weatherLocation,
|
||||
syncing,
|
||||
onSync,
|
||||
onSettings,
|
||||
onLaunch,
|
||||
}: DashboardTopBarProps) {
|
||||
const scrollTo = (id: string) =>
|
||||
document.getElementById(id)?.scrollIntoView({ behavior: "smooth" });
|
||||
return (
|
||||
<header className="dashboard-topbar">
|
||||
<div className="dashboard-topbar-inner">
|
||||
<button
|
||||
className="brand-button"
|
||||
type="button"
|
||||
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
|
||||
aria-label="Naar boven"
|
||||
>
|
||||
<Brand compact />
|
||||
</button>
|
||||
<div className="topbar-search">
|
||||
<SearchOverlay
|
||||
apps={apps}
|
||||
favorites={favorites}
|
||||
categories={categories}
|
||||
server={server}
|
||||
externalAccess={externalAccess}
|
||||
onLaunch={onLaunch}
|
||||
/>
|
||||
</div>
|
||||
<nav aria-label="Dashboardnavigatie">
|
||||
<button
|
||||
type="button"
|
||||
className="active"
|
||||
onClick={() => scrollTo("dashboard-home")}
|
||||
>
|
||||
Dashboard
|
||||
</button>
|
||||
<button type="button" onClick={onSettings}>
|
||||
Instellingen
|
||||
</button>
|
||||
</nav>
|
||||
<div className="topbar-actions">
|
||||
<WeatherBadge enabled={weatherEnabled} location={weatherLocation} />
|
||||
<button
|
||||
className="icon-button sync-status-button"
|
||||
type="button"
|
||||
onClick={onSync}
|
||||
disabled={syncing}
|
||||
title="Statussen vernieuwen"
|
||||
aria-label="Statussen vernieuwen"
|
||||
>
|
||||
<RefreshCw size={19} className={syncing ? "spin" : ""} />
|
||||
<span className="sync-online-dot" />
|
||||
</button>
|
||||
<button
|
||||
className="icon-button"
|
||||
type="button"
|
||||
onClick={onSettings}
|
||||
title="Instellingen"
|
||||
aria-label="Instellingen openen"
|
||||
>
|
||||
<Settings size={21} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import {
|
||||
Activity,
|
||||
CheckCircle2,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
WifiOff,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import type {
|
||||
DashboardPayload,
|
||||
SystemDiagnostics,
|
||||
} from "../../shared/contracts";
|
||||
import { api } from "../api";
|
||||
|
||||
interface OperationsSettingsProps {
|
||||
data: DashboardPayload;
|
||||
}
|
||||
|
||||
export function OperationsSettings({ data }: OperationsSettingsProps) {
|
||||
const [diagnostics, setDiagnostics] = useState<SystemDiagnostics | null>(
|
||||
null,
|
||||
);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const run = async () => {
|
||||
setChecking(true);
|
||||
try {
|
||||
setDiagnostics(await api.diagnostics());
|
||||
setError(null);
|
||||
} catch (reason) {
|
||||
setError(
|
||||
reason instanceof Error ? reason.message : "Diagnostiek mislukt",
|
||||
);
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<header className="settings-heading">
|
||||
<p className="eyebrow">STATUS EN TOEGANG</p>
|
||||
<h2>Systeemstatus</h2>
|
||||
<p>
|
||||
Voer een statuscontrole zonder secrets uit en controleer de
|
||||
deploymentgrens voordat DockDeck buiten je vertrouwde LAN bereikbaar
|
||||
wordt.
|
||||
</p>
|
||||
</header>
|
||||
<div className="operations-overview">
|
||||
<article>
|
||||
<Activity size={19} />
|
||||
<span>
|
||||
<strong>
|
||||
{data.apps.filter((app) => app.widgetEnabled).length}
|
||||
</strong>
|
||||
<small>actieve app-panelen</small>
|
||||
</span>
|
||||
</article>
|
||||
<article>
|
||||
<CheckCircle2 size={19} />
|
||||
<span>
|
||||
<strong>
|
||||
{
|
||||
data.integrations.filter((item) => item.state === "connected")
|
||||
.length
|
||||
}
|
||||
</strong>
|
||||
<small>verbonden integraties</small>
|
||||
</span>
|
||||
</article>
|
||||
<article>
|
||||
<WifiOff size={19} />
|
||||
<span>
|
||||
<strong>
|
||||
{
|
||||
data.integrations.filter((item) => item.state === "degraded")
|
||||
.length
|
||||
}
|
||||
</strong>
|
||||
<small>beperkte integraties</small>
|
||||
</span>
|
||||
</article>
|
||||
</div>
|
||||
<button
|
||||
className="primary-button diagnostics-run"
|
||||
onClick={() => void run()}
|
||||
disabled={checking}
|
||||
>
|
||||
<RefreshCw size={15} className={checking ? "spin" : ""} />
|
||||
{checking ? "Controleren" : "Diagnostiek uitvoeren"}
|
||||
</button>
|
||||
{error && <p className="diagnostics-error">{error}</p>}
|
||||
{diagnostics && (
|
||||
<section
|
||||
className={`diagnostics-result ${diagnostics.status}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
<header>
|
||||
<ShieldCheck size={20} />
|
||||
<span>
|
||||
<strong>
|
||||
{diagnostics.status === "healthy" ? "gezond" : "beperkt"}
|
||||
</strong>
|
||||
<small>
|
||||
{new Date(diagnostics.generatedAt).toLocaleString()}
|
||||
</small>
|
||||
</span>
|
||||
</header>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Database</dt>
|
||||
<dd>{diagnostics.database}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Unraid-runtime</dt>
|
||||
<dd>{diagnostics.unraidNative}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Verouderde panelen</dt>
|
||||
<dd>{diagnostics.staleWidgets}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Onbeschikbare panelen</dt>
|
||||
<dd>{diagnostics.unavailableWidgets}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Offline-appshell</dt>
|
||||
<dd>{diagnostics.pwa}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Versie</dt>
|
||||
<dd>{diagnostics.version}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
)}
|
||||
<div className="security-callout operations-security">
|
||||
<ShieldCheck size={20} />
|
||||
<span>
|
||||
<strong>Externe toegang vereist authenticatie</strong>
|
||||
<small>
|
||||
Behoud de standaard lokale binding of plaats DockDeck achter HTTPS
|
||||
en een reverse proxy met authenticatie, zoals Authentik. DockDeck
|
||||
heeft zelf geen accounts en stelt publieke toegang nooit automatisch
|
||||
in.
|
||||
</small>
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
import {
|
||||
ArrowUpRight,
|
||||
CornerDownLeft,
|
||||
Search,
|
||||
Server,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type {
|
||||
Category,
|
||||
DockApp,
|
||||
Favorite,
|
||||
ServerWidgetMetric,
|
||||
} from "../../shared/contracts";
|
||||
import {
|
||||
effectiveAppUrl,
|
||||
favoriteIconUrl,
|
||||
googleSearchUrl,
|
||||
searchItems,
|
||||
} from "../../shared/domain";
|
||||
import { makeLaunchKey, type LaunchKey } from "../launch-history";
|
||||
import { ServiceIcon } from "./ServiceIcon";
|
||||
|
||||
interface Props {
|
||||
apps: DockApp[];
|
||||
favorites: Favorite[];
|
||||
categories: Category[];
|
||||
server: ServerWidgetMetric | null;
|
||||
externalAccess: boolean;
|
||||
enableShortcut?: boolean;
|
||||
ariaLabel?: string;
|
||||
placeholder?: string;
|
||||
onLaunch?: (key: LaunchKey) => void;
|
||||
}
|
||||
|
||||
export function SearchOverlay({
|
||||
apps,
|
||||
favorites,
|
||||
categories,
|
||||
server,
|
||||
externalAccess,
|
||||
enableShortcut = true,
|
||||
ariaLabel = "Zoek apps, favorieten of Google",
|
||||
placeholder = "Snel zoeken...",
|
||||
onLaunch,
|
||||
}: Props) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [active, setActive] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const results = useMemo(
|
||||
() =>
|
||||
searchItems(query, apps, favorites, externalAccess, categories, server),
|
||||
[query, apps, favorites, externalAccess, categories, server],
|
||||
);
|
||||
const appResults = results.filter((result) => result.kind === "app");
|
||||
const favoriteResults = results.filter(
|
||||
(result) => result.kind === "favorite",
|
||||
);
|
||||
const serverResults = results.filter((result) => result.kind === "server");
|
||||
const orderedResults = [...serverResults, ...appResults, ...favoriteResults];
|
||||
const selectableCount = query.trim() ? orderedResults.length + 1 : 0;
|
||||
const quickLinks = useMemo(
|
||||
() =>
|
||||
[
|
||||
...favorites.slice(0, 4).map((favorite) => ({
|
||||
id: `favorite-${favorite.id}`,
|
||||
name: favorite.name,
|
||||
detail: "Favoriet",
|
||||
url: favorite.url,
|
||||
icon: favoriteIconUrl(favorite),
|
||||
hint: favorite.url,
|
||||
launchKey: makeLaunchKey("favorite", favorite.id),
|
||||
})),
|
||||
...apps
|
||||
.filter((app) => app.visible && effectiveAppUrl(app, externalAccess))
|
||||
.slice(0, 4)
|
||||
.map((app) => ({
|
||||
id: `app-${app.id}`,
|
||||
name: app.displayName,
|
||||
detail: app.status === "online" ? "Online" : "Service",
|
||||
url: effectiveAppUrl(app, externalAccess)!,
|
||||
icon: app.icon,
|
||||
hint: `${app.technicalName} ${app.image ?? ""}`,
|
||||
launchKey: makeLaunchKey("app", app.id),
|
||||
})),
|
||||
].slice(0, 6),
|
||||
[apps, externalAccess, favorites],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (
|
||||
enableShortcut &&
|
||||
(event.metaKey || event.ctrlKey) &&
|
||||
event.key.toLowerCase() === "k"
|
||||
) {
|
||||
event.preventDefault();
|
||||
setActive(true);
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
setActive(false);
|
||||
setQuery("");
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [enableShortcut]);
|
||||
|
||||
useEffect(() => {
|
||||
if (active) inputRef.current?.focus();
|
||||
}, [active]);
|
||||
|
||||
const openUrl = (url: string, launchKey?: LaunchKey) => {
|
||||
if (query.trim()) window.open(url, "_blank", "noopener,noreferrer");
|
||||
if (launchKey) onLaunch?.(launchKey);
|
||||
setActive(false);
|
||||
setQuery("");
|
||||
};
|
||||
|
||||
const launchKeyForResult = (result: (typeof orderedResults)[number]) => {
|
||||
if (result.kind === "favorite") {
|
||||
return makeLaunchKey("favorite", result.id);
|
||||
}
|
||||
if (result.kind !== "app" || result.hidden) return undefined;
|
||||
|
||||
const app = apps.find((candidate) => candidate.id === result.id);
|
||||
if (!app?.visible || effectiveAppUrl(app, externalAccess) !== result.url) {
|
||||
return undefined;
|
||||
}
|
||||
return makeLaunchKey("app", app.id);
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
const result = orderedResults[selectedIndex];
|
||||
if (result) {
|
||||
openUrl(result.url, launchKeyForResult(result));
|
||||
return;
|
||||
}
|
||||
openUrl(googleSearchUrl(query));
|
||||
};
|
||||
|
||||
const openGoogle = () => openUrl(googleSearchUrl(query));
|
||||
|
||||
const searchShell = (
|
||||
<div
|
||||
className={`search-shell ${active ? "expanded" : ""}`}
|
||||
role={active ? "dialog" : undefined}
|
||||
aria-modal={active ? "true" : undefined}
|
||||
aria-label={active ? "Quick Launch" : undefined}
|
||||
>
|
||||
{active && (
|
||||
<button
|
||||
type="button"
|
||||
className="search-backdrop"
|
||||
aria-label="Quick Launch sluiten"
|
||||
onClick={() => {
|
||||
setActive(false);
|
||||
setQuery("");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<form
|
||||
className="search-bar"
|
||||
role="search"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
<Search size={21} aria-hidden="true" />
|
||||
<input
|
||||
name="quickLaunch"
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
setSelectedIndex(0);
|
||||
}}
|
||||
onFocus={() => setActive(true)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "ArrowDown" && selectableCount > 0) {
|
||||
event.preventDefault();
|
||||
setSelectedIndex((index) => (index + 1) % selectableCount);
|
||||
}
|
||||
if (event.key === "ArrowUp" && selectableCount > 0) {
|
||||
event.preventDefault();
|
||||
setSelectedIndex(
|
||||
(index) => (index - 1 + selectableCount) % selectableCount,
|
||||
);
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
aria-label={ariaLabel}
|
||||
aria-controls={active ? "quick-launch-results" : undefined}
|
||||
aria-activedescendant={
|
||||
active && query.trim() ? `quick-result-${selectedIndex}` : undefined
|
||||
}
|
||||
/>
|
||||
<kbd>{active ? "ESC" : "Ctrl K"}</kbd>
|
||||
</form>
|
||||
{active && (
|
||||
<div
|
||||
className="search-results"
|
||||
id="quick-launch-results"
|
||||
role={query.trim() ? "listbox" : undefined}
|
||||
>
|
||||
{!query.trim() && (
|
||||
<div className="quick-launch-home">
|
||||
<div className="search-hint">
|
||||
<Sparkles size={18} />
|
||||
<span>
|
||||
<strong>Quick Launch</strong>
|
||||
<small>
|
||||
Open een snelkoppeling of typ om alles te doorzoeken.
|
||||
</small>
|
||||
</span>
|
||||
</div>
|
||||
{quickLinks.length > 0 && (
|
||||
<section
|
||||
className="quick-links-section"
|
||||
aria-labelledby="quick-links-heading"
|
||||
>
|
||||
<h2 id="quick-links-heading">Snelkoppelingen</h2>
|
||||
<div className="quick-links-grid">
|
||||
{quickLinks.map((link, index) => (
|
||||
<a
|
||||
key={link.id}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => {
|
||||
onLaunch?.(link.launchKey);
|
||||
setActive(false);
|
||||
}}
|
||||
>
|
||||
<span className="result-icon">
|
||||
<ServiceIcon
|
||||
name={link.name}
|
||||
icon={link.icon}
|
||||
hint={link.hint}
|
||||
index={index}
|
||||
/>
|
||||
</span>
|
||||
<span>
|
||||
<strong>{link.name}</strong>
|
||||
<small>{link.detail}</small>
|
||||
</span>
|
||||
<ArrowUpRight size={15} aria-hidden="true" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{[
|
||||
{ label: "Apps", items: [...serverResults, ...appResults] },
|
||||
{ label: "Favorieten", items: favoriteResults },
|
||||
].map((group) =>
|
||||
group.items.length > 0 ? (
|
||||
<section className="result-group" key={group.label}>
|
||||
<h2>{group.label}</h2>
|
||||
{group.items.map((result) => {
|
||||
const index = orderedResults.indexOf(result);
|
||||
const app =
|
||||
result.kind === "app"
|
||||
? apps.find((candidate) => candidate.id === result.id)
|
||||
: undefined;
|
||||
const favorite =
|
||||
result.kind === "favorite"
|
||||
? favorites.find(
|
||||
(candidate) => candidate.id === result.id,
|
||||
)
|
||||
: undefined;
|
||||
return (
|
||||
<a
|
||||
id={`quick-result-${index}`}
|
||||
key={`${result.kind}-${result.id}`}
|
||||
href={result.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
role="option"
|
||||
aria-selected={selectedIndex === index}
|
||||
className={selectedIndex === index ? "selected" : ""}
|
||||
onClick={() => {
|
||||
const launchKey = launchKeyForResult(result);
|
||||
if (launchKey) onLaunch?.(launchKey);
|
||||
setActive(false);
|
||||
setQuery("");
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
>
|
||||
<span className="result-icon">
|
||||
{result.kind === "server" ? (
|
||||
<Server size={17} />
|
||||
) : result.kind === "favorite" ? (
|
||||
<ServiceIcon
|
||||
name={result.name}
|
||||
icon={favorite ? favoriteIconUrl(favorite) : null}
|
||||
hint={favorite?.url}
|
||||
index={index}
|
||||
/>
|
||||
) : (
|
||||
<ServiceIcon
|
||||
name={result.name}
|
||||
icon={app?.icon ?? null}
|
||||
index={index}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
<strong>{result.name}</strong>
|
||||
<small>
|
||||
{result.kind === "app"
|
||||
? `${result.hidden ? "Verborgen app" : app?.status === "online" ? "Online" : app?.status === "offline" ? "Offline" : "Status onbekend"} · ${result.detail}`
|
||||
: result.kind === "server"
|
||||
? `Unraid-server · ${result.detail}`
|
||||
: result.detail}
|
||||
</small>
|
||||
</span>
|
||||
<ArrowUpRight size={16} />
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
) : null,
|
||||
)}
|
||||
{query.trim() && (
|
||||
<section className="result-group google-group">
|
||||
<h2>Google</h2>
|
||||
<button
|
||||
id={`quick-result-${orderedResults.length}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selectedIndex === orderedResults.length}
|
||||
className={`google-result ${selectedIndex === orderedResults.length ? "selected" : ""}`}
|
||||
onMouseEnter={() => setSelectedIndex(orderedResults.length)}
|
||||
onClick={openGoogle}
|
||||
>
|
||||
<span className="result-icon google">
|
||||
<ServiceIcon name="Google" icon={null} index={0} />
|
||||
</span>
|
||||
<span>
|
||||
<strong>Zoeken op Google naar “{query}”</strong>
|
||||
<small>Opent in een nieuw tabblad</small>
|
||||
</span>
|
||||
<CornerDownLeft size={16} />
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
<footer className="search-shortcuts">
|
||||
<span>
|
||||
<kbd>ENTER</kbd> Openen
|
||||
</span>
|
||||
<span>
|
||||
<kbd>ESC</kbd> Sluiten
|
||||
</span>
|
||||
<span>↑ ↓ Navigeren</span>
|
||||
</footer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return active ? createPortal(searchShell, document.body) : searchShell;
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import {
|
||||
ArrowUpRight,
|
||||
BatteryCharging,
|
||||
Boxes,
|
||||
Clock3,
|
||||
Cpu,
|
||||
HardDrive,
|
||||
Layers3,
|
||||
MemoryStick,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
Thermometer,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
ServerWidgetMetric,
|
||||
WidgetChartType,
|
||||
WidgetSize,
|
||||
} from "../../shared/contracts";
|
||||
import { WidgetSparkline } from "./WidgetSparkline";
|
||||
|
||||
function formatMemory(value: number) {
|
||||
return value > 0 ? `${Math.round(value / 1024 ** 3)} GB` : "—";
|
||||
}
|
||||
|
||||
interface ServerWidgetProps {
|
||||
metric: ServerWidgetMetric | null;
|
||||
size: WidgetSize;
|
||||
showStats: boolean;
|
||||
showDetails: boolean;
|
||||
showProgress: boolean;
|
||||
showNative: boolean;
|
||||
showChart: boolean;
|
||||
chartMetric: string;
|
||||
chartType: WidgetChartType;
|
||||
chartSamples: number;
|
||||
showLegend: boolean;
|
||||
hiddenMetrics: string[];
|
||||
metricOrder: string[];
|
||||
statLimit: number;
|
||||
}
|
||||
|
||||
const statLabels = [
|
||||
"CPU cores",
|
||||
"Memory capacity",
|
||||
"Stopped containers",
|
||||
"Array state",
|
||||
"Storage used",
|
||||
"Hottest temperature",
|
||||
"UPS battery",
|
||||
];
|
||||
|
||||
export function ServerWidget({
|
||||
metric,
|
||||
size,
|
||||
showStats,
|
||||
showDetails,
|
||||
showProgress,
|
||||
showNative,
|
||||
showChart,
|
||||
chartMetric,
|
||||
chartType,
|
||||
chartSamples,
|
||||
showLegend,
|
||||
hiddenMetrics,
|
||||
metricOrder,
|
||||
statLimit,
|
||||
}: ServerWidgetProps) {
|
||||
const runningPercent = metric?.containers
|
||||
? Math.round((metric.containersRunning / metric.containers) * 100)
|
||||
: 0;
|
||||
const visibleLabels = statLabels
|
||||
.filter((label) => !hiddenMetrics.includes(label))
|
||||
.sort((left, right) => {
|
||||
const leftIndex = metricOrder.indexOf(left);
|
||||
const rightIndex = metricOrder.indexOf(right);
|
||||
return (
|
||||
(leftIndex < 0 ? Number.MAX_SAFE_INTEGER : leftIndex) -
|
||||
(rightIndex < 0 ? Number.MAX_SAFE_INTEGER : rightIndex)
|
||||
);
|
||||
})
|
||||
.slice(0, statLimit);
|
||||
const isShown = (label: string) => visibleLabels.includes(label);
|
||||
const chartHistory = metric?.history?.[chartMetric] ?? [];
|
||||
return (
|
||||
<article
|
||||
className={`service-widget server-widget widget-size-${size}`}
|
||||
data-widget-size={size}
|
||||
>
|
||||
<header>
|
||||
<span className="server-widget-icon">
|
||||
<Server size={22} />
|
||||
</span>
|
||||
<span className="widget-title">
|
||||
<small>UNRAID SERVER</small>
|
||||
{metric ? (
|
||||
<a
|
||||
className="server-title-link"
|
||||
href={metric.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<strong>{metric.hostName}</strong>
|
||||
<ArrowUpRight size={13} />
|
||||
</a>
|
||||
) : (
|
||||
<strong>Wachten op Unraid</strong>
|
||||
)}
|
||||
</span>
|
||||
<span className={`server-health ${metric ? "online" : "unknown"}`}>
|
||||
{metric ? metric.freshness : "Offline"}
|
||||
</span>
|
||||
</header>
|
||||
{showDetails && (
|
||||
<div className="server-summary">
|
||||
<strong>{metric?.containersRunning ?? 0}</strong>
|
||||
<span>
|
||||
containers actief
|
||||
<small>
|
||||
{metric?.operatingSystem ??
|
||||
"Docker-hostinformatie niet beschikbaar"}
|
||||
</small>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{showStats && (
|
||||
<div className="provider-stats server-stats">
|
||||
{isShown("CPU cores") && (
|
||||
<span>
|
||||
<Cpu size={14} />
|
||||
<small>CPU-kernen</small>
|
||||
<strong>{metric?.cpuCount ?? "—"}</strong>
|
||||
</span>
|
||||
)}
|
||||
{isShown("Memory capacity") && (
|
||||
<span>
|
||||
<MemoryStick size={14} />
|
||||
<small>Geheugencapaciteit</small>
|
||||
<strong>{formatMemory(metric?.totalMemoryBytes ?? 0)}</strong>
|
||||
</span>
|
||||
)}
|
||||
{isShown("Stopped containers") && (
|
||||
<span>
|
||||
<Boxes size={14} />
|
||||
<small>Gestopte containers</small>
|
||||
<strong>{metric?.containersStopped ?? "—"}</strong>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{showNative && metric?.native && (
|
||||
<div className="unraid-native-grid">
|
||||
{isShown("Array state") && (
|
||||
<span>
|
||||
<ShieldCheck size={14} />
|
||||
<small>Arraystatus</small>
|
||||
<strong>{metric.native.arrayState}</strong>
|
||||
<em>
|
||||
{metric.native.parityErrors
|
||||
? `${metric.native.parityErrors} pariteitsfouten`
|
||||
: metric.native.parityStatus}
|
||||
</em>
|
||||
</span>
|
||||
)}
|
||||
{isShown("Storage used") && (
|
||||
<span>
|
||||
<HardDrive size={14} />
|
||||
<small>Opslag gebruikt</small>
|
||||
<strong>
|
||||
{metric.native.storage.length
|
||||
? formatMemory(
|
||||
metric.native.storage.reduce(
|
||||
(total, group) => total + group.usedBytes,
|
||||
0,
|
||||
),
|
||||
)
|
||||
: "—"}
|
||||
</strong>
|
||||
<em>{metric.native.storage.length} array-/poolgroepen</em>
|
||||
</span>
|
||||
)}
|
||||
{isShown("Hottest temperature") && (
|
||||
<span>
|
||||
<Thermometer size={14} />
|
||||
<small>Hoogste temperatuur</small>
|
||||
<strong>
|
||||
{metric.native.hottestTemperatureC === null
|
||||
? "—"
|
||||
: `${Math.round(metric.native.hottestTemperatureC)}°C`}
|
||||
</strong>
|
||||
<em>Schijf- en hostsensoren</em>
|
||||
</span>
|
||||
)}
|
||||
{isShown("UPS battery") && (
|
||||
<span>
|
||||
<BatteryCharging size={14} />
|
||||
<small>UPS-batterij</small>
|
||||
<strong>{metric.native.upsStatus ?? "Niet beschikbaar"}</strong>
|
||||
<em>
|
||||
{metric.native.upsBatteryPercent === null
|
||||
? "Optionele statusbron"
|
||||
: `${Math.round(metric.native.upsBatteryPercent)}% batterij`}
|
||||
</em>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{showChart && chartHistory.length > 1 && (
|
||||
<WidgetSparkline
|
||||
history={chartHistory}
|
||||
chartType={chartType}
|
||||
sampleLimit={chartSamples}
|
||||
label={chartMetric}
|
||||
showLegend={showLegend}
|
||||
/>
|
||||
)}
|
||||
{showDetails && (
|
||||
<div className="server-footer">
|
||||
<span>
|
||||
<Layers3 size={12} /> {metric?.images ?? 0} images
|
||||
</span>
|
||||
<span>
|
||||
<HardDrive size={12} /> Docker {metric?.dockerVersion ?? "—"}
|
||||
</span>
|
||||
<span>
|
||||
<Clock3 size={12} />
|
||||
{metric
|
||||
? `Bijgewerkt ${new Date(metric.updatedAt).toLocaleTimeString(
|
||||
[],
|
||||
{
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
},
|
||||
)}`
|
||||
: "Wachten op vernieuwing"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{showProgress && (
|
||||
<div
|
||||
className="memory-track"
|
||||
role="meter"
|
||||
aria-label="Actieve containers"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={runningPercent}
|
||||
>
|
||||
<span style={{ width: `${runningPercent}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
BarChart3,
|
||||
ServerCog,
|
||||
SlidersHorizontal,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
Preferences,
|
||||
PreferencesPatch,
|
||||
WidgetChartType,
|
||||
} from "../../shared/contracts";
|
||||
|
||||
interface Props {
|
||||
preferences: Preferences;
|
||||
onChange: (patch: PreferencesPatch, message: string) => void;
|
||||
}
|
||||
|
||||
const statMetrics = [
|
||||
{
|
||||
label: "CPU cores",
|
||||
display: "CPU-kernen",
|
||||
description: "Logische CPU-capaciteit die de Docker-host meldt.",
|
||||
},
|
||||
{
|
||||
label: "Memory capacity",
|
||||
display: "Geheugencapaciteit",
|
||||
description: "Totaal geheugen uit de alleen-lezen inventarisservice.",
|
||||
},
|
||||
{
|
||||
label: "Stopped containers",
|
||||
display: "Gestopte containers",
|
||||
description: "Aanwezige containers die momenteel niet actief zijn.",
|
||||
},
|
||||
{
|
||||
label: "Array state",
|
||||
display: "Arraystatus",
|
||||
description: "Overzicht van de Unraid-array- en pariteitsstatus.",
|
||||
},
|
||||
{
|
||||
label: "Storage used",
|
||||
display: "Opslag gebruikt",
|
||||
description: "Gebruikte capaciteit van de gemelde array en pools.",
|
||||
},
|
||||
{
|
||||
label: "Hottest temperature",
|
||||
display: "Hoogste temperatuur",
|
||||
description: "Hoogste beschikbare temperatuur van host- of opslagsensoren.",
|
||||
},
|
||||
{
|
||||
label: "UPS battery",
|
||||
display: "UPS-batterij",
|
||||
description:
|
||||
"UPS-status en resterende batterij indien ingesteld in Unraid.",
|
||||
},
|
||||
];
|
||||
|
||||
const chartMetrics: Array<[string, string]> = [
|
||||
["Running containers", "Actieve containers"],
|
||||
["Stopped containers", "Gestopte containers"],
|
||||
["Images", "Images"],
|
||||
["Memory capacity", "Geheugencapaciteit"],
|
||||
["Storage used", "Opslag gebruikt"],
|
||||
["Hottest temperature", "Hoogste temperatuur"],
|
||||
["Parity errors", "Pariteitsfouten"],
|
||||
["UPS battery", "UPS-batterij"],
|
||||
];
|
||||
|
||||
export function ServerWidgetAdvancedConfigurator({
|
||||
preferences,
|
||||
onChange,
|
||||
}: Props) {
|
||||
const labels = statMetrics.map((metric) => metric.label);
|
||||
const ordered = [
|
||||
...preferences.serverWidgetMetricOrder.filter((label) =>
|
||||
labels.includes(label),
|
||||
),
|
||||
...labels.filter(
|
||||
(label) => !preferences.serverWidgetMetricOrder.includes(label),
|
||||
),
|
||||
].filter((label) => !preferences.serverWidgetHiddenMetrics.includes(label));
|
||||
const move = (index: number, direction: -1 | 1) => {
|
||||
const target = index + direction;
|
||||
if (!ordered[target]) return;
|
||||
const next = [...ordered];
|
||||
[next[index], next[target]] = [next[target]!, next[index]!];
|
||||
onChange(
|
||||
{ serverWidgetMetricOrder: next },
|
||||
"Volgorde van Unraid-metingen bijgewerkt",
|
||||
);
|
||||
};
|
||||
return (
|
||||
<details className="widget-advanced-config server-advanced-config">
|
||||
<summary>
|
||||
<ServerCog size={14} /> Volledige Unraid-paneelconfiguratie
|
||||
</summary>
|
||||
<section className="widget-config-section" aria-label="Unraid-grafiek">
|
||||
<header>
|
||||
<BarChart3 size={14} />
|
||||
<span>
|
||||
<strong>Servertrendgrafiek</strong>
|
||||
<small>
|
||||
Toon geschiedenis van Docker-inventaris, opslag, temperatuur,
|
||||
pariteit of UPS.
|
||||
</small>
|
||||
</span>
|
||||
</header>
|
||||
<div className="widget-advanced-grid">
|
||||
<label>
|
||||
<span>Reeks</span>
|
||||
<select
|
||||
name="serverWidgetChartMetric"
|
||||
value={preferences.serverWidgetChartMetric}
|
||||
onChange={(event) =>
|
||||
onChange(
|
||||
{ serverWidgetChartMetric: event.target.value },
|
||||
"Unraid-grafiekreeks bijgewerkt",
|
||||
)
|
||||
}
|
||||
>
|
||||
{chartMetrics.map(([metric, display]) => (
|
||||
<option key={metric} value={metric}>
|
||||
{display}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Grafiekstijl</span>
|
||||
<select
|
||||
name="serverWidgetChartType"
|
||||
value={preferences.serverWidgetChartType}
|
||||
onChange={(event) =>
|
||||
onChange(
|
||||
{
|
||||
serverWidgetChartType: event.target
|
||||
.value as WidgetChartType,
|
||||
},
|
||||
"Unraid-grafiekstijl bijgewerkt",
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="line">Lijn</option>
|
||||
<option value="area">Vlak</option>
|
||||
<option value="bar">Balken</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Geschiedenisvenster</span>
|
||||
<select
|
||||
name="serverWidgetChartSamples"
|
||||
value={preferences.serverWidgetChartSamples}
|
||||
onChange={(event) =>
|
||||
onChange(
|
||||
{ serverWidgetChartSamples: Number(event.target.value) },
|
||||
"Unraid-grafiekvenster bijgewerkt",
|
||||
)
|
||||
}
|
||||
>
|
||||
{[10, 20, 30, 45, 60].map((samples) => (
|
||||
<option key={samples} value={samples}>
|
||||
Laatste {samples} metingen
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Maximum aantal statistieken</span>
|
||||
<select
|
||||
name="serverWidgetStatLimit"
|
||||
value={preferences.serverWidgetStatLimit}
|
||||
onChange={(event) =>
|
||||
onChange(
|
||||
{ serverWidgetStatLimit: Number(event.target.value) },
|
||||
"Limiet voor Unraid-statistieken bijgewerkt",
|
||||
)
|
||||
}
|
||||
>
|
||||
{[3, 4, 5, 6, 7, 8].map((limit) => (
|
||||
<option key={limit} value={limit}>
|
||||
{limit} statistieken
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
preferences.serverWidgetShowChart
|
||||
? "sparkline-toggle active"
|
||||
: "sparkline-toggle"
|
||||
}
|
||||
aria-pressed={preferences.serverWidgetShowChart}
|
||||
onClick={() =>
|
||||
onChange(
|
||||
{
|
||||
serverWidgetShowChart: !preferences.serverWidgetShowChart,
|
||||
},
|
||||
"Zichtbaarheid van Unraid-grafiek bijgewerkt",
|
||||
)
|
||||
}
|
||||
>
|
||||
<BarChart3 size={15} />
|
||||
<span>
|
||||
<strong>Grafiek tonen</strong>
|
||||
<small>Geschiedenis blijft alleen in het geheugen</small>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
preferences.serverWidgetShowLegend
|
||||
? "sparkline-toggle active"
|
||||
: "sparkline-toggle"
|
||||
}
|
||||
aria-pressed={preferences.serverWidgetShowLegend}
|
||||
onClick={() =>
|
||||
onChange(
|
||||
{
|
||||
serverWidgetShowLegend: !preferences.serverWidgetShowLegend,
|
||||
},
|
||||
"Legenda van Unraid-grafiek bijgewerkt",
|
||||
)
|
||||
}
|
||||
>
|
||||
<SlidersHorizontal size={15} />
|
||||
<span>
|
||||
<strong>Legenda tonen</strong>
|
||||
<small>Reeks, laatste waarde en metingen</small>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<div className="metric-visibility-list" aria-label="Unraid-statistieken">
|
||||
<strong>Serverstatistieken</strong>
|
||||
<small>
|
||||
Kies welke eigen Unraid- en Docker-hostsignalen de kaart mag tonen.
|
||||
</small>
|
||||
<div>
|
||||
{statMetrics.map((metric) => {
|
||||
const visible = !preferences.serverWidgetHiddenMetrics.includes(
|
||||
metric.label,
|
||||
);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={metric.label}
|
||||
className={visible ? "active" : ""}
|
||||
aria-pressed={visible}
|
||||
onClick={() =>
|
||||
onChange(
|
||||
{
|
||||
serverWidgetHiddenMetrics: visible
|
||||
? [
|
||||
...preferences.serverWidgetHiddenMetrics,
|
||||
metric.label,
|
||||
]
|
||||
: preferences.serverWidgetHiddenMetrics.filter(
|
||||
(label) => label !== metric.label,
|
||||
),
|
||||
},
|
||||
`${metric.display} ${visible ? "verborgen" : "ingeschakeld"}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<span>{metric.display}</span>
|
||||
<small>{metric.description}</small>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{ordered.length > 1 && (
|
||||
<div
|
||||
className="metric-order-list"
|
||||
aria-label="Volgorde van Unraid-metingen"
|
||||
>
|
||||
{ordered.map((metric, index) => (
|
||||
<span key={metric}>
|
||||
<strong>
|
||||
{statMetrics.find((item) => item.label === metric)?.display ??
|
||||
metric}
|
||||
</strong>
|
||||
<button
|
||||
type="button"
|
||||
disabled={index === 0}
|
||||
onClick={() => move(index, -1)}
|
||||
aria-label={`${metric} eerder plaatsen`}
|
||||
>
|
||||
<ArrowUp size={12} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={index === ordered.length - 1}
|
||||
onClick={() => move(index, 1)}
|
||||
aria-label={`${metric} later plaatsen`}
|
||||
>
|
||||
<ArrowDown size={12} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import {
|
||||
SiAdguard,
|
||||
SiAuthentik,
|
||||
SiDeluge,
|
||||
SiGitea,
|
||||
SiGithub,
|
||||
SiGoogle,
|
||||
SiGrafana,
|
||||
SiHomeassistant,
|
||||
SiImmich,
|
||||
SiJellyfin,
|
||||
SiNextcloud,
|
||||
SiNginxproxymanager,
|
||||
SiOllama,
|
||||
SiPaperlessngx,
|
||||
SiPlex,
|
||||
SiPortainer,
|
||||
SiQbittorrent,
|
||||
SiRadarr,
|
||||
SiSonarr,
|
||||
SiVaultwarden,
|
||||
SiYoutube,
|
||||
} from "@icons-pack/react-simple-icons";
|
||||
import {
|
||||
Bot,
|
||||
Box,
|
||||
Braces,
|
||||
Cloud,
|
||||
Database,
|
||||
Download,
|
||||
FileStack,
|
||||
Film,
|
||||
Gauge,
|
||||
HardDrive,
|
||||
Image,
|
||||
LockKeyhole,
|
||||
Mail,
|
||||
Network,
|
||||
Radio,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
Workflow,
|
||||
} from "lucide-react";
|
||||
import type { ComponentType, SVGProps } from "react";
|
||||
import { useState } from "react";
|
||||
import { inferServiceKind } from "../../shared/domain";
|
||||
|
||||
const hues = ["coral", "violet", "cyan", "lime", "amber"] as const;
|
||||
|
||||
interface ServiceIconProps {
|
||||
name: string;
|
||||
icon: string | null;
|
||||
hint?: string | null;
|
||||
index?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
type IconComponent = ComponentType<
|
||||
SVGProps<SVGSVGElement> & { color?: string; size?: number | string }
|
||||
>;
|
||||
|
||||
const brandIcons: Array<[RegExp, IconComponent]> = [
|
||||
[/home.?assistant|ha-core/i, SiHomeassistant],
|
||||
[/paperless/i, SiPaperlessngx],
|
||||
[/nginx.?proxy.?manager|npm/i, SiNginxproxymanager],
|
||||
[/q.?bittorrent/i, SiQbittorrent],
|
||||
[/vaultwarden/i, SiVaultwarden],
|
||||
[/adguard/i, SiAdguard],
|
||||
[/authentik/i, SiAuthentik],
|
||||
[/nextcloud/i, SiNextcloud],
|
||||
[/portainer/i, SiPortainer],
|
||||
[/jellyfin/i, SiJellyfin],
|
||||
[/youtube/i, SiYoutube],
|
||||
[/github/i, SiGithub],
|
||||
[/google/i, SiGoogle],
|
||||
[/grafana/i, SiGrafana],
|
||||
[/immich/i, SiImmich],
|
||||
[/deluge/i, SiDeluge],
|
||||
[/sonarr/i, SiSonarr],
|
||||
[/radarr/i, SiRadarr],
|
||||
[/ollama/i, SiOllama],
|
||||
[/gitea/i, SiGitea],
|
||||
[/plex/i, SiPlex],
|
||||
];
|
||||
|
||||
const semanticIcons: Record<
|
||||
ReturnType<typeof inferServiceKind>,
|
||||
IconComponent
|
||||
> = {
|
||||
dns: ShieldCheck,
|
||||
media: Film,
|
||||
photos: Image,
|
||||
automation: Workflow,
|
||||
downloads: Download,
|
||||
monitoring: Gauge,
|
||||
code: Braces,
|
||||
ai: Bot,
|
||||
documents: FileStack,
|
||||
security: LockKeyhole,
|
||||
service: Box,
|
||||
};
|
||||
|
||||
const infrastructureIcons: Array<[RegExp, IconComponent]> = [
|
||||
[/postgres|mariadb|mysql|mongo|redis|database|db\b/i, Database],
|
||||
[/cloud|cloudflare/i, Cloud],
|
||||
[/mail|smtp/i, Mail],
|
||||
[/network|proxy|gateway/i, Network],
|
||||
[/radio|mqtt/i, Radio],
|
||||
[/worker|workflow|queue/i, Workflow],
|
||||
[/storage|disk|nas/i, HardDrive],
|
||||
[/server|service/i, Server],
|
||||
];
|
||||
|
||||
const loopbackHosts = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1"]);
|
||||
|
||||
function resolveIconUrl(icon: string) {
|
||||
try {
|
||||
const url = new URL(icon, window.location.href);
|
||||
if (
|
||||
loopbackHosts.has(url.hostname) &&
|
||||
!loopbackHosts.has(window.location.hostname)
|
||||
) {
|
||||
url.hostname = window.location.hostname;
|
||||
}
|
||||
return url.toString();
|
||||
} catch {
|
||||
return icon;
|
||||
}
|
||||
}
|
||||
|
||||
function inferredIcon(name: string, hint: string | null | undefined) {
|
||||
const haystack = `${name} ${hint ?? ""}`;
|
||||
const BrandIcon = brandIcons.find(([pattern]) => pattern.test(haystack))?.[1];
|
||||
if (BrandIcon) return { Icon: BrandIcon, brand: true };
|
||||
const InfrastructureIcon = infrastructureIcons.find(([pattern]) =>
|
||||
pattern.test(haystack),
|
||||
)?.[1];
|
||||
return {
|
||||
Icon:
|
||||
InfrastructureIcon ?? semanticIcons[inferServiceKind(haystack)] ?? Box,
|
||||
brand: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function ServiceIcon({
|
||||
name,
|
||||
icon,
|
||||
hint,
|
||||
index = 0,
|
||||
className = "",
|
||||
}: ServiceIconProps) {
|
||||
const [failedIcon, setFailedIcon] = useState<string | null>(null);
|
||||
const resolvedIcon = icon ? resolveIconUrl(icon) : null;
|
||||
if (resolvedIcon && failedIcon !== resolvedIcon)
|
||||
return (
|
||||
<img
|
||||
className={`app-icon-image ${className}`}
|
||||
src={resolvedIcon}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onError={() => setFailedIcon(resolvedIcon)}
|
||||
/>
|
||||
);
|
||||
const { Icon, brand } = inferredIcon(name, hint);
|
||||
return (
|
||||
<span
|
||||
className={`app-brand-icon ${brand ? "actual-brand" : "semantic"} ${hues[index % hues.length]} ${className}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon color={brand ? "default" : "currentColor"} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
import {
|
||||
Activity,
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ArrowUpRight,
|
||||
Bot,
|
||||
BrainCircuit,
|
||||
Code2,
|
||||
Clock3,
|
||||
Cpu,
|
||||
Download,
|
||||
Film,
|
||||
Files,
|
||||
Gauge,
|
||||
HardDrive,
|
||||
Image,
|
||||
LockKeyhole,
|
||||
MemoryStick,
|
||||
Network,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
DockApp,
|
||||
IntegrationState,
|
||||
WidgetCapability,
|
||||
WidgetMetric,
|
||||
WidgetMetricDefinition,
|
||||
WidgetValueHistoryPoint,
|
||||
} from "../../shared/contracts";
|
||||
import {
|
||||
effectiveAppUrl,
|
||||
inferServiceKind,
|
||||
type ServiceKind,
|
||||
} from "../../shared/domain";
|
||||
import { ServiceIcon } from "./ServiceIcon";
|
||||
import { WidgetSparkline } from "./WidgetSparkline";
|
||||
|
||||
const kindDetails: Record<
|
||||
ServiceKind,
|
||||
{ label: string; icon: typeof Activity }
|
||||
> = {
|
||||
dns: { label: "DNS-bescherming", icon: ShieldCheck },
|
||||
media: { label: "Streamingactiviteit", icon: Film },
|
||||
photos: { label: "Bibliotheekverwerking", icon: Image },
|
||||
automation: { label: "Automatiseringsengine", icon: Bot },
|
||||
downloads: { label: "Downloadengine", icon: Download },
|
||||
monitoring: { label: "Live telemetrie", icon: Gauge },
|
||||
code: { label: "Repositoryservice", icon: Code2 },
|
||||
ai: { label: "Lokale intelligentie", icon: BrainCircuit },
|
||||
documents: { label: "Documentworkflow", icon: Files },
|
||||
security: { label: "Beveiligde service", icon: LockKeyhole },
|
||||
service: { label: "Servicestatus", icon: HardDrive },
|
||||
};
|
||||
|
||||
function formatBytes(value: number | null): string {
|
||||
if (value === null) return "—";
|
||||
if (value < 1024 ** 2) return `${Math.round(value / 1024)} KB`;
|
||||
if (value < 1024 ** 3) return `${Math.round(value / 1024 ** 2)} MB`;
|
||||
return `${(value / 1024 ** 3).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
function formatRate(value: number | null | undefined): string {
|
||||
if (value == null) return "—";
|
||||
if (value < 1024) return `${Math.round(value)} B/s`;
|
||||
if (value < 1024 ** 2) return `${Math.round(value / 1024)} KB/s`;
|
||||
return `${(value / 1024 ** 2).toFixed(1)} MB/s`;
|
||||
}
|
||||
|
||||
function formatUpdatedAt(value: string | null | undefined): string {
|
||||
if (!value) return "Wachten op vernieuwing";
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return "Vernieuwingstijd onbekend";
|
||||
return `Bijgewerkt ${parsed.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}`;
|
||||
}
|
||||
|
||||
interface TailoredStat {
|
||||
label: string;
|
||||
value: string;
|
||||
numericValue?: number;
|
||||
icon?: typeof Activity;
|
||||
tone?: "default" | "positive" | "warning";
|
||||
}
|
||||
|
||||
function numericDisplayValue(value: string): number | null {
|
||||
const match = value
|
||||
.trim()
|
||||
.replace(/,/g, "")
|
||||
.match(/^(-?\d+(?:\.\d+)?)\s*([kmgt])?/i);
|
||||
if (!match) return null;
|
||||
const parsed = Number(match[1]);
|
||||
if (!Number.isFinite(parsed)) return null;
|
||||
const suffix = match[2]?.toLowerCase();
|
||||
return (
|
||||
parsed *
|
||||
(suffix === "t"
|
||||
? 1e12
|
||||
: suffix === "g"
|
||||
? 1e9
|
||||
: suffix === "m"
|
||||
? 1e6
|
||||
: suffix === "k"
|
||||
? 1e3
|
||||
: 1)
|
||||
);
|
||||
}
|
||||
|
||||
function capabilityFallback(
|
||||
capability: WidgetCapability,
|
||||
app: DockApp,
|
||||
metric: WidgetMetric | undefined,
|
||||
) {
|
||||
const stats: TailoredStat[] = [
|
||||
{
|
||||
label: "Status",
|
||||
value: app.status,
|
||||
tone: app.status === "online" ? "positive" : "warning",
|
||||
icon: Activity,
|
||||
},
|
||||
{
|
||||
label: "CPU",
|
||||
value:
|
||||
metric?.cpuPercent == null ? "—" : `${metric.cpuPercent.toFixed(1)}%`,
|
||||
numericValue: metric?.cpuPercent ?? undefined,
|
||||
icon: Cpu,
|
||||
},
|
||||
{
|
||||
label: "Geheugen",
|
||||
value: formatBytes(metric?.memoryUsageBytes ?? null),
|
||||
numericValue: metric?.memoryUsageBytes ?? undefined,
|
||||
icon: MemoryStick,
|
||||
},
|
||||
{
|
||||
label: "Inkomend",
|
||||
value: formatRate(metric?.networkRxBytesPerSecond),
|
||||
numericValue: metric?.networkRxBytesPerSecond ?? undefined,
|
||||
icon: ArrowDown,
|
||||
},
|
||||
{
|
||||
label: "Uitgaand",
|
||||
value: formatRate(metric?.networkTxBytesPerSecond),
|
||||
numericValue: metric?.networkTxBytesPerSecond ?? undefined,
|
||||
icon: ArrowUp,
|
||||
},
|
||||
{
|
||||
label: "Processen",
|
||||
value: metric?.pids == null ? "—" : String(metric.pids),
|
||||
numericValue: metric?.pids ?? undefined,
|
||||
icon: Activity,
|
||||
},
|
||||
];
|
||||
return {
|
||||
summary:
|
||||
app.status === "online"
|
||||
? `${capability.label} is beschikbaar`
|
||||
: `${capability.label} vraagt aandacht`,
|
||||
detail:
|
||||
app.status === "online"
|
||||
? "Dockertelemetrie actief · alleen-lezen"
|
||||
: (metric?.statusText ?? "Container is offline"),
|
||||
stats,
|
||||
};
|
||||
}
|
||||
|
||||
function infrastructureHistory(
|
||||
metric: WidgetMetric | undefined,
|
||||
key: "cpu" | "memory" | "network",
|
||||
): WidgetValueHistoryPoint[] {
|
||||
if (!metric) return [];
|
||||
return metric.history.flatMap((point) => {
|
||||
const value =
|
||||
key === "memory"
|
||||
? point.memoryPercent
|
||||
: key === "network"
|
||||
? point.networkRxBytesPerSecond
|
||||
: point.cpuPercent;
|
||||
return value === null ? [] : [{ sampledAt: point.sampledAt, value }];
|
||||
});
|
||||
}
|
||||
|
||||
function tailoredFallback(
|
||||
kind: ServiceKind,
|
||||
app: DockApp,
|
||||
metric: WidgetMetric | undefined,
|
||||
providerState: IntegrationState | undefined,
|
||||
): { summary: string; detail: string; stats: TailoredStat[] } {
|
||||
const incoming = metric?.networkRxBytesPerSecond;
|
||||
const outgoing = metric?.networkTxBytesPerSecond;
|
||||
const memory = formatBytes(metric?.memoryUsageBytes ?? null);
|
||||
const cpu =
|
||||
metric?.cpuPercent == null ? "—" : `${metric.cpuPercent.toFixed(1)}%`;
|
||||
if (kind === "downloads") {
|
||||
return {
|
||||
summary:
|
||||
incoming != null && incoming > 64 * 1024
|
||||
? `Downloaden met ${formatRate(incoming)}`
|
||||
: "Downloadengine is inactief",
|
||||
detail: metric?.statusText ?? "Wachten op overdrachtstelemetrie",
|
||||
stats: [
|
||||
{
|
||||
label: "Downloadsnelheid",
|
||||
value: formatRate(incoming),
|
||||
icon: ArrowDown,
|
||||
tone: incoming ? "positive" : "default",
|
||||
},
|
||||
{ label: "Uploadsnelheid", value: formatRate(outgoing), icon: ArrowUp },
|
||||
{ label: "Werkgeheugen", value: memory, icon: MemoryStick },
|
||||
],
|
||||
};
|
||||
}
|
||||
if (kind === "dns") {
|
||||
return {
|
||||
summary: "DNS-container is gekoppeld",
|
||||
detail:
|
||||
providerState?.state === "degraded"
|
||||
? "Dockertelemetrie actief · optionele DNS-details niet bereikbaar"
|
||||
: (metric?.statusText ?? "Resolververkeer wordt gemonitord"),
|
||||
stats: [
|
||||
{
|
||||
label: "DNS-inkomend",
|
||||
value: formatRate(incoming),
|
||||
icon: ArrowDown,
|
||||
tone: "positive",
|
||||
},
|
||||
{ label: "DNS-uitgaand", value: formatRate(outgoing), icon: ArrowUp },
|
||||
{ label: "Resolvergeheugen", value: memory, icon: MemoryStick },
|
||||
],
|
||||
};
|
||||
}
|
||||
if (kind === "media")
|
||||
return {
|
||||
summary: `Belasting media-engine ${cpu}`,
|
||||
detail: metric?.statusText ?? "Wachten op streamingtelemetrie",
|
||||
stats: [
|
||||
{ label: "Transcodebelasting", value: cpu, icon: Cpu },
|
||||
{ label: "Streamverkeer", value: formatRate(outgoing), icon: Film },
|
||||
{ label: "Mediageheugen", value: memory, icon: MemoryStick },
|
||||
],
|
||||
};
|
||||
if (kind === "photos")
|
||||
return {
|
||||
summary: `Belasting bibliotheekworker ${cpu}`,
|
||||
detail: metric?.statusText ?? "Wachten op bibliotheektelemetrie",
|
||||
stats: [
|
||||
{ label: "Indexbelasting", value: cpu, icon: Cpu },
|
||||
{ label: "Uploads", value: formatRate(incoming), icon: Image },
|
||||
{ label: "Bibliotheekgeheugen", value: memory, icon: MemoryStick },
|
||||
],
|
||||
};
|
||||
if (kind === "automation")
|
||||
return {
|
||||
summary:
|
||||
app.status === "online"
|
||||
? "Automatiseringsengine reageert"
|
||||
: "Automatiseringen niet beschikbaar",
|
||||
detail: metric?.statusText ?? "Wachten op automatiseringstelemetrie",
|
||||
stats: [
|
||||
{ label: "Eventbelasting", value: cpu, icon: Cpu },
|
||||
{ label: "Eventverkeer", value: formatRate(incoming), icon: Network },
|
||||
{ label: "Statusgeheugen", value: memory, icon: MemoryStick },
|
||||
],
|
||||
};
|
||||
if (kind === "ai")
|
||||
return {
|
||||
summary:
|
||||
metric?.cpuPercent != null && metric.cpuPercent > 5
|
||||
? `Inferentiewerklast actief · ${cpu} CPU`
|
||||
: "AI-runtime is gereed",
|
||||
detail: metric?.statusText ?? "Wachten op modeltelemetrie",
|
||||
stats: [
|
||||
{ label: "Inferentiebelasting", value: cpu, icon: Cpu },
|
||||
{ label: "Modelverkeer", value: formatRate(outgoing), icon: Network },
|
||||
{ label: "Modelgeheugen", value: memory, icon: MemoryStick },
|
||||
],
|
||||
};
|
||||
if (kind === "documents")
|
||||
return {
|
||||
summary:
|
||||
incoming != null && incoming > 32 * 1024
|
||||
? "Documentinvoer is actief"
|
||||
: "Documentworkflow staat stand-by",
|
||||
detail: metric?.statusText ?? "Wachten op archieftelemetrie",
|
||||
stats: [
|
||||
{ label: "Workerbelasting", value: cpu, icon: Cpu },
|
||||
{ label: "Invoer", value: formatRate(incoming), icon: ArrowDown },
|
||||
{ label: "Archiefgeheugen", value: memory, icon: MemoryStick },
|
||||
],
|
||||
};
|
||||
if (kind === "security")
|
||||
return {
|
||||
summary:
|
||||
app.status === "online"
|
||||
? "Beveiligde service is beschikbaar"
|
||||
: "Beveiligde service vraagt aandacht",
|
||||
detail:
|
||||
metric?.statusText ??
|
||||
"Wachten op telemetrie van de beveiligingsservice",
|
||||
stats: [
|
||||
{ label: "Verzoekbelasting", value: cpu, icon: Cpu },
|
||||
{
|
||||
label: "Beveiligd verkeer",
|
||||
value: formatRate(incoming),
|
||||
icon: Network,
|
||||
},
|
||||
{ label: "Servicegeheugen", value: memory, icon: MemoryStick },
|
||||
],
|
||||
};
|
||||
return {
|
||||
summary:
|
||||
app.status === "online" ? "Service reageert" : "Service vraagt aandacht",
|
||||
detail: metric?.statusText ?? "Wachten op live telemetrie",
|
||||
stats: [
|
||||
{ label: "Procesbelasting", value: cpu, icon: Cpu },
|
||||
{ label: "Live inkomend", value: formatRate(incoming), icon: ArrowDown },
|
||||
{ label: "Werkgeheugen", value: memory, icon: MemoryStick },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
interface ServiceWidgetProps {
|
||||
app: DockApp;
|
||||
metric?: WidgetMetric;
|
||||
capability?: WidgetCapability;
|
||||
providerState?: IntegrationState;
|
||||
externalAccess: boolean;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export function ServiceWidget({
|
||||
app,
|
||||
metric,
|
||||
capability,
|
||||
providerState,
|
||||
externalAccess,
|
||||
index,
|
||||
}: ServiceWidgetProps) {
|
||||
const kind = inferServiceKind(`${app.technicalName} ${app.displayName}`);
|
||||
const details = kindDetails[kind];
|
||||
const KindIcon = details.icon;
|
||||
const url = effectiveAppUrl(app, externalAccess);
|
||||
const fallback = capability
|
||||
? capabilityFallback(capability, app, metric)
|
||||
: tailoredFallback(kind, app, metric, providerState);
|
||||
const memoryPercent =
|
||||
metric?.memoryUsageBytes != null &&
|
||||
metric.memoryLimitBytes != null &&
|
||||
metric.memoryLimitBytes > 0
|
||||
? Math.min(100, (metric.memoryUsageBytes / metric.memoryLimitBytes) * 100)
|
||||
: 0;
|
||||
const freshness =
|
||||
metric?.provider?.freshness ?? metric?.freshness ?? "unavailable";
|
||||
const providerThresholdStat = metric?.provider?.stats.find(
|
||||
(stat) => stat.label === app.widgetWarningMetric,
|
||||
);
|
||||
const thresholdValue =
|
||||
app.widgetWarningMetric === "memory"
|
||||
? memoryPercent
|
||||
: app.widgetWarningMetric === "network"
|
||||
? metric?.networkRxBytesPerSecond
|
||||
: app.widgetWarningMetric === "cpu"
|
||||
? metric?.cpuPercent
|
||||
: (providerThresholdStat?.numericValue ??
|
||||
(providerThresholdStat
|
||||
? numericDisplayValue(providerThresholdStat.value)
|
||||
: null));
|
||||
const warning =
|
||||
app.widgetWarningThreshold !== null &&
|
||||
thresholdValue != null &&
|
||||
(app.widgetWarningDirection === "below"
|
||||
? thresholdValue <= app.widgetWarningThreshold
|
||||
: thresholdValue >= app.widgetWarningThreshold);
|
||||
const displayStats: TailoredStat[] = metric?.provider
|
||||
? metric.provider.stats.map((stat) => ({ ...stat }))
|
||||
: fallback.stats;
|
||||
const orderedStats = displayStats
|
||||
.filter((stat) => !app.widgetHiddenMetrics.includes(stat.label))
|
||||
.sort((left, right) => {
|
||||
if (app.widgetPrimaryMetric) {
|
||||
if (left.label === app.widgetPrimaryMetric) return -1;
|
||||
if (right.label === app.widgetPrimaryMetric) return 1;
|
||||
}
|
||||
const leftIndex = app.widgetMetricOrder.indexOf(left.label);
|
||||
const rightIndex = app.widgetMetricOrder.indexOf(right.label);
|
||||
return (
|
||||
(leftIndex < 0 ? Number.MAX_SAFE_INTEGER : leftIndex) -
|
||||
(rightIndex < 0 ? Number.MAX_SAFE_INTEGER : rightIndex)
|
||||
);
|
||||
})
|
||||
.slice(0, app.widgetStatLimit);
|
||||
const definitions = new Map(
|
||||
capability?.metrics.map((definition) => [definition.label, definition]) ??
|
||||
[],
|
||||
);
|
||||
const requestedChart = app.widgetChartMetric;
|
||||
const providerHistory = metric?.provider?.history ?? {};
|
||||
const automaticProviderSeries = Object.entries(providerHistory).find(
|
||||
([, history]) => history.length > 1,
|
||||
);
|
||||
const chartMetric =
|
||||
requestedChart === "auto"
|
||||
? (automaticProviderSeries?.[0] ?? "cpu")
|
||||
: requestedChart;
|
||||
const chartHistory =
|
||||
chartMetric === "cpu" ||
|
||||
chartMetric === "memory" ||
|
||||
chartMetric === "network"
|
||||
? infrastructureHistory(metric, chartMetric)
|
||||
: (providerHistory[chartMetric] ?? []);
|
||||
const body = (
|
||||
<>
|
||||
<header>
|
||||
<ServiceIcon
|
||||
name={app.displayName}
|
||||
icon={app.icon}
|
||||
index={index}
|
||||
className="widget-service-icon"
|
||||
/>
|
||||
<span className="widget-title">
|
||||
<small>
|
||||
{app.widgetCustomLabel || metric?.provider?.label || details.label}
|
||||
</small>
|
||||
<strong>{app.displayName}</strong>
|
||||
</span>
|
||||
<span className={`widget-freshness ${freshness}`}>{freshness}</span>
|
||||
<span className={`widget-kind ${kind}`} aria-hidden="true">
|
||||
<KindIcon size={17} />
|
||||
</span>
|
||||
</header>
|
||||
{app.widgetShowSummary && (
|
||||
<div className="widget-state">
|
||||
<span className={`status-dot ${app.status}`} />
|
||||
<strong>{metric?.provider?.summary ?? fallback.summary}</strong>
|
||||
<small>
|
||||
{metric?.provider
|
||||
? "Provider-API · alleen-lezen live data"
|
||||
: fallback.detail}
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
{app.widgetShowMetrics && (
|
||||
<div className="provider-stats tailored-stats">
|
||||
{orderedStats.map((stat) => {
|
||||
const StatIcon = stat.icon;
|
||||
const definition = definitions.get(stat.label) as
|
||||
WidgetMetricDefinition | undefined;
|
||||
const presentation =
|
||||
app.widgetMetricStyles[stat.label] ??
|
||||
definition?.defaultStyle ??
|
||||
"value";
|
||||
const numericValue =
|
||||
stat.numericValue ?? numericDisplayValue(stat.value);
|
||||
const presentationValue =
|
||||
numericValue === null
|
||||
? null
|
||||
: Math.max(0, Math.min(100, numericValue));
|
||||
return (
|
||||
<span
|
||||
className={`${stat.tone ?? "default"} metric-style-${presentation}`}
|
||||
key={stat.label}
|
||||
>
|
||||
{StatIcon && <StatIcon size={13} />}
|
||||
<small>{stat.label}</small>
|
||||
<strong>{stat.value}</strong>
|
||||
{presentation !== "value" && presentationValue !== null && (
|
||||
<span
|
||||
className="stat-visual"
|
||||
role="meter"
|
||||
aria-label={`${stat.label} ${presentation}`}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(presentationValue)}
|
||||
>
|
||||
<i style={{ width: `${presentationValue}%` }} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{app.widgetShowSparkline && chartHistory.length > 1 && (
|
||||
<WidgetSparkline
|
||||
history={chartHistory}
|
||||
chartType={app.widgetChartType}
|
||||
sampleLimit={app.widgetChartSamples}
|
||||
label={
|
||||
chartMetric === "cpu"
|
||||
? "Container-CPU"
|
||||
: chartMetric === "memory"
|
||||
? "Containergeheugen"
|
||||
: chartMetric === "network"
|
||||
? "Inkomend netwerkverkeer"
|
||||
: chartMetric
|
||||
}
|
||||
showLegend={app.widgetShowLegend}
|
||||
/>
|
||||
)}
|
||||
{app.widgetShowInfrastructure && (
|
||||
<div className="widget-infra-line">
|
||||
<span>
|
||||
<Cpu size={12} />{" "}
|
||||
{metric?.cpuPercent == null
|
||||
? "—"
|
||||
: `${metric.cpuPercent.toFixed(1)}%`}
|
||||
</span>
|
||||
<span>
|
||||
<MemoryStick size={12} />{" "}
|
||||
{formatBytes(metric?.memoryUsageBytes ?? null)}
|
||||
</span>
|
||||
<span>
|
||||
<Network size={12} /> {formatRate(metric?.networkRxBytesPerSecond)}
|
||||
</span>
|
||||
<span>
|
||||
<Activity size={12} /> {metric?.pids ?? "—"} PIDs
|
||||
</span>
|
||||
<span>
|
||||
<Clock3 size={12} />
|
||||
{formatUpdatedAt(metric?.provider?.updatedAt ?? metric?.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{app.widgetShowProgress && (
|
||||
<div
|
||||
className="memory-track"
|
||||
role="meter"
|
||||
aria-label="Geheugengebruik"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(memoryPercent)}
|
||||
>
|
||||
<span style={{ width: `${memoryPercent}%` }} />
|
||||
</div>
|
||||
)}
|
||||
{url && <ArrowUpRight className="widget-arrow" size={17} />}
|
||||
</>
|
||||
);
|
||||
return url ? (
|
||||
<a
|
||||
className={`service-widget ${app.status} service-widget-${kind} widget-size-${app.widgetSize}${warning ? " widget-warning" : ""}`}
|
||||
data-widget-size={app.widgetSize}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{body}
|
||||
</a>
|
||||
) : (
|
||||
<article
|
||||
className={`service-widget ${app.status} service-widget-${kind} widget-size-${app.widgetSize}${warning ? " widget-warning" : ""}`}
|
||||
data-widget-size={app.widgetSize}
|
||||
>
|
||||
{body}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { Activity, Check, RefreshCw, Settings2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import type { DashboardPayload, WidgetSize } from "../../shared/contracts";
|
||||
import { api } from "../api";
|
||||
import { ServiceWidget } from "./ServiceWidget";
|
||||
import { ServerWidget } from "./ServerWidget";
|
||||
import { WidgetEditToolbar } from "./WidgetEditToolbar";
|
||||
|
||||
interface ServiceWidgetsProps {
|
||||
data: DashboardPayload;
|
||||
syncing: boolean;
|
||||
onSync: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function ServiceWidgets({ data, syncing, onSync }: ServiceWidgetsProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [editError, setEditError] = useState<string | null>(null);
|
||||
const apps = data.apps
|
||||
.filter((app) => app.widgetEnabled)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.widgetOrder - right.widgetOrder ||
|
||||
left.displayName.localeCompare(right.displayName),
|
||||
);
|
||||
const showServer = data.preferences.showServerWidget;
|
||||
if (apps.length === 0 && !showServer) return null;
|
||||
const items: Array<
|
||||
| {
|
||||
kind: "server";
|
||||
id: "server";
|
||||
order: number;
|
||||
size: WidgetSize;
|
||||
label: string;
|
||||
}
|
||||
| {
|
||||
kind: "app";
|
||||
id: string;
|
||||
order: number;
|
||||
size: WidgetSize;
|
||||
label: string;
|
||||
}
|
||||
> = [
|
||||
...(showServer
|
||||
? [
|
||||
{
|
||||
kind: "server" as const,
|
||||
id: "server" as const,
|
||||
order: data.preferences.serverWidgetOrder,
|
||||
size: data.preferences.serverWidgetSize,
|
||||
label: "Unraid server",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...apps.map((app) => ({
|
||||
kind: "app" as const,
|
||||
id: app.id,
|
||||
order: app.widgetOrder,
|
||||
size: app.widgetSize,
|
||||
label: app.displayName,
|
||||
})),
|
||||
].sort(
|
||||
(left, right) =>
|
||||
left.order - right.order || left.label.localeCompare(right.label),
|
||||
);
|
||||
|
||||
const perform = async (action: () => Promise<unknown>) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await action();
|
||||
await onSync();
|
||||
setEditError(null);
|
||||
} catch (error) {
|
||||
setEditError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "De indelingswijziging kon niet worden opgeslagen",
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const move = (index: number, direction: -1 | 1) => {
|
||||
const target = index + direction;
|
||||
if (!items[target]) return;
|
||||
const reordered = [...items];
|
||||
[reordered[index], reordered[target]] = [
|
||||
reordered[target]!,
|
||||
reordered[index]!,
|
||||
];
|
||||
void perform(() =>
|
||||
api.updateWidgetLayout({
|
||||
items: reordered.map((item, position) => ({
|
||||
id: item.id,
|
||||
kind: item.kind,
|
||||
order: position * 10,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="widgets-section" aria-labelledby="widgets-heading">
|
||||
<header>
|
||||
<div>
|
||||
<p className="section-kicker">LIVE UIT JE DOCKER-OMGEVING</p>
|
||||
<h2 id="widgets-heading">
|
||||
<Activity size={18} /> Servicepanelen
|
||||
</h2>
|
||||
</div>
|
||||
<div className="widgets-actions">
|
||||
<button
|
||||
className={editing ? "widgets-edit active" : "widgets-edit"}
|
||||
onClick={() => setEditing((value) => !value)}
|
||||
aria-pressed={editing}
|
||||
>
|
||||
{editing ? <Check size={13} /> : <Settings2 size={13} />}
|
||||
{editing ? "Klaar" : "Indeling bewerken"}
|
||||
</button>
|
||||
<button
|
||||
className="widgets-refresh"
|
||||
onClick={() => void onSync()}
|
||||
disabled={syncing}
|
||||
>
|
||||
<RefreshCw size={13} className={syncing ? "spin" : ""} />
|
||||
{syncing
|
||||
? "Vernieuwen"
|
||||
: items.length === 1
|
||||
? "1 livepaneel"
|
||||
: `${items.length} livepanelen`}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
{editError && (
|
||||
<p className="widget-edit-error" role="alert">
|
||||
{editError}
|
||||
</p>
|
||||
)}
|
||||
<div className={editing ? "widgets-grid editing" : "widgets-grid"}>
|
||||
{items.map((item, index) => {
|
||||
const app =
|
||||
item.kind === "app"
|
||||
? apps.find((candidate) => candidate.id === item.id)
|
||||
: undefined;
|
||||
return (
|
||||
<div
|
||||
className={`widget-shell widget-size-${item.size}`}
|
||||
data-widget-size={item.size}
|
||||
key={`${item.kind}-${item.id}`}
|
||||
>
|
||||
{editing && (
|
||||
<WidgetEditToolbar
|
||||
label={item.label}
|
||||
size={item.size}
|
||||
first={index === 0}
|
||||
last={index === items.length - 1}
|
||||
disabled={saving || syncing}
|
||||
onMove={(direction) => move(index, direction)}
|
||||
onSizeChange={(size) =>
|
||||
void perform(() =>
|
||||
item.kind === "server"
|
||||
? api.updatePreferences({ serverWidgetSize: size })
|
||||
: api.updateApp(item.id, { widgetSize: size }),
|
||||
)
|
||||
}
|
||||
onHide={() =>
|
||||
void perform(() =>
|
||||
item.kind === "server"
|
||||
? api.updatePreferences({ showServerWidget: false })
|
||||
: api.updateApp(item.id, { widgetEnabled: false }),
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{item.kind === "server" ? (
|
||||
<ServerWidget
|
||||
metric={data.serverWidget}
|
||||
size={data.preferences.serverWidgetSize}
|
||||
showStats={data.preferences.serverWidgetShowStats}
|
||||
showDetails={data.preferences.serverWidgetShowDetails}
|
||||
showProgress={data.preferences.serverWidgetShowProgress}
|
||||
showNative={data.preferences.serverWidgetShowNative}
|
||||
showChart={data.preferences.serverWidgetShowChart}
|
||||
chartMetric={data.preferences.serverWidgetChartMetric}
|
||||
chartType={data.preferences.serverWidgetChartType}
|
||||
chartSamples={data.preferences.serverWidgetChartSamples}
|
||||
showLegend={data.preferences.serverWidgetShowLegend}
|
||||
hiddenMetrics={data.preferences.serverWidgetHiddenMetrics}
|
||||
metricOrder={data.preferences.serverWidgetMetricOrder}
|
||||
statLimit={data.preferences.serverWidgetStatLimit}
|
||||
/>
|
||||
) : app ? (
|
||||
<ServiceWidget
|
||||
app={app}
|
||||
metric={data.widgetMetrics[app.technicalName]}
|
||||
capability={data.widgetCapabilities.find(
|
||||
(capability) => capability.appId === app.id,
|
||||
)}
|
||||
providerState={data.integrations.find(
|
||||
(integration) =>
|
||||
integration.id === `widget-${app.technicalName}`,
|
||||
)}
|
||||
externalAccess={data.preferences.externalAccess}
|
||||
index={index}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import { ArrowDown, ArrowUp, GripVertical } from "lucide-react";
|
||||
import { isSortableDropTarget } from "../sortable-order";
|
||||
import {
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type ReactNode,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
export interface SortableControls {
|
||||
dragHandle: ReactNode;
|
||||
moveDownButton: ReactNode;
|
||||
moveUpButton: ReactNode;
|
||||
isDragging: boolean;
|
||||
}
|
||||
|
||||
interface SortableListProps<T extends { id: string }> {
|
||||
items: readonly T[];
|
||||
label: string;
|
||||
className: string;
|
||||
emptyMessage?: string;
|
||||
orientation?: "vertical" | "horizontal" | "grid";
|
||||
getItemLabel: (item: T) => string;
|
||||
onReorder: (items: T[]) => void | boolean | Promise<void | boolean>;
|
||||
renderItem: (item: T, index: number, controls: SortableControls) => ReactNode;
|
||||
}
|
||||
|
||||
interface DropTarget {
|
||||
id: string;
|
||||
after: boolean;
|
||||
}
|
||||
|
||||
interface PointerDrag {
|
||||
id: string;
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
const moveItem = <T extends { id: string }>(
|
||||
items: readonly T[],
|
||||
sourceId: string,
|
||||
targetId: string,
|
||||
after: boolean,
|
||||
) => {
|
||||
const sourceIndex = items.findIndex((item) => item.id === sourceId);
|
||||
const targetIndex = items.findIndex((item) => item.id === targetId);
|
||||
if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex)
|
||||
return [...items];
|
||||
|
||||
const next = [...items];
|
||||
const [moved] = next.splice(sourceIndex, 1);
|
||||
if (!moved) return [...items];
|
||||
const adjustedTarget = targetIndex - Number(sourceIndex < targetIndex);
|
||||
next.splice(adjustedTarget + Number(after), 0, moved);
|
||||
return next;
|
||||
};
|
||||
|
||||
export function SortableList<T extends { id: string }>({
|
||||
items,
|
||||
label,
|
||||
className,
|
||||
emptyMessage,
|
||||
orientation = "vertical",
|
||||
getItemLabel,
|
||||
onReorder,
|
||||
renderItem,
|
||||
}: SortableListProps<T>) {
|
||||
const [optimisticItems, setOptimisticItems] = useState<T[] | null>(null);
|
||||
const [draggingId, setDraggingId] = useState<string | null>(null);
|
||||
const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [announcement, setAnnouncement] = useState("");
|
||||
const pointerDrag = useRef<PointerDrag | null>(null);
|
||||
const savingRef = useRef(false);
|
||||
const orderedItems = optimisticItems ?? [...items];
|
||||
|
||||
const commitOrder = async (nextItems: T[], movedId: string) => {
|
||||
if (savingRef.current) return;
|
||||
const nextPosition = nextItems.findIndex((item) => item.id === movedId) + 1;
|
||||
savingRef.current = true;
|
||||
setSaving(true);
|
||||
setOptimisticItems(nextItems);
|
||||
setAnnouncement(
|
||||
`${getItemLabel(nextItems[nextPosition - 1]!)} verplaatst naar positie ${nextPosition} van ${nextItems.length}.`,
|
||||
);
|
||||
try {
|
||||
const saved = await onReorder(nextItems);
|
||||
if (saved === false) {
|
||||
setOptimisticItems(null);
|
||||
setAnnouncement("De nieuwe volgorde kon niet worden opgeslagen.");
|
||||
}
|
||||
} catch {
|
||||
setOptimisticItems(null);
|
||||
setAnnouncement("De nieuwe volgorde kon niet worden opgeslagen.");
|
||||
} finally {
|
||||
setOptimisticItems(null);
|
||||
savingRef.current = false;
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const moveBy = (itemId: string, offset: -1 | 1) => {
|
||||
const sourceIndex = orderedItems.findIndex((item) => item.id === itemId);
|
||||
const targetIndex = sourceIndex + offset;
|
||||
if (
|
||||
sourceIndex < 0 ||
|
||||
targetIndex < 0 ||
|
||||
targetIndex >= orderedItems.length
|
||||
)
|
||||
return;
|
||||
const next = [...orderedItems];
|
||||
[next[sourceIndex], next[targetIndex]] = [
|
||||
next[targetIndex]!,
|
||||
next[sourceIndex]!,
|
||||
];
|
||||
void commitOrder(next, itemId);
|
||||
};
|
||||
|
||||
const resetPointerDrag = () => {
|
||||
pointerDrag.current = null;
|
||||
setDraggingId(null);
|
||||
setDropTarget(null);
|
||||
};
|
||||
|
||||
const handlePointerDown = (
|
||||
event: ReactPointerEvent<HTMLButtonElement>,
|
||||
id: string,
|
||||
) => {
|
||||
if (savingRef.current || event.button !== 0 || event.isPrimary === false)
|
||||
return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
pointerDrag.current = {
|
||||
id,
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
active: false,
|
||||
};
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const drag = pointerDrag.current;
|
||||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||
|
||||
if (
|
||||
!drag.active &&
|
||||
Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY) < 6
|
||||
)
|
||||
return;
|
||||
|
||||
if (!drag.active) {
|
||||
drag.active = true;
|
||||
setDraggingId(drag.id);
|
||||
}
|
||||
|
||||
const elementAtPointer = document
|
||||
.elementFromPoint?.(event.clientX, event.clientY)
|
||||
?.closest<HTMLElement>("[data-sortable-id]");
|
||||
const eventTarget = (event.target as HTMLElement | null)?.closest?.(
|
||||
"[data-sortable-id]",
|
||||
);
|
||||
const target = elementAtPointer ?? eventTarget;
|
||||
const targetId = (target as HTMLElement | null)?.dataset.sortableId;
|
||||
if (
|
||||
!target ||
|
||||
!targetId ||
|
||||
!isSortableDropTarget(target, event.currentTarget) ||
|
||||
targetId === drag.id
|
||||
) {
|
||||
setDropTarget(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const bounds = target.getBoundingClientRect();
|
||||
const isVertical =
|
||||
orientation === "vertical" ||
|
||||
(orientation === "grid" &&
|
||||
Math.abs(event.clientY - (bounds.top + bounds.height / 2)) >
|
||||
Math.abs(event.clientX - (bounds.left + bounds.width / 2)));
|
||||
const after = isVertical
|
||||
? event.clientY > bounds.top + bounds.height / 2
|
||||
: event.clientX > bounds.left + bounds.width / 2;
|
||||
setDropTarget({ id: targetId, after });
|
||||
};
|
||||
|
||||
const handlePointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const drag = pointerDrag.current;
|
||||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||
const destination = dropTarget;
|
||||
resetPointerDrag();
|
||||
if (!drag.active || !destination) return;
|
||||
const next = moveItem(
|
||||
orderedItems,
|
||||
drag.id,
|
||||
destination.id,
|
||||
destination.after,
|
||||
);
|
||||
if (next.some((item, index) => item.id !== orderedItems[index]?.id))
|
||||
void commitOrder(next, drag.id);
|
||||
};
|
||||
|
||||
if (items.length === 0) {
|
||||
return emptyMessage ? (
|
||||
<div className={`${className} sortable-list-empty`} role="status">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`sortable-list ${className}`}
|
||||
role="list"
|
||||
aria-label={label}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={resetPointerDrag}
|
||||
>
|
||||
{orderedItems.map((item, index) => {
|
||||
const itemLabel = getItemLabel(item);
|
||||
const isDragging = draggingId === item.id;
|
||||
const isDropTarget = dropTarget?.id === item.id;
|
||||
const handle = (
|
||||
<button
|
||||
className="sortable-handle"
|
||||
type="button"
|
||||
aria-label={`Sleep ${itemLabel}; gebruik de knoppen omhoog en omlaag voor toetsenbordbediening`}
|
||||
title="Sleep om te verplaatsen"
|
||||
disabled={saving}
|
||||
onClick={(event) => event.preventDefault()}
|
||||
onPointerDown={(event) => handlePointerDown(event, item.id)}
|
||||
>
|
||||
<GripVertical size={15} aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
const moveUpButton = (
|
||||
<button
|
||||
className="sortable-move-button"
|
||||
type="button"
|
||||
aria-label={`${itemLabel} omhoog verplaatsen`}
|
||||
title="Omhoog verplaatsen"
|
||||
disabled={saving || index === 0}
|
||||
onClick={() => moveBy(item.id, -1)}
|
||||
>
|
||||
<ArrowUp size={14} aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
const moveDownButton = (
|
||||
<button
|
||||
className="sortable-move-button"
|
||||
type="button"
|
||||
aria-label={`${itemLabel} omlaag verplaatsen`}
|
||||
title="Omlaag verplaatsen"
|
||||
disabled={saving || index === orderedItems.length - 1}
|
||||
onClick={() => moveBy(item.id, 1)}
|
||||
>
|
||||
<ArrowDown size={14} aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
const targetPosition =
|
||||
isDropTarget && dropTarget
|
||||
? dropTarget.after
|
||||
? "after"
|
||||
: "before"
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`sortable-list-item ${isDragging ? "is-dragging" : ""} ${targetPosition ? `drop-${targetPosition}` : ""}`}
|
||||
data-sortable-id={item.id}
|
||||
data-sortable-index={index}
|
||||
aria-busy={saving || undefined}
|
||||
role="listitem"
|
||||
key={item.id}
|
||||
>
|
||||
{renderItem(item, index, {
|
||||
dragHandle: handle,
|
||||
moveDownButton,
|
||||
moveUpButton,
|
||||
isDragging,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className="sr-only" aria-live="polite" aria-atomic="true">
|
||||
{announcement}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
Cloud,
|
||||
CloudFog,
|
||||
CloudLightning,
|
||||
CloudMoon,
|
||||
CloudRain,
|
||||
CloudSun,
|
||||
Moon,
|
||||
Snowflake,
|
||||
Sun,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { WeatherMetric } from "../../shared/contracts";
|
||||
import { api } from "../api";
|
||||
|
||||
const iconFor = (weather: WeatherMetric | null) => {
|
||||
if (!weather) return CloudSun;
|
||||
const code = weather.weatherCode;
|
||||
if (code === 0) return weather.isDay ? Sun : Moon;
|
||||
if (code <= 3) return weather.isDay ? CloudSun : CloudMoon;
|
||||
if (code === 45 || code === 48) return CloudFog;
|
||||
if (code <= 67 || (code >= 80 && code <= 82)) return CloudRain;
|
||||
if (code <= 77 || (code >= 85 && code <= 86)) return Snowflake;
|
||||
if (code >= 95) return CloudLightning;
|
||||
return Cloud;
|
||||
};
|
||||
|
||||
export function WeatherBadge({
|
||||
enabled,
|
||||
location,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
location: string;
|
||||
}) {
|
||||
const [weather, setWeather] = useState<WeatherMetric | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
let active = true;
|
||||
const refresh = () =>
|
||||
void api
|
||||
.weather()
|
||||
.then((value) => active && setWeather(value))
|
||||
.catch(() => active && setWeather(null));
|
||||
refresh();
|
||||
const timer = window.setInterval(refresh, 10 * 60_000);
|
||||
return () => {
|
||||
active = false;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [enabled, location]);
|
||||
|
||||
if (!enabled) return null;
|
||||
const Icon = iconFor(weather);
|
||||
return (
|
||||
<span
|
||||
className={`weather-badge ${weather ? "ready" : "loading"}`}
|
||||
title={
|
||||
weather ? `${weather.summary} in ${weather.location}` : "Weer laden"
|
||||
}
|
||||
aria-label={
|
||||
weather
|
||||
? `${Math.round(weather.temperatureC)} graden, ${weather.summary} in ${weather.location}`
|
||||
: "Weer laden"
|
||||
}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<strong>
|
||||
{weather ? `${Math.round(weather.temperatureC)}°` : "--°"}
|
||||
</strong>
|
||||
<span>{weather?.location ?? location}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
BarChart3,
|
||||
Gauge,
|
||||
RefreshCw,
|
||||
SlidersHorizontal,
|
||||
Tag,
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
AppPatch,
|
||||
DockApp,
|
||||
WidgetMetricDefinition,
|
||||
WidgetMetricStyle,
|
||||
} from "../../shared/contracts";
|
||||
|
||||
interface WidgetAdvancedConfiguratorProps {
|
||||
app: DockApp;
|
||||
availableMetrics: WidgetMetricDefinition[];
|
||||
onChange: (patch: AppPatch, message: string) => void;
|
||||
}
|
||||
|
||||
const refreshOptions = [
|
||||
{ value: 15_000, label: "15 seconden" },
|
||||
{ value: 30_000, label: "30 seconden" },
|
||||
{ value: 60_000, label: "1 minuut" },
|
||||
{ value: 120_000, label: "2 minuten" },
|
||||
{ value: 300_000, label: "5 minuten" },
|
||||
];
|
||||
|
||||
const chartSamples = [10, 20, 30, 45, 60];
|
||||
const infrastructureMetrics = [
|
||||
{ label: "cpu", name: "Container-CPU" },
|
||||
{ label: "memory", name: "Containergeheugen" },
|
||||
{ label: "network", name: "Inkomend netwerkverkeer" },
|
||||
];
|
||||
|
||||
export function WidgetAdvancedConfigurator({
|
||||
app,
|
||||
availableMetrics,
|
||||
onChange,
|
||||
}: WidgetAdvancedConfiguratorProps) {
|
||||
const metricLabels = availableMetrics.map((metric) => metric.label);
|
||||
const orderedMetrics = [
|
||||
...app.widgetMetricOrder.filter((label) => metricLabels.includes(label)),
|
||||
...metricLabels.filter((label) => !app.widgetMetricOrder.includes(label)),
|
||||
].filter((label) => !app.widgetHiddenMetrics.includes(label));
|
||||
const chartableMetrics = availableMetrics.filter(
|
||||
(metric) => metric.chartable,
|
||||
);
|
||||
const warningMetrics = [
|
||||
...infrastructureMetrics,
|
||||
...availableMetrics
|
||||
.filter((metric) => metric.unit !== "text")
|
||||
.map((metric) => ({ label: metric.label, name: metric.label })),
|
||||
];
|
||||
const moveMetric = (index: number, direction: -1 | 1) => {
|
||||
const target = index + direction;
|
||||
if (!orderedMetrics[target]) return;
|
||||
const next = [...orderedMetrics];
|
||||
[next[index], next[target]] = [next[target]!, next[index]!];
|
||||
onChange({ widgetMetricOrder: next }, "Volgorde van metingen bijgewerkt");
|
||||
};
|
||||
const setMetricStyle = (label: string, style: WidgetMetricStyle) =>
|
||||
onChange(
|
||||
{
|
||||
widgetMetricStyles: { ...app.widgetMetricStyles, [label]: style },
|
||||
},
|
||||
`Weergave van ${label} bijgewerkt`,
|
||||
);
|
||||
|
||||
return (
|
||||
<details className="widget-advanced-config">
|
||||
<summary>
|
||||
<Gauge size={14} /> Volledige paneelconfiguratie
|
||||
</summary>
|
||||
<div className="widget-advanced-grid">
|
||||
<label>
|
||||
<span>
|
||||
<Tag size={13} /> Eigen paneellabel
|
||||
</span>
|
||||
<input
|
||||
name={`widgetLabel-${app.id}`}
|
||||
defaultValue={app.widgetCustomLabel ?? ""}
|
||||
placeholder="Gebruik een appspecifiek label"
|
||||
maxLength={64}
|
||||
onBlur={(event) => {
|
||||
const value = event.target.value.trim() || null;
|
||||
if (value !== app.widgetCustomLabel)
|
||||
onChange(
|
||||
{ widgetCustomLabel: value },
|
||||
"Paneellabel bijgewerkt",
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Primaire statistiek</span>
|
||||
<select
|
||||
name={`widgetPrimaryMetric-${app.id}`}
|
||||
value={app.widgetPrimaryMetric ?? ""}
|
||||
onChange={(event) =>
|
||||
onChange(
|
||||
{ widgetPrimaryMetric: event.target.value || null },
|
||||
"Primaire meting bijgewerkt",
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">Automatisch</option>
|
||||
{orderedMetrics.map((metric) => (
|
||||
<option value={metric} key={metric}>
|
||||
{metric}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Maximum aantal statistieken</span>
|
||||
<select
|
||||
name={`widgetStatLimit-${app.id}`}
|
||||
value={app.widgetStatLimit}
|
||||
onChange={(event) =>
|
||||
onChange(
|
||||
{ widgetStatLimit: Number(event.target.value) },
|
||||
"Limiet voor statistieken bijgewerkt",
|
||||
)
|
||||
}
|
||||
>
|
||||
{Array.from({ length: 12 }, (_, index) => index + 1).map(
|
||||
(limit) => (
|
||||
<option value={limit} key={limit}>
|
||||
{limit} statistieken
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Paneelvolgorde</span>
|
||||
<input
|
||||
name={`widgetOrder-${app.id}`}
|
||||
type="number"
|
||||
min={0}
|
||||
max={10_000}
|
||||
defaultValue={app.widgetOrder}
|
||||
onBlur={(event) => {
|
||||
const value = Number(event.target.value);
|
||||
if (value !== app.widgetOrder)
|
||||
onChange({ widgetOrder: value }, "Paneelvolgorde bijgewerkt");
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>
|
||||
<RefreshCw size={13} /> Vernieuwingsinterval
|
||||
</span>
|
||||
<select
|
||||
name={`widgetRefreshInterval-${app.id}`}
|
||||
value={app.widgetRefreshIntervalMs}
|
||||
onChange={(event) =>
|
||||
onChange(
|
||||
{ widgetRefreshIntervalMs: Number(event.target.value) },
|
||||
"Vernieuwingsinterval bijgewerkt",
|
||||
)
|
||||
}
|
||||
>
|
||||
{refreshOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<section className="widget-config-section" aria-label="Trendgrafiek">
|
||||
<header>
|
||||
<BarChart3 size={14} />
|
||||
<span>
|
||||
<strong>Trendgrafiek</strong>
|
||||
<small>
|
||||
Kies een echte reeks, grafiekstijl en geheugenvenster.
|
||||
</small>
|
||||
</span>
|
||||
</header>
|
||||
<div className="widget-advanced-grid">
|
||||
<label>
|
||||
<span>Reeks</span>
|
||||
<select
|
||||
name={`widgetChartMetric-${app.id}`}
|
||||
value={app.widgetChartMetric}
|
||||
onChange={(event) =>
|
||||
onChange(
|
||||
{ widgetChartMetric: event.target.value },
|
||||
"Grafiekreeks bijgewerkt",
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="auto">Beste beschikbare signaal</option>
|
||||
{infrastructureMetrics.map((metric) => (
|
||||
<option key={metric.label} value={metric.label}>
|
||||
{metric.name}
|
||||
</option>
|
||||
))}
|
||||
{chartableMetrics.map((metric) => (
|
||||
<option key={metric.label} value={metric.label}>
|
||||
{metric.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Grafiekstijl</span>
|
||||
<select
|
||||
name={`widgetChartType-${app.id}`}
|
||||
value={app.widgetChartType}
|
||||
onChange={(event) =>
|
||||
onChange(
|
||||
{
|
||||
widgetChartType: event.target.value as
|
||||
"line" | "area" | "bar",
|
||||
},
|
||||
"Grafiekstijl bijgewerkt",
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="line">Lijn</option>
|
||||
<option value="area">Vlak</option>
|
||||
<option value="bar">Balken</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Geschiedenisvenster</span>
|
||||
<select
|
||||
name={`widgetChartSamples-${app.id}`}
|
||||
value={app.widgetChartSamples}
|
||||
onChange={(event) =>
|
||||
onChange(
|
||||
{ widgetChartSamples: Number(event.target.value) },
|
||||
"Grafiekvenster bijgewerkt",
|
||||
)
|
||||
}
|
||||
>
|
||||
{chartSamples.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
Laatste {value} metingen
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
app.widgetShowSparkline
|
||||
? "sparkline-toggle active"
|
||||
: "sparkline-toggle"
|
||||
}
|
||||
aria-pressed={app.widgetShowSparkline}
|
||||
onClick={() =>
|
||||
onChange(
|
||||
{ widgetShowSparkline: !app.widgetShowSparkline },
|
||||
app.widgetShowSparkline
|
||||
? "Grafiek verborgen"
|
||||
: "Grafiek ingeschakeld",
|
||||
)
|
||||
}
|
||||
>
|
||||
<TrendingUp size={15} />
|
||||
<span>
|
||||
<strong>Grafiek tonen</strong>
|
||||
<small>Geschiedenis blijft alleen in het geheugen</small>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
app.widgetShowLegend
|
||||
? "sparkline-toggle active"
|
||||
: "sparkline-toggle"
|
||||
}
|
||||
aria-pressed={app.widgetShowLegend}
|
||||
onClick={() =>
|
||||
onChange(
|
||||
{ widgetShowLegend: !app.widgetShowLegend },
|
||||
"Grafieklegenda bijgewerkt",
|
||||
)
|
||||
}
|
||||
>
|
||||
<SlidersHorizontal size={15} />
|
||||
<span>
|
||||
<strong>Legenda tonen</strong>
|
||||
<small>Reeks, waarde en aantal metingen</small>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="widget-config-section"
|
||||
aria-label="Paneelwaarschuwing"
|
||||
>
|
||||
<header>
|
||||
<Gauge size={14} />
|
||||
<span>
|
||||
<strong>Visuele waarschuwing</strong>
|
||||
<small>
|
||||
Markeer de kaart wanneer een gekozen numeriek signaal een
|
||||
drempelwaarde overschrijdt.
|
||||
</small>
|
||||
</span>
|
||||
</header>
|
||||
<div className="widget-advanced-grid">
|
||||
<label>
|
||||
<span>Waarschuwingssignaal</span>
|
||||
<select
|
||||
name={`widgetWarningMetric-${app.id}`}
|
||||
value={app.widgetWarningMetric}
|
||||
onChange={(event) =>
|
||||
onChange(
|
||||
{ widgetWarningMetric: event.target.value },
|
||||
"Waarschuwingsmeting bijgewerkt",
|
||||
)
|
||||
}
|
||||
>
|
||||
{warningMetrics.map((metric) => (
|
||||
<option value={metric.label} key={metric.label}>
|
||||
{metric.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Activeringsrichting</span>
|
||||
<select
|
||||
name={`widgetWarningDirection-${app.id}`}
|
||||
value={app.widgetWarningDirection}
|
||||
onChange={(event) =>
|
||||
onChange(
|
||||
{
|
||||
widgetWarningDirection: event.target.value as
|
||||
"above" | "below",
|
||||
},
|
||||
"Waarschuwingsrichting bijgewerkt",
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="above">Op of boven</option>
|
||||
<option value="below">Op of onder</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Drempelwaarde</span>
|
||||
<input
|
||||
name={`widgetWarningThreshold-${app.id}`}
|
||||
type="number"
|
||||
min={0}
|
||||
max={1_000_000_000_000}
|
||||
defaultValue={app.widgetWarningThreshold ?? ""}
|
||||
placeholder="Uitgeschakeld"
|
||||
onBlur={(event) => {
|
||||
const parsed = event.target.value
|
||||
? Number(event.target.value)
|
||||
: null;
|
||||
if (parsed !== app.widgetWarningThreshold)
|
||||
onChange(
|
||||
{ widgetWarningThreshold: parsed },
|
||||
"Waarschuwingsdrempel bijgewerkt",
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div
|
||||
className="metric-visibility-list"
|
||||
aria-label="Zichtbare app-metingen"
|
||||
>
|
||||
<strong>Appspecifieke metingen</strong>
|
||||
<small>
|
||||
Kies elke statistiek en of die als waarde, meter of voortgangsbalk
|
||||
verschijnt.
|
||||
</small>
|
||||
<div>
|
||||
{availableMetrics.map((metric) => {
|
||||
const visible = !app.widgetHiddenMetrics.includes(metric.label);
|
||||
const style =
|
||||
app.widgetMetricStyles[metric.label] ?? metric.defaultStyle;
|
||||
return (
|
||||
<div className="metric-config-row" key={metric.label}>
|
||||
<button
|
||||
type="button"
|
||||
className={visible ? "active" : ""}
|
||||
aria-pressed={visible}
|
||||
onClick={() =>
|
||||
onChange(
|
||||
{
|
||||
widgetHiddenMetrics: visible
|
||||
? [...app.widgetHiddenMetrics, metric.label]
|
||||
: app.widgetHiddenMetrics.filter(
|
||||
(label) => label !== metric.label,
|
||||
),
|
||||
},
|
||||
`${metric.label} ${visible ? "verborgen" : "ingeschakeld"}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<span>{metric.label}</span>
|
||||
<small>{metric.description}</small>
|
||||
</button>
|
||||
<label>
|
||||
<span className="sr-only">Weergave van {metric.label}</span>
|
||||
<select
|
||||
name={`widgetMetricStyle-${app.id}-${metric.label}`}
|
||||
value={style}
|
||||
disabled={!visible}
|
||||
onChange={(event) =>
|
||||
setMetricStyle(
|
||||
metric.label,
|
||||
event.target.value as WidgetMetricStyle,
|
||||
)
|
||||
}
|
||||
aria-label={`Weergave van ${metric.label}`}
|
||||
>
|
||||
<option value="value">Waarde</option>
|
||||
<option value="gauge">Meter</option>
|
||||
<option value="progress">Voortgang</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{orderedMetrics.length > 1 && (
|
||||
<div
|
||||
className="metric-order-list"
|
||||
aria-label="Weergavevolgorde van metingen"
|
||||
>
|
||||
{orderedMetrics.map((metric, index) => (
|
||||
<span key={metric}>
|
||||
<strong>{metric}</strong>
|
||||
<button
|
||||
type="button"
|
||||
disabled={index === 0}
|
||||
onClick={() => moveMetric(index, -1)}
|
||||
aria-label={`${metric} eerder plaatsen`}
|
||||
>
|
||||
<ArrowUp size={12} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={index === orderedMetrics.length - 1}
|
||||
onClick={() => moveMetric(index, 1)}
|
||||
aria-label={`${metric} later plaatsen`}
|
||||
>
|
||||
<ArrowDown size={12} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Check, Maximize2, Rows3 } from "lucide-react";
|
||||
import type { WidgetSize } from "../../shared/contracts";
|
||||
|
||||
export interface WidgetSectionOption {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface WidgetConfiguratorProps {
|
||||
label: string;
|
||||
size: WidgetSize;
|
||||
sections: WidgetSectionOption[];
|
||||
onSizeChange: (size: WidgetSize) => void;
|
||||
onSectionChange: (id: string, enabled: boolean) => void;
|
||||
}
|
||||
|
||||
const sizes: Array<{
|
||||
id: WidgetSize;
|
||||
label: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{ id: "compact", label: "Compact", description: "Snelle blik" },
|
||||
{ id: "standard", label: "Standaard", description: "Evenwichtig" },
|
||||
{ id: "wide", label: "Breed", description: "Veel detail" },
|
||||
];
|
||||
|
||||
export function WidgetConfigurator({
|
||||
label,
|
||||
size,
|
||||
sections,
|
||||
onSizeChange,
|
||||
onSectionChange,
|
||||
}: WidgetConfiguratorProps) {
|
||||
return (
|
||||
<div className="widget-configurator">
|
||||
<div className="widget-config-heading">
|
||||
<span>
|
||||
<Maximize2 size={13} /> Paneelformaat
|
||||
</span>
|
||||
<small>
|
||||
{sections.filter((section) => section.enabled).length} content blocks
|
||||
</small>
|
||||
</div>
|
||||
<div
|
||||
className="widget-size-picker"
|
||||
role="group"
|
||||
aria-label={`Paneelgrootte voor ${label}`}
|
||||
>
|
||||
{sizes.map((option) => (
|
||||
<button
|
||||
type="button"
|
||||
key={option.id}
|
||||
className={size === option.id ? "active" : ""}
|
||||
aria-pressed={size === option.id}
|
||||
onClick={() => onSizeChange(option.id)}
|
||||
>
|
||||
<strong>{option.label}</strong>
|
||||
<small>{option.description}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<fieldset className="widget-content-picker">
|
||||
<legend>
|
||||
<Rows3 size={13} /> Zichtbare inhoud
|
||||
</legend>
|
||||
{sections.map((section) => (
|
||||
<button
|
||||
type="button"
|
||||
key={section.id}
|
||||
className={section.enabled ? "active" : ""}
|
||||
aria-pressed={section.enabled}
|
||||
onClick={() => onSectionChange(section.id, !section.enabled)}
|
||||
>
|
||||
<span className="widget-content-check" aria-hidden="true">
|
||||
{section.enabled && <Check size={12} />}
|
||||
</span>
|
||||
<span>
|
||||
<strong>{section.label}</strong>
|
||||
<small>{section.description}</small>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
EyeOff,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
} from "lucide-react";
|
||||
import type { WidgetSize } from "../../shared/contracts";
|
||||
|
||||
interface WidgetEditToolbarProps {
|
||||
label: string;
|
||||
size: WidgetSize;
|
||||
first: boolean;
|
||||
last: boolean;
|
||||
disabled: boolean;
|
||||
onMove: (direction: -1 | 1) => void;
|
||||
onSizeChange: (size: WidgetSize) => void;
|
||||
onHide: () => void;
|
||||
}
|
||||
|
||||
const sizes: WidgetSize[] = ["compact", "standard", "wide"];
|
||||
|
||||
export function WidgetEditToolbar({
|
||||
label,
|
||||
size,
|
||||
first,
|
||||
last,
|
||||
disabled,
|
||||
onMove,
|
||||
onSizeChange,
|
||||
onHide,
|
||||
}: WidgetEditToolbarProps) {
|
||||
const nextSize =
|
||||
sizes[(sizes.indexOf(size) + 1) % sizes.length] ?? "standard";
|
||||
return (
|
||||
<div
|
||||
className="widget-edit-toolbar"
|
||||
role="toolbar"
|
||||
aria-label={`${label} bewerken`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || first}
|
||||
onClick={() => onMove(-1)}
|
||||
aria-label={`${label} eerder plaatsen`}
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || last}
|
||||
onClick={() => onMove(1)}
|
||||
aria-label={`${label} later plaatsen`}
|
||||
>
|
||||
<ArrowRight size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onSizeChange(nextSize)}
|
||||
aria-label={`${label} wijzigen van ${size} naar ${nextSize}`}
|
||||
>
|
||||
{size === "compact" ? <Maximize2 size={14} /> : <Minimize2 size={14} />}
|
||||
<span>{size}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onHide}
|
||||
aria-label={`${label} verbergen`}
|
||||
>
|
||||
<EyeOff size={14} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Download, Layers3, Play, Plus, Trash2, Upload } from "lucide-react";
|
||||
import { type ChangeEvent, type FormEvent, useRef } from "react";
|
||||
import type { LayoutPreset, LayoutPresetImport } from "../../shared/contracts";
|
||||
import { api } from "../api";
|
||||
|
||||
type Performer = (
|
||||
action: () => Promise<unknown>,
|
||||
message: string,
|
||||
) => Promise<boolean>;
|
||||
|
||||
interface WidgetPresetManagerProps {
|
||||
presets: LayoutPreset[];
|
||||
perform: Performer;
|
||||
}
|
||||
|
||||
export function WidgetPresetManager({
|
||||
presets,
|
||||
perform,
|
||||
}: WidgetPresetManagerProps) {
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const create = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
const name = String(new FormData(form).get("name"));
|
||||
void perform(
|
||||
() => api.createLayoutPreset(name),
|
||||
"Indelingspreset opgeslagen",
|
||||
).then((saved) => saved && form.reset());
|
||||
};
|
||||
const importPreset = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const payload = JSON.parse(await file.text()) as LayoutPresetImport;
|
||||
await perform(
|
||||
() => api.importLayoutPreset(payload),
|
||||
"Indelingspreset geïmporteerd",
|
||||
);
|
||||
} catch {
|
||||
await perform(
|
||||
() =>
|
||||
Promise.reject(
|
||||
new Error("Dit indelingsbestand bevat geen geldige JSON."),
|
||||
),
|
||||
"",
|
||||
);
|
||||
} finally {
|
||||
event.target.value = "";
|
||||
}
|
||||
};
|
||||
return (
|
||||
<section
|
||||
className="widget-presets"
|
||||
aria-labelledby="widget-presets-heading"
|
||||
>
|
||||
<header>
|
||||
<span className="studio-mark">
|
||||
<Layers3 size={18} />
|
||||
</span>
|
||||
<span>
|
||||
<strong id="widget-presets-heading">Indelingspresets</strong>
|
||||
<small>
|
||||
Wissel in één handeling tussen volledige paneelindelingen.
|
||||
</small>
|
||||
</span>
|
||||
</header>
|
||||
<form onSubmit={create} className="preset-create-row">
|
||||
<input
|
||||
name="name"
|
||||
required
|
||||
maxLength={48}
|
||||
placeholder="Ochtend, Media, Onderhoud…"
|
||||
aria-label="Naam van nieuwe indelingspreset"
|
||||
/>
|
||||
<button type="submit" className="primary-button">
|
||||
<Plus size={15} /> Huidige indeling opslaan
|
||||
</button>
|
||||
<button type="button" onClick={() => fileRef.current?.click()}>
|
||||
<Upload size={15} /> Importeren
|
||||
</button>
|
||||
<input
|
||||
name="layoutPresetFile"
|
||||
className="sr-only"
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="application/json"
|
||||
onChange={(event) => void importPreset(event)}
|
||||
/>
|
||||
</form>
|
||||
<div className="preset-list">
|
||||
{presets.length === 0 && (
|
||||
<p>
|
||||
Nog geen presets. Sla de huidige indeling op om je eerste te maken.
|
||||
</p>
|
||||
)}
|
||||
{presets.map((preset) => (
|
||||
<article key={preset.id}>
|
||||
<span>
|
||||
<strong>{preset.name}</strong>
|
||||
<small>
|
||||
{preset.configuration.apps.length} app-panelen · bijgewerkt{" "}
|
||||
{new Date(preset.updatedAt).toLocaleDateString()}
|
||||
</small>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void perform(async () => {
|
||||
await api.applyLayoutPreset(preset.id);
|
||||
await api.sync();
|
||||
}, `Indeling ${preset.name} toegepast`)
|
||||
}
|
||||
>
|
||||
<Play size={14} /> Toepassen
|
||||
</button>
|
||||
<a
|
||||
href={`/api/layout-presets/${encodeURIComponent(preset.id)}/export`}
|
||||
aria-label={`${preset.name} exporteren`}
|
||||
>
|
||||
<Download size={14} /> Exporteren
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="danger-icon"
|
||||
onClick={() =>
|
||||
void perform(
|
||||
() => api.deleteLayoutPreset(preset.id),
|
||||
`${preset.name} verwijderd`,
|
||||
)
|
||||
}
|
||||
aria-label={`${preset.name} verwijderen`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type {
|
||||
WidgetChartType,
|
||||
WidgetValueHistoryPoint,
|
||||
} from "../../shared/contracts";
|
||||
|
||||
interface WidgetSparklineProps {
|
||||
history: WidgetValueHistoryPoint[];
|
||||
chartType: WidgetChartType;
|
||||
sampleLimit: number;
|
||||
label: string;
|
||||
showLegend: boolean;
|
||||
}
|
||||
|
||||
export function WidgetSparkline({
|
||||
history,
|
||||
chartType,
|
||||
sampleLimit,
|
||||
label,
|
||||
showLegend,
|
||||
}: WidgetSparklineProps) {
|
||||
const samples = history
|
||||
.filter((point) => Number.isFinite(point.value))
|
||||
.slice(-sampleLimit);
|
||||
if (samples.length < 2) return null;
|
||||
const values = samples.map((point) => point.value);
|
||||
const minimum = Math.min(...values);
|
||||
const maximum = Math.max(...values);
|
||||
const range = Math.max(0.001, maximum - minimum);
|
||||
const coordinates = values.map((value, index) => ({
|
||||
x: (index / Math.max(1, values.length - 1)) * 100,
|
||||
y: 28 - ((value - minimum) / range) * 24,
|
||||
}));
|
||||
const points = coordinates
|
||||
.map(({ x, y }) => `${x.toFixed(1)},${y.toFixed(1)}`)
|
||||
.join(" ");
|
||||
const barWidth = Math.max(1.5, 82 / values.length);
|
||||
return (
|
||||
<figure
|
||||
className={`widget-sparkline chart-${chartType}`}
|
||||
aria-label={`${label} recent trend`}
|
||||
>
|
||||
<svg viewBox="0 0 100 32" aria-hidden="true">
|
||||
{chartType === "bar" ? (
|
||||
coordinates.map(({ x, y }, index) => (
|
||||
<rect
|
||||
key={`${samples[index]?.sampledAt ?? "sample"}-${index}`}
|
||||
x={Math.max(0, x - barWidth / 2)}
|
||||
y={y}
|
||||
width={barWidth}
|
||||
height={30 - y}
|
||||
rx="1"
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
{chartType === "area" && (
|
||||
<polygon points={`0,30 ${points} 100,30`} />
|
||||
)}
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
{showLegend && (
|
||||
<figcaption>
|
||||
<span>{label}</span>
|
||||
<span>
|
||||
{values.at(-1)?.toLocaleString(undefined, {
|
||||
maximumFractionDigits: 1,
|
||||
})}{" "}
|
||||
· {values.length} samples
|
||||
</span>
|
||||
</figcaption>
|
||||
)}
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
font-family: "Geist Variable", Geist, ui-sans-serif, system-ui, sans-serif;
|
||||
--color-background: #f6f7fb;
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-elevated: #f0f2fa;
|
||||
--color-surface-muted: #e9ebf4;
|
||||
--color-border: #dfe2ee;
|
||||
--color-border-strong: #cbd0df;
|
||||
--color-text: #171a2b;
|
||||
--color-text-muted: #626a80;
|
||||
--color-text-faint: #8a92a8;
|
||||
--color-primary: #5e63df;
|
||||
--color-primary-strong: #4b50c7;
|
||||
--color-primary-soft: #e8e9ff;
|
||||
--color-focus: #6366f1;
|
||||
--color-success: #0d9f72;
|
||||
--color-success-soft: #e2f7ef;
|
||||
--color-warning: #b7791f;
|
||||
--color-warning-soft: #fff2d7;
|
||||
--color-error: #d74747;
|
||||
--color-error-soft: #fde8e8;
|
||||
--color-unknown: #7c879e;
|
||||
--color-hover: #f2f3fa;
|
||||
--color-active: #e8e9ff;
|
||||
--color-disabled: #a8aec0;
|
||||
--shadow-overlay: 0 28px 80px rgb(13 20 43 / 0.18);
|
||||
--shadow-interactive: 0 12px 32px rgb(79 70 229 / 0.08);
|
||||
--radius-control: 8px;
|
||||
--radius-surface: 12px;
|
||||
--radius-panel: 16px;
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
--space-10: 40px;
|
||||
--space-12: 48px;
|
||||
--content-canon: 1280px;
|
||||
|
||||
/* Compatibiliteitsaliases voor bestaande componenten. */
|
||||
--bg: var(--color-background);
|
||||
--surface: var(--color-surface);
|
||||
--surface-raised: var(--color-surface);
|
||||
--surface-muted: var(--color-surface-muted);
|
||||
--text: var(--color-text);
|
||||
--text-soft: var(--color-text-muted);
|
||||
--text-faint: var(--color-text-faint);
|
||||
--border: var(--color-border);
|
||||
--border-strong: var(--color-border-strong);
|
||||
--accent: var(--color-primary);
|
||||
--accent-soft: var(--color-primary-soft);
|
||||
--green: var(--color-success);
|
||||
--amber: var(--color-warning);
|
||||
--shadow: var(--shadow-overlay);
|
||||
--shadow-soft: var(--shadow-interactive);
|
||||
--radius-sm: var(--radius-control);
|
||||
--radius: var(--radius-surface);
|
||||
--radius-lg: var(--radius-panel);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--color-background: #0b1326;
|
||||
--color-surface: #111a30;
|
||||
--color-surface-elevated: #17213a;
|
||||
--color-surface-muted: #202a42;
|
||||
--color-border: #25314b;
|
||||
--color-border-strong: #34415e;
|
||||
--color-text: #eef0ff;
|
||||
--color-text-muted: #a9b1c9;
|
||||
--color-text-faint: #78849f;
|
||||
--color-primary: #818cf8;
|
||||
--color-primary-strong: #9aa2ff;
|
||||
--color-primary-soft: #232c58;
|
||||
--color-focus: #aeb4ff;
|
||||
--color-success: #10b981;
|
||||
--color-success-soft: #0c3b34;
|
||||
--color-warning: #f0b35a;
|
||||
--color-warning-soft: #3c3025;
|
||||
--color-error: #f26b6b;
|
||||
--color-error-soft: #3d232c;
|
||||
--color-unknown: #94a3b8;
|
||||
--color-hover: #17223b;
|
||||
--color-active: #252f5d;
|
||||
--color-disabled: #59647c;
|
||||
--shadow-overlay: 0 32px 90px rgb(1 5 16 / 0.56);
|
||||
--shadow-interactive: 0 12px 36px rgb(99 102 241 / 0.1);
|
||||
}
|
||||
|
||||
:root[data-theme="harbor"] {
|
||||
color-scheme: dark;
|
||||
--color-background: #081923;
|
||||
--color-surface: #0e2430;
|
||||
--color-surface-elevated: #14303e;
|
||||
--color-surface-muted: #1b3b48;
|
||||
--color-border: #28505e;
|
||||
--color-border-strong: #376879;
|
||||
--color-text: #edf8fa;
|
||||
--color-text-muted: #a9c4ca;
|
||||
--color-text-faint: #71939b;
|
||||
--color-primary: #8c96ff;
|
||||
--color-primary-strong: #a8afff;
|
||||
--color-primary-soft: #263156;
|
||||
--color-focus: #aeb4ff;
|
||||
--color-success: #38c997;
|
||||
--color-success-soft: #123e36;
|
||||
--color-warning: #e7b45f;
|
||||
--color-warning-soft: #3b3326;
|
||||
--color-error: #f47b76;
|
||||
--color-error-soft: #44272b;
|
||||
--color-unknown: #8ca2ad;
|
||||
--color-hover: #14303e;
|
||||
--color-active: #28345e;
|
||||
--color-disabled: #5d7580;
|
||||
}
|
||||
|
||||
:root[data-accent="coral"] {
|
||||
--color-primary: #776fe8;
|
||||
--color-primary-soft: color-mix(in srgb, #776fe8 16%, var(--color-surface));
|
||||
}
|
||||
|
||||
:root[data-accent="ocean"] {
|
||||
--color-primary: #4f83df;
|
||||
--color-primary-soft: color-mix(in srgb, #4f83df 16%, var(--color-surface));
|
||||
}
|
||||
|
||||
:root[data-accent="violet"] {
|
||||
--color-primary: #818cf8;
|
||||
--color-primary-soft: color-mix(in srgb, #818cf8 16%, var(--color-surface));
|
||||
}
|
||||
|
||||
:root[data-accent="lime"] {
|
||||
--color-primary: #6f9f78;
|
||||
--color-primary-soft: color-mix(in srgb, #6f9f78 16%, var(--color-surface));
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
export const LAUNCH_HISTORY_STORAGE_KEY = "dockdeck.launch-history.v1";
|
||||
export const MAX_LAUNCH_HISTORY_ENTRIES = 200;
|
||||
export const MAX_LAUNCH_COUNT = 9_999;
|
||||
export const MAX_LAUNCH_ID_LENGTH = 160;
|
||||
|
||||
const MAX_SERIALIZED_LENGTH = 64_000;
|
||||
const MAX_FUTURE_SKEW_MS = 5 * 60 * 1_000;
|
||||
|
||||
export type LaunchKind = "app" | "favorite";
|
||||
export type LaunchKey = `${LaunchKind}:${string}`;
|
||||
export type LaunchHistoryMode = "recent" | "most-used";
|
||||
|
||||
export interface LaunchHistoryEntry {
|
||||
key: LaunchKey;
|
||||
lastOpenedAt: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface LaunchHistoryStorage {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
}
|
||||
|
||||
export function makeLaunchKey(kind: LaunchKind, id: string): LaunchKey {
|
||||
return `${kind}:${id}`;
|
||||
}
|
||||
|
||||
export function isLaunchKey(value: unknown): value is LaunchKey {
|
||||
if (typeof value !== "string") return false;
|
||||
const separator = value.indexOf(":");
|
||||
if (separator < 1) return false;
|
||||
|
||||
const kind = value.slice(0, separator);
|
||||
const id = value.slice(separator + 1);
|
||||
return (
|
||||
(kind === "app" || kind === "favorite") &&
|
||||
id.length > 0 &&
|
||||
id.length <= MAX_LAUNCH_ID_LENGTH
|
||||
);
|
||||
}
|
||||
|
||||
function isBoundedTimestamp(value: unknown, now: number): value is number {
|
||||
return (
|
||||
Number.isSafeInteger(value) &&
|
||||
typeof value === "number" &&
|
||||
value >= 0 &&
|
||||
value <= now + MAX_FUTURE_SKEW_MS
|
||||
);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function hasOnlyEntryFields(value: Record<string, unknown>) {
|
||||
const keys = Object.keys(value);
|
||||
return (
|
||||
keys.length === 3 &&
|
||||
keys.includes("key") &&
|
||||
keys.includes("lastOpenedAt") &&
|
||||
keys.includes("count")
|
||||
);
|
||||
}
|
||||
|
||||
export function parseLaunchHistory(
|
||||
serialized: string | null,
|
||||
now = Date.now(),
|
||||
): LaunchHistoryEntry[] {
|
||||
if (!serialized || serialized.length > MAX_SERIALIZED_LENGTH) return [];
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(serialized);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
|
||||
const entries = new Map<LaunchKey, LaunchHistoryEntry>();
|
||||
for (const item of parsed) {
|
||||
if (!isRecord(item) || !hasOnlyEntryFields(item)) continue;
|
||||
if (!isLaunchKey(item.key)) continue;
|
||||
if (!isBoundedTimestamp(item.lastOpenedAt, now)) continue;
|
||||
if (
|
||||
!Number.isSafeInteger(item.count) ||
|
||||
typeof item.count !== "number" ||
|
||||
item.count < 1
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entry: LaunchHistoryEntry = {
|
||||
key: item.key,
|
||||
lastOpenedAt: item.lastOpenedAt,
|
||||
count: Math.min(item.count, MAX_LAUNCH_COUNT),
|
||||
};
|
||||
const previous = entries.get(entry.key);
|
||||
if (!previous) {
|
||||
entries.set(entry.key, entry);
|
||||
} else if (entry.lastOpenedAt > previous.lastOpenedAt) {
|
||||
entries.set(entry.key, {
|
||||
...entry,
|
||||
count: Math.max(previous.count, entry.count),
|
||||
});
|
||||
} else {
|
||||
entries.set(entry.key, {
|
||||
...previous,
|
||||
count: Math.max(previous.count, entry.count),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [...entries.values()]
|
||||
.sort((left, right) => right.lastOpenedAt - left.lastOpenedAt)
|
||||
.slice(0, MAX_LAUNCH_HISTORY_ENTRIES);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeLaunchHistory(entries: readonly LaunchHistoryEntry[]) {
|
||||
const now = Date.now();
|
||||
return JSON.stringify(
|
||||
entries
|
||||
.filter(
|
||||
(entry) =>
|
||||
isLaunchKey(entry.key) &&
|
||||
isBoundedTimestamp(entry.lastOpenedAt, now) &&
|
||||
Number.isSafeInteger(entry.count) &&
|
||||
entry.count >= 1,
|
||||
)
|
||||
.slice(0, MAX_LAUNCH_HISTORY_ENTRIES)
|
||||
.map(({ key, lastOpenedAt, count }) => ({
|
||||
key,
|
||||
lastOpenedAt,
|
||||
count: Math.min(Math.max(1, count), MAX_LAUNCH_COUNT),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export function loadLaunchHistory(storage: LaunchHistoryStorage) {
|
||||
try {
|
||||
return parseLaunchHistory(storage.getItem(LAUNCH_HISTORY_STORAGE_KEY));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function saveLaunchHistory(
|
||||
storage: LaunchHistoryStorage,
|
||||
entries: readonly LaunchHistoryEntry[],
|
||||
) {
|
||||
try {
|
||||
if (entries.length === 0) {
|
||||
storage.removeItem(LAUNCH_HISTORY_STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
storage.setItem(
|
||||
LAUNCH_HISTORY_STORAGE_KEY,
|
||||
serializeLaunchHistory(entries),
|
||||
);
|
||||
} catch {
|
||||
// Launch history is a best-effort browser convenience.
|
||||
}
|
||||
}
|
||||
|
||||
export function recordLaunchHistory(
|
||||
entries: readonly LaunchHistoryEntry[],
|
||||
key: string,
|
||||
lastOpenedAt = Date.now(),
|
||||
): LaunchHistoryEntry[] {
|
||||
const now = Date.now();
|
||||
if (!isLaunchKey(key) || !isBoundedTimestamp(lastOpenedAt, now)) {
|
||||
return [...entries];
|
||||
}
|
||||
|
||||
const existing = entries.find((entry) => entry.key === key);
|
||||
const existingCount =
|
||||
existing && Number.isSafeInteger(existing.count) && existing.count > 0
|
||||
? Math.min(existing.count, MAX_LAUNCH_COUNT)
|
||||
: 0;
|
||||
return [
|
||||
{
|
||||
key,
|
||||
lastOpenedAt,
|
||||
count: Math.min(existingCount + 1, MAX_LAUNCH_COUNT),
|
||||
},
|
||||
...entries.filter((entry) => entry.key !== key),
|
||||
].slice(0, MAX_LAUNCH_HISTORY_ENTRIES);
|
||||
}
|
||||
|
||||
export function retainAvailableLaunches(
|
||||
entries: readonly LaunchHistoryEntry[],
|
||||
availableKeys: ReadonlySet<string>,
|
||||
) {
|
||||
return entries.filter((entry) => availableKeys.has(entry.key));
|
||||
}
|
||||
|
||||
export function selectLaunchHistory(
|
||||
entries: readonly LaunchHistoryEntry[],
|
||||
availableKeys: ReadonlySet<string>,
|
||||
mode: LaunchHistoryMode,
|
||||
limit = 6,
|
||||
) {
|
||||
const indexed = entries
|
||||
.map((entry, index) => ({ entry, index }))
|
||||
.filter(({ entry }) => availableKeys.has(entry.key));
|
||||
|
||||
indexed.sort((left, right) => {
|
||||
if (mode === "most-used" && left.entry.count !== right.entry.count) {
|
||||
return right.entry.count - left.entry.count;
|
||||
}
|
||||
if (left.entry.lastOpenedAt !== right.entry.lastOpenedAt) {
|
||||
return right.entry.lastOpenedAt - left.entry.lastOpenedAt;
|
||||
}
|
||||
return left.index - right.index;
|
||||
});
|
||||
|
||||
return indexed.slice(0, Math.max(0, limit)).map(({ entry }) => entry);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "@fontsource-variable/geist";
|
||||
import "@fontsource/newsreader/latin-500-italic.css";
|
||||
import { App } from "./App";
|
||||
import "./styles.css";
|
||||
import "./design-tokens.css";
|
||||
import "./canon.css";
|
||||
import "./stitch-direct.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
if (import.meta.env.PROD && "serviceWorker" in navigator) {
|
||||
window.addEventListener("load", () => {
|
||||
void navigator.serviceWorker.register("/sw.js");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { api } from "./api";
|
||||
import { MAX_SORTABLE_ITEMS, sortOrderForPosition } from "./sortable-order";
|
||||
|
||||
export type SortableEntity = "app" | "category" | "favorite";
|
||||
|
||||
export async function persistSortOrder(
|
||||
entity: SortableEntity,
|
||||
items: readonly { id: string }[],
|
||||
) {
|
||||
if (items.length > MAX_SORTABLE_ITEMS) {
|
||||
throw new RangeError(
|
||||
`Sortable order supports up to ${MAX_SORTABLE_ITEMS} items.`,
|
||||
);
|
||||
}
|
||||
const update =
|
||||
entity === "app"
|
||||
? api.updateApp
|
||||
: entity === "category"
|
||||
? api.updateCategory
|
||||
: api.updateFavorite;
|
||||
|
||||
for (let start = 0; start < items.length; start += 8) {
|
||||
const batch = items.slice(start, start + 8);
|
||||
await Promise.all(
|
||||
batch.map((item, batchIndex) =>
|
||||
update(item.id, {
|
||||
sortOrder: sortOrderForPosition(start + batchIndex, items.length),
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export function reorderWithinSlots<T extends { id: string }>(
|
||||
completeOrder: readonly T[],
|
||||
movableIds: readonly string[],
|
||||
reorderedItems: readonly T[],
|
||||
) {
|
||||
const movable = new Set(movableIds);
|
||||
let nextIndex = 0;
|
||||
return completeOrder.map((item) =>
|
||||
movable.has(item.id) ? (reorderedItems[nextIndex++] ?? item) : item,
|
||||
);
|
||||
}
|
||||
|
||||
export function isSortableDropTarget(target: Element, list: Element) {
|
||||
return target.closest(".sortable-list") === list;
|
||||
}
|
||||
|
||||
export const MAX_SORTABLE_ITEMS = 10_001;
|
||||
|
||||
export function sortOrderForPosition(index: number, itemCount: number) {
|
||||
if (
|
||||
!Number.isInteger(itemCount) ||
|
||||
itemCount < 1 ||
|
||||
itemCount > MAX_SORTABLE_ITEMS
|
||||
) {
|
||||
throw new RangeError(
|
||||
`Sortable order supports between 1 and ${MAX_SORTABLE_ITEMS} items.`,
|
||||
);
|
||||
}
|
||||
if (!Number.isInteger(index) || index < 0 || index >= itemCount) {
|
||||
throw new RangeError("Sortable position is outside the item list.");
|
||||
}
|
||||
return itemCount === 1 ? 0 : Math.round((index * 10_000) / (itemCount - 1));
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
LAUNCH_HISTORY_STORAGE_KEY,
|
||||
loadLaunchHistory,
|
||||
recordLaunchHistory,
|
||||
retainAvailableLaunches,
|
||||
saveLaunchHistory,
|
||||
type LaunchHistoryEntry,
|
||||
type LaunchHistoryStorage,
|
||||
} from "./launch-history";
|
||||
|
||||
function getBrowserStorage(): LaunchHistoryStorage | null {
|
||||
try {
|
||||
return typeof window === "undefined" ? null : window.localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function useLaunchHistory() {
|
||||
const storageRef = useRef<LaunchHistoryStorage | null>(getBrowserStorage());
|
||||
const [entries, setEntries] = useState<LaunchHistoryEntry[]>(() =>
|
||||
storageRef.current ? loadLaunchHistory(storageRef.current) : [],
|
||||
);
|
||||
const entriesRef = useRef(entries);
|
||||
|
||||
useEffect(() => {
|
||||
if (storageRef.current) saveLaunchHistory(storageRef.current, entries);
|
||||
}, [entries]);
|
||||
|
||||
useEffect(() => {
|
||||
const storage = storageRef.current;
|
||||
if (!storage || typeof window === "undefined") return;
|
||||
|
||||
const reloadFromOtherTab = (event: StorageEvent) => {
|
||||
if (event.key !== LAUNCH_HISTORY_STORAGE_KEY) return;
|
||||
const next = loadLaunchHistory(storage);
|
||||
entriesRef.current = next;
|
||||
setEntries(next);
|
||||
};
|
||||
window.addEventListener("storage", reloadFromOtherTab);
|
||||
return () => window.removeEventListener("storage", reloadFromOtherTab);
|
||||
}, []);
|
||||
|
||||
const record = useCallback((key: string) => {
|
||||
const next = recordLaunchHistory(entriesRef.current, key);
|
||||
entriesRef.current = next;
|
||||
setEntries(next);
|
||||
}, []);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
entriesRef.current = [];
|
||||
setEntries([]);
|
||||
}, []);
|
||||
|
||||
const retainAvailable = useCallback((availableKeys: ReadonlySet<string>) => {
|
||||
const current = entriesRef.current;
|
||||
const next = retainAvailableLaunches(current, availableKeys);
|
||||
if (next.length === current.length) return;
|
||||
entriesRef.current = next;
|
||||
setEntries(next);
|
||||
}, []);
|
||||
|
||||
return { entries, record, clear, retainAvailable };
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
export function useStoredBoolean(key: string, fallback: () => boolean) {
|
||||
const [value, setValue] = useState(() => {
|
||||
try {
|
||||
const stored = window.localStorage.getItem(key);
|
||||
return stored === null ? fallback() : stored === "true";
|
||||
} catch {
|
||||
return fallback();
|
||||
}
|
||||
});
|
||||
|
||||
const update = useCallback(
|
||||
(next: boolean) => {
|
||||
setValue(next);
|
||||
try {
|
||||
window.localStorage.setItem(key, String(next));
|
||||
} catch {
|
||||
// The preference remains available for this session when storage is blocked.
|
||||
}
|
||||
},
|
||||
[key],
|
||||
);
|
||||
|
||||
return [value, update] as const;
|
||||
}
|
||||
@@ -0,0 +1,611 @@
|
||||
import fastifyStatic from "@fastify/static";
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { basename, dirname, extname, join, resolve } from "node:path";
|
||||
import {
|
||||
appPatchSchema,
|
||||
categoryInputSchema,
|
||||
dashboardProfileImportSchema,
|
||||
dashboardProfileInputSchema,
|
||||
favoriteInputSchema,
|
||||
importSchema,
|
||||
integrationCredentialInputSchema,
|
||||
layoutPresetImportSchema,
|
||||
layoutPresetInputSchema,
|
||||
preferencesPatchSchema,
|
||||
widgetLayoutInputSchema,
|
||||
} from "../shared/contracts.js";
|
||||
import { registerOperatorAuth } from "./auth.js";
|
||||
import { buildInfo } from "./build-info.js";
|
||||
import {
|
||||
DashboardProfileLimitError,
|
||||
DashboardProfileNameConflictError,
|
||||
DockDeckRepository,
|
||||
} from "./database.js";
|
||||
import { DiscoveryService } from "./integrations/discovery.js";
|
||||
import { ProviderWidgetService } from "./integrations/provider-widgets.js";
|
||||
import { WallpaperStore } from "./wallpaper.js";
|
||||
import { WeatherService } from "./weather.js";
|
||||
|
||||
export interface AppOptions {
|
||||
repository?: DockDeckRepository;
|
||||
discovery?: DiscoveryService;
|
||||
serveClient?: boolean;
|
||||
providerWidgets?: ProviderWidgetService;
|
||||
wallpaperStore?: WallpaperStore;
|
||||
weather?: WeatherService;
|
||||
}
|
||||
|
||||
const errorBody = (message: string) => ({ error: message });
|
||||
|
||||
export async function buildApp(
|
||||
options: AppOptions = {},
|
||||
): Promise<FastifyInstance> {
|
||||
const app = Fastify({ logger: process.env.NODE_ENV !== "test" });
|
||||
const repository = options.repository ?? new DockDeckRepository();
|
||||
const discovery = options.discovery ?? new DiscoveryService();
|
||||
const providerWidgets =
|
||||
options.providerWidgets ?? new ProviderWidgetService();
|
||||
const wallpaperStore =
|
||||
options.wallpaperStore ??
|
||||
new WallpaperStore(
|
||||
process.env.WALLPAPER_DIRECTORY ??
|
||||
dirname(resolve(process.env.DATABASE_PATH ?? "./data/dockdeck.db")),
|
||||
);
|
||||
const weather = options.weather ?? new WeatherService();
|
||||
|
||||
registerOperatorAuth(app);
|
||||
|
||||
app.addContentTypeParser(
|
||||
["image/png", "image/jpeg", "image/webp"],
|
||||
{ parseAs: "buffer", bodyLimit: 8 * 1024 * 1024 },
|
||||
(_request, body, done) => done(null, body),
|
||||
);
|
||||
|
||||
const integrationStates = () => [
|
||||
...discovery.getStates(),
|
||||
...providerWidgets.getStates(),
|
||||
];
|
||||
const widgetMetrics = () => {
|
||||
const providers = providerWidgets.getWidgets();
|
||||
return Object.fromEntries(
|
||||
repository
|
||||
.getApps()
|
||||
.filter((item) => item.widgetEnabled)
|
||||
.map((item) => {
|
||||
const metric = discovery.getWidgetMetrics()[item.technicalName];
|
||||
const provider = providers[item.technicalName];
|
||||
const base = metric ?? {
|
||||
statusText:
|
||||
item.status === "online" ? "Container online" : "No live data",
|
||||
cpuPercent: null,
|
||||
memoryUsageBytes: null,
|
||||
memoryLimitBytes: null,
|
||||
networkRxBytes: null,
|
||||
networkTxBytes: null,
|
||||
networkRxBytesPerSecond: null,
|
||||
networkTxBytesPerSecond: null,
|
||||
pids: null,
|
||||
updatedAt: provider?.updatedAt ?? null,
|
||||
freshness: provider?.freshness ?? "unavailable",
|
||||
history: [],
|
||||
};
|
||||
return [
|
||||
item.technicalName,
|
||||
provider
|
||||
? {
|
||||
...base,
|
||||
provider,
|
||||
freshness: provider.freshness,
|
||||
}
|
||||
: base,
|
||||
];
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
app.setErrorHandler((error, _request, reply) => {
|
||||
const cause = error as Error & { statusCode?: number; issues?: unknown };
|
||||
const status = cause.issues ? 400 : (cause.statusCode ?? 500);
|
||||
app.log.error({ err: cause, status }, "Request failed");
|
||||
return reply
|
||||
.status(status)
|
||||
.send(
|
||||
errorBody(
|
||||
status === 500
|
||||
? "Er ging iets mis. Probeer het opnieuw."
|
||||
: cause.message,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
app.get("/api/health", async () => ({
|
||||
status: "ok",
|
||||
...buildInfo,
|
||||
integrations: integrationStates(),
|
||||
}));
|
||||
|
||||
const performDiscoverySync = async () => {
|
||||
const widgetApps = repository
|
||||
.getApps()
|
||||
.filter((item) => item.widgetEnabled)
|
||||
.map((item) => ({
|
||||
technicalName: item.technicalName,
|
||||
refreshIntervalMs: item.widgetRefreshIntervalMs,
|
||||
}));
|
||||
const result = await discovery.discover(
|
||||
repository.getPreferences().externalAccess,
|
||||
widgetApps,
|
||||
);
|
||||
const inventoryIsAuthoritative = result.integrations.some(
|
||||
(integration) =>
|
||||
integration.id === "docker" && integration.state === "connected",
|
||||
);
|
||||
repository.syncDiscovered(result.apps, {
|
||||
removeMissing: inventoryIsAuthoritative,
|
||||
});
|
||||
await providerWidgets.refresh(repository.getApps());
|
||||
return { apps: repository.getApps(), integrations: integrationStates() };
|
||||
};
|
||||
let discoverySync: ReturnType<typeof performDiscoverySync> | null = null;
|
||||
|
||||
app.post("/api/discovery/sync", async () => {
|
||||
discoverySync ??= performDiscoverySync();
|
||||
try {
|
||||
return await discoverySync;
|
||||
} finally {
|
||||
discoverySync = null;
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/dashboard", async () => {
|
||||
const preferences = repository.getPreferences();
|
||||
const serverWidget = discovery.getServerWidget();
|
||||
return {
|
||||
apps: repository.getApps(),
|
||||
categories: repository.getCategories(),
|
||||
favorites: repository.getFavorites(),
|
||||
preferences,
|
||||
integrations: integrationStates(),
|
||||
widgetMetrics: widgetMetrics(),
|
||||
widgetCapabilities: providerWidgets.getCapabilities(repository.getApps()),
|
||||
serverWidget: serverWidget
|
||||
? {
|
||||
...serverWidget,
|
||||
hostName: preferences.serverName,
|
||||
url: preferences.serverUrl,
|
||||
}
|
||||
: null,
|
||||
layoutPresets: repository.getLayoutPresets(),
|
||||
profiles: repository.getDashboardProfileSummaries(),
|
||||
wallpaper: wallpaperStore.describe(),
|
||||
};
|
||||
});
|
||||
|
||||
app.post("/api/widgets/audit", async () => {
|
||||
await providerWidgets.refresh(repository.getApps(), true);
|
||||
return { integrations: integrationStates() };
|
||||
});
|
||||
|
||||
app.post("/api/widgets/enable-all", async () => ({
|
||||
apps: repository.enableAllWidgets(),
|
||||
}));
|
||||
|
||||
app.get("/api/weather", async (_request, reply) => {
|
||||
const preferences = repository.getPreferences();
|
||||
if (!preferences.weatherEnabled) return reply.status(204).send();
|
||||
if (process.env.MOCK_WEATHER === "true")
|
||||
return {
|
||||
location: preferences.weatherLocation,
|
||||
temperatureC: 21.4,
|
||||
weatherCode: 1,
|
||||
isDay: true,
|
||||
summary: "Licht bewolkt",
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
try {
|
||||
return await weather.current(
|
||||
preferences.weatherLocation,
|
||||
preferences.weatherLatitude,
|
||||
preferences.weatherLongitude,
|
||||
);
|
||||
} catch {
|
||||
return reply
|
||||
.status(503)
|
||||
.send(errorBody("Weerdata is tijdelijk niet beschikbaar"));
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/wallpaper", async (_request, reply) => {
|
||||
const file = wallpaperStore.read();
|
||||
if (!file)
|
||||
return reply.status(404).send(errorBody("Geen wallpaper ingesteld"));
|
||||
return reply
|
||||
.header("content-type", file.contentType)
|
||||
.header("cache-control", "public, max-age=3600")
|
||||
.send(file.body);
|
||||
});
|
||||
|
||||
app.put("/api/wallpaper", async (request, reply) => {
|
||||
if (!Buffer.isBuffer(request.body))
|
||||
return reply.status(415).send(errorBody("Gebruik PNG, JPEG of WebP"));
|
||||
return reply.status(201).send(wallpaperStore.save(request.body));
|
||||
});
|
||||
|
||||
app.delete("/api/wallpaper", async (_request, reply) => {
|
||||
wallpaperStore.remove();
|
||||
return reply.status(204).send();
|
||||
});
|
||||
|
||||
app.patch<{ Params: { id: string } }>(
|
||||
"/api/apps/:id",
|
||||
async (request, reply) => {
|
||||
const patch = appPatchSchema.parse(request.body);
|
||||
const current = repository
|
||||
.getApps()
|
||||
.find((item) => item.id === request.params.id);
|
||||
if (!current)
|
||||
return reply.status(404).send(errorBody("App niet gevonden"));
|
||||
const updated = repository.updateApp(request.params.id, patch);
|
||||
return updated;
|
||||
},
|
||||
);
|
||||
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
"/api/apps/:id",
|
||||
async (request, reply) => {
|
||||
if (!repository.removeApp(request.params.id))
|
||||
return reply.status(404).send(errorBody("App niet gevonden"));
|
||||
await providerWidgets.refresh(repository.getApps());
|
||||
return reply.status(204).send();
|
||||
},
|
||||
);
|
||||
|
||||
app.post("/api/categories", async (request, reply) =>
|
||||
reply
|
||||
.status(201)
|
||||
.send(repository.createCategory(categoryInputSchema.parse(request.body))),
|
||||
);
|
||||
app.post("/api/categories/organize", async () =>
|
||||
repository.organizeAppCategories(),
|
||||
);
|
||||
app.patch<{ Params: { id: string } }>(
|
||||
"/api/categories/:id",
|
||||
async (request) =>
|
||||
repository.updateCategory(
|
||||
request.params.id,
|
||||
categoryInputSchema.partial().parse(request.body),
|
||||
),
|
||||
);
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
"/api/categories/:id",
|
||||
async (request, reply) => {
|
||||
repository.deleteCategory(request.params.id);
|
||||
return reply.status(204).send();
|
||||
},
|
||||
);
|
||||
|
||||
app.post("/api/favorites", async (request, reply) =>
|
||||
reply
|
||||
.status(201)
|
||||
.send(repository.createFavorite(favoriteInputSchema.parse(request.body))),
|
||||
);
|
||||
app.patch<{ Params: { id: string } }>("/api/favorites/:id", async (request) =>
|
||||
repository.updateFavorite(
|
||||
request.params.id,
|
||||
favoriteInputSchema.partial().parse(request.body),
|
||||
),
|
||||
);
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
"/api/favorites/:id",
|
||||
async (request, reply) => {
|
||||
repository.deleteFavorite(request.params.id);
|
||||
return reply.status(204).send();
|
||||
},
|
||||
);
|
||||
|
||||
app.patch("/api/preferences", async (request) =>
|
||||
repository.updatePreferences(preferencesPatchSchema.parse(request.body)),
|
||||
);
|
||||
|
||||
app.put("/api/widget-layout", async (request) => {
|
||||
repository.updateWidgetLayout(widgetLayoutInputSchema.parse(request.body));
|
||||
return { saved: true };
|
||||
});
|
||||
|
||||
app.post("/api/layout-presets", async (request, reply) =>
|
||||
reply
|
||||
.status(201)
|
||||
.send(
|
||||
repository.createLayoutPreset(
|
||||
layoutPresetInputSchema.parse(request.body),
|
||||
),
|
||||
),
|
||||
);
|
||||
app.post<{ Params: { id: string } }>(
|
||||
"/api/layout-presets/:id/apply",
|
||||
async (request, reply) => {
|
||||
if (!repository.applyLayoutPreset(request.params.id))
|
||||
return reply.status(404).send(errorBody("Layout preset not found"));
|
||||
return { applied: true };
|
||||
},
|
||||
);
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
"/api/layout-presets/:id",
|
||||
async (request, reply) => {
|
||||
if (!repository.deleteLayoutPreset(request.params.id))
|
||||
return reply.status(404).send(errorBody("Layout preset not found"));
|
||||
return reply.status(204).send();
|
||||
},
|
||||
);
|
||||
app.get<{ Params: { id: string } }>(
|
||||
"/api/layout-presets/:id/export",
|
||||
async (request, reply) => {
|
||||
const preset = repository
|
||||
.getLayoutPresets()
|
||||
.find((item) => item.id === request.params.id);
|
||||
if (!preset)
|
||||
return reply.status(404).send(errorBody("Layout preset not found"));
|
||||
reply.header(
|
||||
"content-disposition",
|
||||
`attachment; filename="dockdeck-layout-${preset.name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}.json"`,
|
||||
);
|
||||
return {
|
||||
version: 1,
|
||||
name: preset.name,
|
||||
configuration: preset.configuration,
|
||||
};
|
||||
},
|
||||
);
|
||||
app.post("/api/layout-presets/import", async (request, reply) =>
|
||||
reply
|
||||
.status(201)
|
||||
.send(
|
||||
repository.importLayoutPreset(
|
||||
layoutPresetImportSchema.parse(request.body),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
app.get("/api/profiles", async () => repository.getDashboardProfiles());
|
||||
app.post("/api/profiles", async (request, reply) => {
|
||||
try {
|
||||
return reply
|
||||
.status(201)
|
||||
.send(
|
||||
repository.createDashboardProfile(
|
||||
dashboardProfileInputSchema.parse(request.body),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof DashboardProfileNameConflictError)
|
||||
return reply
|
||||
.status(409)
|
||||
.send(errorBody("A dashboard profile with this name already exists"));
|
||||
if (error instanceof DashboardProfileLimitError)
|
||||
return reply
|
||||
.status(409)
|
||||
.send(errorBody("The dashboard profile limit has been reached"));
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
app.get<{ Params: { id: string } }>(
|
||||
"/api/profiles/:id",
|
||||
async (request, reply) => {
|
||||
const profile = repository.getDashboardProfile(request.params.id);
|
||||
if (!profile)
|
||||
return reply.status(404).send(errorBody("Dashboard profile not found"));
|
||||
return profile;
|
||||
},
|
||||
);
|
||||
app.patch<{ Params: { id: string } }>(
|
||||
"/api/profiles/:id",
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const profile = repository.updateDashboardProfile(
|
||||
request.params.id,
|
||||
dashboardProfileInputSchema.parse(request.body),
|
||||
);
|
||||
if (!profile)
|
||||
return reply
|
||||
.status(404)
|
||||
.send(errorBody("Dashboard profile not found"));
|
||||
return profile;
|
||||
} catch (error) {
|
||||
if (error instanceof DashboardProfileNameConflictError)
|
||||
return reply
|
||||
.status(409)
|
||||
.send(
|
||||
errorBody("A dashboard profile with this name already exists"),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
"/api/profiles/:id",
|
||||
async (request, reply) => {
|
||||
if (!repository.deleteDashboardProfile(request.params.id))
|
||||
return reply.status(404).send(errorBody("Dashboard profile not found"));
|
||||
return reply.status(204).send();
|
||||
},
|
||||
);
|
||||
app.post<{ Params: { id: string } }>(
|
||||
"/api/profiles/:id/apply",
|
||||
async (request, reply) => {
|
||||
if (!repository.applyDashboardProfile(request.params.id))
|
||||
return reply
|
||||
.status(404)
|
||||
.send(errorBody("Dashboard profile not found or invalid"));
|
||||
return { applied: true };
|
||||
},
|
||||
);
|
||||
app.get<{ Params: { id: string } }>(
|
||||
"/api/profiles/:id/export",
|
||||
async (request, reply) => {
|
||||
const profile = repository.getDashboardProfile(request.params.id);
|
||||
if (!profile)
|
||||
return reply.status(404).send(errorBody("Dashboard profile not found"));
|
||||
reply.header(
|
||||
"content-disposition",
|
||||
`attachment; filename="dockdeck-profile-${profile.name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}.json"`,
|
||||
);
|
||||
return {
|
||||
version: 1,
|
||||
name: profile.name,
|
||||
configuration: profile.configuration,
|
||||
};
|
||||
},
|
||||
);
|
||||
app.post("/api/profiles/import", async (request, reply) => {
|
||||
try {
|
||||
return reply
|
||||
.status(201)
|
||||
.send(
|
||||
repository.importDashboardProfile(
|
||||
dashboardProfileImportSchema.parse(request.body),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof DashboardProfileNameConflictError)
|
||||
return reply
|
||||
.status(409)
|
||||
.send(errorBody("A dashboard profile with this name already exists"));
|
||||
if (error instanceof DashboardProfileLimitError)
|
||||
return reply
|
||||
.status(409)
|
||||
.send(errorBody("The dashboard profile limit has been reached"));
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/diagnostics", async () => {
|
||||
const metrics = Object.values(widgetMetrics());
|
||||
const integrations = integrationStates();
|
||||
return {
|
||||
status: integrations.some((item) => item.state === "degraded")
|
||||
? "degraded"
|
||||
: "healthy",
|
||||
generatedAt: new Date().toISOString(),
|
||||
version: buildInfo.version,
|
||||
database: "connected",
|
||||
enabledWidgets: repository.getApps().filter((item) => item.widgetEnabled)
|
||||
.length,
|
||||
staleWidgets: metrics.filter((metric) => metric.freshness === "stale")
|
||||
.length,
|
||||
unavailableWidgets: metrics.filter(
|
||||
(metric) => metric.freshness === "unavailable",
|
||||
).length,
|
||||
integrations: integrations.map(({ id, label, state, checkedAt }) => ({
|
||||
id,
|
||||
label,
|
||||
state,
|
||||
checkedAt,
|
||||
})),
|
||||
unraidNative: integrations.some(
|
||||
(item) => item.id === "unraid-native" && item.state === "connected",
|
||||
)
|
||||
? "connected"
|
||||
: "unavailable",
|
||||
pwa: "enabled",
|
||||
};
|
||||
});
|
||||
|
||||
app.put<{ Params: { id: string } }>(
|
||||
"/api/integrations/:id/credentials",
|
||||
async (request, reply) => {
|
||||
const origin = request.headers.origin;
|
||||
if (origin) {
|
||||
try {
|
||||
if (new URL(origin).host !== request.headers.host)
|
||||
return reply.status(403).send(errorBody("Origin not allowed"));
|
||||
} catch {
|
||||
return reply.status(403).send(errorBody("Origin not allowed"));
|
||||
}
|
||||
}
|
||||
const parsed = integrationCredentialInputSchema.safeParse(request.body);
|
||||
if (!parsed.success)
|
||||
return reply
|
||||
.status(400)
|
||||
.send(errorBody("Credential fields are invalid"));
|
||||
const target = repository
|
||||
.getApps()
|
||||
.find((item) => `widget-${item.technicalName}` === request.params.id);
|
||||
if (!target)
|
||||
return reply.status(404).send(errorBody("Integration not found"));
|
||||
try {
|
||||
providerWidgets.configure(target, parsed.data.values);
|
||||
} catch {
|
||||
return reply
|
||||
.status(400)
|
||||
.send(errorBody("Credential configuration is invalid"));
|
||||
}
|
||||
await providerWidgets.refresh(repository.getApps());
|
||||
return { saved: true };
|
||||
},
|
||||
);
|
||||
|
||||
app.get("/api/export", async (_request, reply) => {
|
||||
reply.header(
|
||||
"content-disposition",
|
||||
`attachment; filename="dockdeck-backup-${new Date().toISOString().slice(0, 10)}.json"`,
|
||||
);
|
||||
return repository.export();
|
||||
});
|
||||
app.post("/api/import", async (request) => {
|
||||
repository.import(importSchema.parse(request.body));
|
||||
return { imported: true };
|
||||
});
|
||||
|
||||
const unraidIconsPath = resolve(
|
||||
process.env.UNRAID_ICONS_PATH ?? "/unraid-icons",
|
||||
);
|
||||
const iconFiles = existsSync(unraidIconsPath)
|
||||
? new Map(
|
||||
readdirSync(unraidIconsPath).map((name) => [
|
||||
name.toLocaleLowerCase(),
|
||||
name,
|
||||
]),
|
||||
)
|
||||
: new Map<string, string>();
|
||||
const iconMimeTypes: Record<string, string> = {
|
||||
".gif": "image/gif",
|
||||
".ico": "image/x-icon",
|
||||
".jpeg": "image/jpeg",
|
||||
".jpg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".svg": "image/svg+xml",
|
||||
".webp": "image/webp",
|
||||
};
|
||||
app.get<{ Params: { name: string } }>(
|
||||
"/api/icons/:name",
|
||||
(request, reply) => {
|
||||
const requestedName = request.params.name;
|
||||
if (basename(requestedName) !== requestedName)
|
||||
return reply.status(404).send(errorBody("Icoon niet gevonden"));
|
||||
const fileName = iconFiles.get(requestedName.toLocaleLowerCase());
|
||||
const mimeType = fileName
|
||||
? iconMimeTypes[extname(fileName).toLocaleLowerCase()]
|
||||
: undefined;
|
||||
if (!fileName || !mimeType)
|
||||
return reply.status(404).send(errorBody("Icoon niet gevonden"));
|
||||
return reply
|
||||
.type(mimeType)
|
||||
.header("cache-control", "public, max-age=86400, immutable")
|
||||
.send(readFileSync(join(unraidIconsPath, fileName)));
|
||||
},
|
||||
);
|
||||
|
||||
const clientPath = resolve("dist/client");
|
||||
if (options.serveClient !== false && existsSync(clientPath)) {
|
||||
await app.register(fastifyStatic, { root: clientPath, wildcard: false });
|
||||
app.setNotFoundHandler((request, reply) => {
|
||||
if (request.url.startsWith("/api/"))
|
||||
return reply.status(404).send(errorBody("Endpoint niet gevonden"));
|
||||
return reply.sendFile("index.html");
|
||||
});
|
||||
}
|
||||
|
||||
app.addHook("onClose", async () => repository.close());
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { scrypt, timingSafeEqual } from "node:crypto";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
|
||||
const USERNAME_COMPARE_LENGTH = 64;
|
||||
const MAXIMUM_AUTHORIZATION_LENGTH = 4096;
|
||||
const MAXIMUM_PASSWORD_LENGTH = 1024;
|
||||
const MAXIMUM_CONCURRENT_CHECKS = 4;
|
||||
const MAXIMUM_FAILURES_PER_MINUTE = 10;
|
||||
|
||||
function timingSafeStringEqual(a: string, b: string): boolean {
|
||||
const bufA = Buffer.alloc(USERNAME_COMPARE_LENGTH);
|
||||
const bufB = Buffer.alloc(USERNAME_COMPARE_LENGTH);
|
||||
bufA.write(a.slice(0, USERNAME_COMPARE_LENGTH));
|
||||
bufB.write(b.slice(0, USERNAME_COMPARE_LENGTH));
|
||||
return timingSafeEqual(bufA, bufB) && a === b;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional operator authentication. Off by default (no env vars set) so
|
||||
* existing deployments are unaffected until the operator opts in - see
|
||||
* README for setup. When enabled, every request must present HTTP Basic
|
||||
* credentials matching DOCKDECK_OPERATOR_USER and a scrypt hash of the
|
||||
* password (DOCKDECK_OPERATOR_PASSWORD_HASH), computed with
|
||||
* `scripts/hash-operator-password.mjs`. Never compares or stores plaintext.
|
||||
*/
|
||||
export function registerOperatorAuth(app: FastifyInstance): void {
|
||||
const configuredUser = process.env.DOCKDECK_OPERATOR_USER;
|
||||
const configuredHash = process.env.DOCKDECK_OPERATOR_PASSWORD_HASH;
|
||||
|
||||
if (Boolean(configuredUser) !== Boolean(configuredHash)) {
|
||||
throw new Error(
|
||||
"DOCKDECK_OPERATOR_USER and DOCKDECK_OPERATOR_PASSWORD_HASH must be set together",
|
||||
);
|
||||
}
|
||||
if (!configuredUser || !configuredHash) {
|
||||
app.log.warn(
|
||||
"DockDeck operator authentication is disabled (DOCKDECK_OPERATOR_USER / " +
|
||||
"DOCKDECK_OPERATOR_PASSWORD_HASH not set). This dashboard holds read " +
|
||||
"credentials for other services - set both env vars to require login.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = configuredHash.split(":");
|
||||
const [salt, expectedHex] = parts;
|
||||
if (
|
||||
parts.length !== 2 ||
|
||||
!salt ||
|
||||
!expectedHex ||
|
||||
!/^[0-9a-f]{32}$/i.test(salt) ||
|
||||
!/^[0-9a-f]{64}$/i.test(expectedHex)
|
||||
) {
|
||||
throw new Error(
|
||||
"DOCKDECK_OPERATOR_PASSWORD_HASH must be a 16-byte salt and 32-byte hash in 'salt:hash' hexadecimal form, as " +
|
||||
"produced by scripts/hash-operator-password.mjs",
|
||||
);
|
||||
}
|
||||
const expected = Buffer.from(expectedHex, "hex");
|
||||
const failures = new Map<string, { count: number; resetAt: number }>();
|
||||
let activeChecks = 0;
|
||||
|
||||
const derive = (password: string) =>
|
||||
new Promise<Buffer>((resolve, reject) => {
|
||||
scrypt(password, salt, expected.length, (error, derivedKey) => {
|
||||
if (error) reject(error);
|
||||
else resolve(derivedKey);
|
||||
});
|
||||
});
|
||||
|
||||
app.addHook("onRequest", async (request, reply) => {
|
||||
if (request.url === "/api/health") return;
|
||||
|
||||
const header = request.headers.authorization;
|
||||
const currentFailure = failures.get(request.ip);
|
||||
if (currentFailure && currentFailure.resetAt <= Date.now())
|
||||
failures.delete(request.ip);
|
||||
if ((failures.get(request.ip)?.count ?? 0) >= MAXIMUM_FAILURES_PER_MINUTE) {
|
||||
return reply
|
||||
.code(429)
|
||||
.send({ error: "Too many authentication attempts" });
|
||||
}
|
||||
if (
|
||||
!header ||
|
||||
header.length > MAXIMUM_AUTHORIZATION_LENGTH ||
|
||||
!header.startsWith("Basic ")
|
||||
) {
|
||||
return reply
|
||||
.code(401)
|
||||
.header("WWW-Authenticate", 'Basic realm="DockDeck", charset="UTF-8"')
|
||||
.send({ error: "Authentication required" });
|
||||
}
|
||||
|
||||
const credentials = header.startsWith("Basic ")
|
||||
? Buffer.from(header.slice(6), "base64").toString("utf8")
|
||||
: null;
|
||||
const separatorIndex = credentials?.indexOf(":") ?? -1;
|
||||
const user =
|
||||
separatorIndex >= 0 ? credentials!.slice(0, separatorIndex) : null;
|
||||
const password =
|
||||
separatorIndex >= 0 ? credentials!.slice(separatorIndex + 1) : null;
|
||||
|
||||
const userMatches =
|
||||
user !== null && timingSafeStringEqual(user, configuredUser);
|
||||
if (password === null || password.length > MAXIMUM_PASSWORD_LENGTH)
|
||||
return reply
|
||||
.code(401)
|
||||
.header("WWW-Authenticate", 'Basic realm="DockDeck", charset="UTF-8"')
|
||||
.send({ error: "Authentication required" });
|
||||
if (activeChecks >= MAXIMUM_CONCURRENT_CHECKS)
|
||||
return reply.code(429).send({ error: "Authentication service is busy" });
|
||||
|
||||
let passwordMatches = false;
|
||||
activeChecks += 1;
|
||||
try {
|
||||
const candidate = await derive(password);
|
||||
passwordMatches = timingSafeEqual(candidate, expected);
|
||||
} finally {
|
||||
activeChecks -= 1;
|
||||
}
|
||||
|
||||
if (!userMatches || !passwordMatches) {
|
||||
const previous = failures.get(request.ip);
|
||||
failures.set(request.ip, {
|
||||
count: (previous?.count ?? 0) + 1,
|
||||
resetAt: previous?.resetAt ?? Date.now() + 60_000,
|
||||
});
|
||||
return reply
|
||||
.code(401)
|
||||
.header("WWW-Authenticate", 'Basic realm="DockDeck", charset="UTF-8"')
|
||||
.send({ error: "Authentication required" });
|
||||
}
|
||||
failures.delete(request.ip);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export interface BuildInfo {
|
||||
version: string;
|
||||
revision: string;
|
||||
shortRevision: string;
|
||||
buildDate: string;
|
||||
}
|
||||
|
||||
declare const __DOCKDECK_BUILD_INFO__: BuildInfo;
|
||||
|
||||
const developmentBuildInfo: BuildInfo = {
|
||||
version: process.env.npm_package_version ?? "0.1.0",
|
||||
revision: "development",
|
||||
shortRevision: "dev",
|
||||
buildDate: "1970-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
export const buildInfo: BuildInfo =
|
||||
typeof __DOCKDECK_BUILD_INFO__ === "undefined"
|
||||
? developmentBuildInfo
|
||||
: __DOCKDECK_BUILD_INFO__;
|
||||
@@ -0,0 +1,18 @@
|
||||
export async function mapWithConcurrency<T, R>(
|
||||
values: T[],
|
||||
maximumConcurrency: number,
|
||||
mapper: (value: T, index: number) => Promise<R>,
|
||||
): Promise<R[]> {
|
||||
const results = new Array<R>(values.length);
|
||||
let nextIndex = 0;
|
||||
const worker = async () => {
|
||||
while (nextIndex < values.length) {
|
||||
const index = nextIndex++;
|
||||
results[index] = await mapper(values[index]!, index);
|
||||
}
|
||||
};
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(maximumConcurrency, values.length) }, worker),
|
||||
);
|
||||
return results;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
const DEFAULT_MAXIMUM_BYTES = 1024 * 1024;
|
||||
|
||||
export async function readBoundedJsonResponse<T>(
|
||||
response: Response,
|
||||
maximumBytes = DEFAULT_MAXIMUM_BYTES,
|
||||
): Promise<T> {
|
||||
const declaredLength = Number(response.headers.get("content-length"));
|
||||
if (Number.isFinite(declaredLength) && declaredLength > maximumBytes)
|
||||
throw new Error("Integration response is too large");
|
||||
|
||||
if (!response.body) throw new Error("Integration returned an empty response");
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let received = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
received += value.byteLength;
|
||||
if (received > maximumBytes) {
|
||||
await reader.cancel();
|
||||
throw new Error("Integration response is too large");
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
const body = Buffer.concat(chunks, received).toString("utf8");
|
||||
return JSON.parse(body) as T;
|
||||
}
|
||||
|
||||
export async function fetchBoundedJson<T>(
|
||||
url: string,
|
||||
init: RequestInit = {},
|
||||
timeoutMs = 4_000,
|
||||
maximumBytes = DEFAULT_MAXIMUM_BYTES,
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<T> {
|
||||
const response = await fetcher(url, {
|
||||
...init,
|
||||
redirect: "error",
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new Error(`Integration returned HTTP ${response.status}`);
|
||||
return readBoundedJsonResponse<T>(response, maximumBytes);
|
||||
}
|
||||
|
||||
export async function fetchBoundedOk(
|
||||
url: string,
|
||||
init: RequestInit = {},
|
||||
timeoutMs = 4_000,
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<void> {
|
||||
const response = await fetcher(url, {
|
||||
...init,
|
||||
redirect: "error",
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new Error(`Integration returned HTTP ${response.status}`);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { buildApp } from "./app.js";
|
||||
|
||||
const host = process.env.HOST ?? "127.0.0.1";
|
||||
const port = Number(process.env.PORT ?? 3000);
|
||||
const app = await buildApp();
|
||||
|
||||
try {
|
||||
await app.listen({ host, port });
|
||||
if (process.env.MOCK_DISCOVERY === "true") {
|
||||
await app.inject({ method: "POST", url: "/api/discovery/sync" });
|
||||
}
|
||||
} catch (error) {
|
||||
app.log.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
const validKey = /^[A-Z][A-Z0-9_]*$/;
|
||||
|
||||
export class IntegrationEnvironmentStore {
|
||||
private readonly values = new Map<string, string>();
|
||||
|
||||
constructor(
|
||||
private readonly path = process.env.NODE_ENV === "test"
|
||||
? undefined
|
||||
: (process.env.INTEGRATION_ENV_PATH ?? "./data/integrations.env"),
|
||||
) {
|
||||
this.load();
|
||||
}
|
||||
|
||||
private load() {
|
||||
if (!this.path || !existsSync(this.path)) return;
|
||||
for (const line of readFileSync(this.path, "utf8").split(/\r?\n/)) {
|
||||
const separator = line.indexOf("=");
|
||||
if (separator < 1) continue;
|
||||
const key = line.slice(0, separator);
|
||||
if (!validKey.test(key)) continue;
|
||||
try {
|
||||
const value = JSON.parse(line.slice(separator + 1)) as unknown;
|
||||
if (typeof value !== "string") continue;
|
||||
this.values.set(key, value);
|
||||
process.env[key] = value;
|
||||
} catch {
|
||||
// Ignore malformed lines without exposing their potentially sensitive contents.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
set(values: Record<string, string>) {
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (!validKey.test(key)) throw new Error("Invalid environment key");
|
||||
this.values.set(key, value);
|
||||
process.env[key] = value;
|
||||
}
|
||||
this.persist();
|
||||
}
|
||||
|
||||
clear(keys: string[]) {
|
||||
this.set(Object.fromEntries(keys.map((key) => [key, ""])));
|
||||
}
|
||||
|
||||
private persist() {
|
||||
if (!this.path) return;
|
||||
mkdirSync(dirname(this.path), { recursive: true });
|
||||
const temporaryPath = `${this.path}.tmp`;
|
||||
const body = [...this.values.entries()]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, value]) => `${key}=${JSON.stringify(value)}`)
|
||||
.join("\n");
|
||||
writeFileSync(temporaryPath, `${body}\n`, {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
});
|
||||
chmodSync(temporaryPath, 0o600);
|
||||
renameSync(temporaryPath, this.path);
|
||||
chmodSync(this.path, 0o600);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,879 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type {
|
||||
IntegrationState,
|
||||
ServerWidgetMetric,
|
||||
WidgetMetric,
|
||||
} from "../../shared/contracts.js";
|
||||
import {
|
||||
matchNpmHost,
|
||||
normalizeUrl,
|
||||
resolveContainerHost,
|
||||
resolveUnraidIcon,
|
||||
resolveUnraidWebUi,
|
||||
} from "../../shared/domain.js";
|
||||
import { inferPersonalProject } from "../../shared/personal-project.js";
|
||||
import type { DiscoveredApp } from "../database.js";
|
||||
import { mapWithConcurrency } from "../concurrency.js";
|
||||
import { fetchBoundedJson } from "../http.js";
|
||||
import { UnraidStatusService } from "./unraid-status.js";
|
||||
|
||||
interface DockerContainer {
|
||||
Id: string;
|
||||
Names: string[];
|
||||
Image: string;
|
||||
State: string;
|
||||
Status: string;
|
||||
Ports: { PrivatePort: number; PublicPort?: number; Type?: string }[];
|
||||
Labels?: Record<string, string>;
|
||||
NetworkSettings?: {
|
||||
Networks?: Record<string, { IPAddress?: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
interface DockerStats {
|
||||
cpu_stats?: {
|
||||
cpu_usage?: { total_usage?: number; percpu_usage?: number[] };
|
||||
system_cpu_usage?: number;
|
||||
online_cpus?: number;
|
||||
};
|
||||
precpu_stats?: {
|
||||
cpu_usage?: { total_usage?: number };
|
||||
system_cpu_usage?: number;
|
||||
};
|
||||
memory_stats?: {
|
||||
usage?: number;
|
||||
limit?: number;
|
||||
stats?: { cache?: number; inactive_file?: number };
|
||||
};
|
||||
networks?: Record<string, { rx_bytes?: number; tx_bytes?: number }>;
|
||||
pids_stats?: { current?: number };
|
||||
}
|
||||
|
||||
interface DockerInfo {
|
||||
Name?: string;
|
||||
OperatingSystem?: string;
|
||||
Architecture?: string;
|
||||
ServerVersion?: string;
|
||||
NCPU?: number;
|
||||
MemTotal?: number;
|
||||
Containers?: number;
|
||||
ContainersRunning?: number;
|
||||
ContainersPaused?: number;
|
||||
ContainersStopped?: number;
|
||||
Images?: number;
|
||||
}
|
||||
|
||||
interface AppOpsContainer {
|
||||
id: string;
|
||||
name: string;
|
||||
state: string;
|
||||
health?: string;
|
||||
image: string;
|
||||
ports?: string[];
|
||||
cpu?: string;
|
||||
ram?: string;
|
||||
metricsStatus?: string;
|
||||
}
|
||||
|
||||
const MAXIMUM_INVENTORY_ITEMS = 500;
|
||||
|
||||
interface TemplateInfo {
|
||||
name: string;
|
||||
webUi: string | null;
|
||||
icon: string | null;
|
||||
}
|
||||
|
||||
export interface DiscoveryResult {
|
||||
apps: DiscoveredApp[];
|
||||
integrations: IntegrationState[];
|
||||
}
|
||||
|
||||
export interface WidgetRequest {
|
||||
technicalName: string;
|
||||
refreshIntervalMs: number;
|
||||
}
|
||||
|
||||
type ClassifiedDiscoveredApp = DiscoveredApp & { personalProject: boolean };
|
||||
|
||||
const checkedAt = () => new Date().toISOString();
|
||||
|
||||
function state(
|
||||
id: IntegrationState["id"],
|
||||
label: string,
|
||||
value: IntegrationState["state"],
|
||||
message: string,
|
||||
): IntegrationState {
|
||||
return { id, label, state: value, message, checkedAt: checkedAt() };
|
||||
}
|
||||
|
||||
export function parseUnraidTemplate(xml: string): TemplateInfo {
|
||||
const pick = (tag: string) => {
|
||||
const match = xml.match(
|
||||
new RegExp(
|
||||
`<${tag}[^>]*>(?:<!\\[CDATA\\[)?([\\s\\S]*?)(?:\\]\\]>)?</${tag}>`,
|
||||
"i",
|
||||
),
|
||||
);
|
||||
return match?.[1]?.trim() || null;
|
||||
};
|
||||
return { name: pick("Name") ?? "", webUi: pick("WebUI"), icon: pick("Icon") };
|
||||
}
|
||||
|
||||
function readTemplates(path: string | undefined): Map<string, TemplateInfo> {
|
||||
const templates = new Map<string, TemplateInfo>();
|
||||
if (!path || !existsSync(path)) return templates;
|
||||
for (const file of readdirSync(path).filter((name) =>
|
||||
name.endsWith(".xml"),
|
||||
)) {
|
||||
const parsed = parseUnraidTemplate(readFileSync(join(path, file), "utf8"));
|
||||
if (parsed.name) templates.set(parsed.name.toLowerCase(), parsed);
|
||||
}
|
||||
return templates;
|
||||
}
|
||||
|
||||
async function fetchJson<T>(
|
||||
url: string,
|
||||
init?: RequestInit,
|
||||
timeoutMs = 4_000,
|
||||
): Promise<T> {
|
||||
return fetchBoundedJson<T>(url, init, timeoutMs, 2 * 1024 * 1024);
|
||||
}
|
||||
|
||||
function finite(value: number | undefined): number | null {
|
||||
return Number.isFinite(value) ? (value ?? null) : null;
|
||||
}
|
||||
|
||||
function bytesFromHuman(value: string | undefined): number | null {
|
||||
const match = value?.trim().match(/^([\d.]+)\s*([kmgt]i?b)$/i);
|
||||
if (!match?.[1] || !match[2]) return null;
|
||||
const amount = Number(match[1]);
|
||||
if (!Number.isFinite(amount)) return null;
|
||||
const power = { kb: 1, kib: 1, mb: 2, mib: 2, gb: 3, gib: 3, tb: 4, tib: 4 }[
|
||||
match[2].toLowerCase()
|
||||
];
|
||||
return power ? amount * 1024 ** power : null;
|
||||
}
|
||||
|
||||
export function appOpsContainerToDocker(
|
||||
item: AppOpsContainer,
|
||||
): DockerContainer {
|
||||
const runtimeState = item.state.trim().toLocaleLowerCase();
|
||||
const ports = [...new Set(item.ports ?? [])].flatMap((value) => {
|
||||
const match = value.match(/(\d+):(\d+)(?:\/(tcp|udp))?$/i);
|
||||
if (!match?.[1] || !match[2]) return [];
|
||||
return [
|
||||
{
|
||||
PublicPort: Number(match[1]),
|
||||
PrivatePort: Number(match[2]),
|
||||
Type: match[3]?.toLowerCase() ?? "tcp",
|
||||
},
|
||||
];
|
||||
});
|
||||
return {
|
||||
Id: item.id,
|
||||
Names: [`/${item.name}`],
|
||||
Image: item.image,
|
||||
State: runtimeState,
|
||||
Status: [item.state, item.health].filter(Boolean).join(" · ") || "unknown",
|
||||
Ports: ports,
|
||||
};
|
||||
}
|
||||
|
||||
export function metricFromAppOps(item: AppOpsContainer): WidgetMetric {
|
||||
const [used, limit] = (item.ram ?? "")
|
||||
.split("/")
|
||||
.map((value) => value.trim());
|
||||
const cpu = Number.parseFloat(item.cpu ?? "");
|
||||
const measured = item.metricsStatus === "measured";
|
||||
return {
|
||||
statusText:
|
||||
[item.state, item.health].filter(Boolean).join(" · ") || "unknown",
|
||||
cpuPercent: measured && Number.isFinite(cpu) ? cpu : null,
|
||||
memoryUsageBytes: measured ? bytesFromHuman(used) : null,
|
||||
memoryLimitBytes: measured ? bytesFromHuman(limit) : null,
|
||||
networkRxBytes: null,
|
||||
networkTxBytes: null,
|
||||
networkRxBytesPerSecond: null,
|
||||
networkTxBytesPerSecond: null,
|
||||
pids: null,
|
||||
updatedAt: measured ? checkedAt() : null,
|
||||
freshness: measured ? "fresh" : "unavailable",
|
||||
history: [],
|
||||
};
|
||||
}
|
||||
|
||||
function readHostCapacity(): { cpuCount: number; totalMemoryBytes: number } {
|
||||
let cpuCount = 0;
|
||||
let totalMemoryBytes = 0;
|
||||
try {
|
||||
const cpuInfo = readFileSync(
|
||||
process.env.UNRAID_CPUINFO_PATH ?? "/host-proc/cpuinfo",
|
||||
"utf8",
|
||||
);
|
||||
cpuCount = [...cpuInfo.matchAll(/^processor\s*:/gm)].length;
|
||||
} catch {
|
||||
// Host capacity is optional; inventory and navigation remain available.
|
||||
}
|
||||
try {
|
||||
const memoryInfo = readFileSync(
|
||||
process.env.UNRAID_MEMINFO_PATH ?? "/host-proc/meminfo",
|
||||
"utf8",
|
||||
);
|
||||
const totalKib = Number(memoryInfo.match(/^MemTotal:\s+(\d+)\s+kB/im)?.[1]);
|
||||
if (Number.isFinite(totalKib)) totalMemoryBytes = totalKib * 1024;
|
||||
} catch {
|
||||
// Host capacity is optional; native array data remains available.
|
||||
}
|
||||
return { cpuCount, totalMemoryBytes };
|
||||
}
|
||||
|
||||
function metricFromStats(statusText: string, stats: DockerStats): WidgetMetric {
|
||||
const cpuDelta =
|
||||
(stats.cpu_stats?.cpu_usage?.total_usage ?? 0) -
|
||||
(stats.precpu_stats?.cpu_usage?.total_usage ?? 0);
|
||||
const systemDelta =
|
||||
(stats.cpu_stats?.system_cpu_usage ?? 0) -
|
||||
(stats.precpu_stats?.system_cpu_usage ?? 0);
|
||||
const cpuCount =
|
||||
stats.cpu_stats?.online_cpus ??
|
||||
stats.cpu_stats?.cpu_usage?.percpu_usage?.length ??
|
||||
1;
|
||||
const rawMemory = stats.memory_stats?.usage;
|
||||
const cache =
|
||||
stats.memory_stats?.stats?.inactive_file ??
|
||||
stats.memory_stats?.stats?.cache ??
|
||||
0;
|
||||
const networks = Object.values(stats.networks ?? {});
|
||||
return {
|
||||
statusText,
|
||||
cpuPercent:
|
||||
cpuDelta > 0 && systemDelta > 0
|
||||
? Math.round((cpuDelta / systemDelta) * cpuCount * 10_000) / 100
|
||||
: null,
|
||||
memoryUsageBytes: finite(
|
||||
rawMemory === undefined ? undefined : Math.max(0, rawMemory - cache),
|
||||
),
|
||||
memoryLimitBytes: finite(stats.memory_stats?.limit),
|
||||
networkRxBytes: finite(
|
||||
networks.reduce((total, network) => total + (network.rx_bytes ?? 0), 0),
|
||||
),
|
||||
networkTxBytes: finite(
|
||||
networks.reduce((total, network) => total + (network.tx_bytes ?? 0), 0),
|
||||
),
|
||||
networkRxBytesPerSecond: null,
|
||||
networkTxBytesPerSecond: null,
|
||||
pids: finite(stats.pids_stats?.current),
|
||||
updatedAt: checkedAt(),
|
||||
freshness: "fresh",
|
||||
history: [],
|
||||
};
|
||||
}
|
||||
|
||||
function emptyMetric(statusText: string): WidgetMetric {
|
||||
return {
|
||||
statusText,
|
||||
cpuPercent: null,
|
||||
memoryUsageBytes: null,
|
||||
memoryLimitBytes: null,
|
||||
networkRxBytes: null,
|
||||
networkTxBytes: null,
|
||||
networkRxBytesPerSecond: null,
|
||||
networkTxBytesPerSecond: null,
|
||||
pids: null,
|
||||
updatedAt: null,
|
||||
freshness: "unavailable",
|
||||
history: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchNpmHosts(): Promise<string[]> {
|
||||
const base = process.env.NPM_URL?.replace(/\/$/, "");
|
||||
if (!base) return [];
|
||||
let token = process.env.NPM_TOKEN;
|
||||
if (!token && process.env.NPM_USERNAME && process.env.NPM_PASSWORD) {
|
||||
const auth = await fetchJson<{ token: string }>(`${base}/api/tokens`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
identity: process.env.NPM_USERNAME,
|
||||
secret: process.env.NPM_PASSWORD,
|
||||
}),
|
||||
});
|
||||
token = auth.token;
|
||||
}
|
||||
if (!token) throw new Error("No server-side NPM credential configured");
|
||||
const hosts = await fetchJson<Array<{ domain_names?: string[] }>>(
|
||||
`${base}/api/nginx/proxy-hosts`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
},
|
||||
);
|
||||
return hosts.flatMap((host) => host.domain_names ?? []);
|
||||
}
|
||||
|
||||
const mockContainers: DockerContainer[] = [
|
||||
{
|
||||
Id: "mock-plex-001",
|
||||
Names: ["/Plex-Media-Server"],
|
||||
Image: "lscr.io/linuxserver/plex:latest",
|
||||
State: "running",
|
||||
Status: "Up 6 days",
|
||||
Ports: [{ PrivatePort: 32400, PublicPort: 32400, Type: "tcp" }],
|
||||
Labels: {
|
||||
"net.unraid.docker.webui": "http://[IP]:[PORT:32400]/web",
|
||||
"net.unraid.docker.icon": "",
|
||||
},
|
||||
},
|
||||
{
|
||||
Id: "mock-immich-002",
|
||||
Names: ["/immich_server"],
|
||||
Image: "ghcr.io/immich-app/immich-server:release",
|
||||
State: "running",
|
||||
Status: "Up 2 days",
|
||||
Ports: [{ PrivatePort: 2283, PublicPort: 2283, Type: "tcp" }],
|
||||
Labels: { "net.unraid.docker.webui": "http://[IP]:[PORT:2283]" },
|
||||
},
|
||||
{
|
||||
Id: "mock-homeassistant-003",
|
||||
Names: ["/home-assistant"],
|
||||
Image: "ghcr.io/home-assistant/home-assistant:stable",
|
||||
State: "exited",
|
||||
Status: "Exited (0) 3 hours ago",
|
||||
Ports: [{ PrivatePort: 8123, PublicPort: 8123, Type: "tcp" }],
|
||||
Labels: { "net.unraid.docker.webui": "http://[IP]:[PORT:8123]" },
|
||||
},
|
||||
{
|
||||
Id: "mock-paperless-004",
|
||||
Names: ["/paperless-ngx"],
|
||||
Image: "ghcr.io/paperless-ngx/paperless-ngx:latest",
|
||||
State: "running",
|
||||
Status: "Up 12 hours",
|
||||
Ports: [{ PrivatePort: 8000, PublicPort: 8010, Type: "tcp" }],
|
||||
Labels: { "net.unraid.docker.webui": "http://[IP]:[PORT:8000]" },
|
||||
},
|
||||
];
|
||||
|
||||
export class DiscoveryService {
|
||||
private latestStates: IntegrationState[] = [
|
||||
state("unraid", "Unraid templates", "disabled", "Nog niet gecontroleerd"),
|
||||
state("docker", "Docker proxy", "disabled", "Nog niet gecontroleerd"),
|
||||
state("npm", "Nginx Proxy Manager", "disabled", "Optionele integratie"),
|
||||
];
|
||||
private latestWidgetMetrics: Record<string, WidgetMetric> = {};
|
||||
private latestServerWidget: ServerWidgetMetric | null = null;
|
||||
private networkSamples = new Map<
|
||||
string,
|
||||
{ rx: number; tx: number; sampledAt: number }
|
||||
>();
|
||||
private metricSamples = new Map<string, number>();
|
||||
private metricHistory = new Map<string, WidgetMetric["history"]>();
|
||||
private serverHistory: NonNullable<ServerWidgetMetric["history"]> = {};
|
||||
private appOpsMetrics = new Map<string, WidgetMetric>();
|
||||
private appOpsMetricsUpdatedAt = 0;
|
||||
private appOpsMetricsRefresh: Promise<void> | null = null;
|
||||
|
||||
constructor(private readonly unraidStatus = new UnraidStatusService()) {}
|
||||
|
||||
getStates() {
|
||||
return this.latestStates;
|
||||
}
|
||||
|
||||
getWidgetMetrics() {
|
||||
return this.latestWidgetMetrics;
|
||||
}
|
||||
|
||||
getServerWidget() {
|
||||
return this.latestServerWidget;
|
||||
}
|
||||
|
||||
private setServerWidget(widget: Omit<ServerWidgetMetric, "history">) {
|
||||
const native = widget.native;
|
||||
const storageTotal =
|
||||
native?.storage.reduce((total, group) => total + group.totalBytes, 0) ??
|
||||
0;
|
||||
const storageUsed =
|
||||
native?.storage.reduce((total, group) => total + group.usedBytes, 0) ?? 0;
|
||||
const values: Record<string, number | null> = {
|
||||
"Running containers": widget.containersRunning,
|
||||
"Stopped containers": widget.containersStopped,
|
||||
Images: widget.images,
|
||||
"Memory capacity": widget.totalMemoryBytes,
|
||||
"Storage used":
|
||||
storageTotal > 0 ? (storageUsed / storageTotal) * 100 : null,
|
||||
"Hottest temperature": native?.hottestTemperatureC ?? null,
|
||||
"Parity errors": native?.parityErrors ?? null,
|
||||
"UPS battery": native?.upsBatteryPercent ?? null,
|
||||
};
|
||||
for (const [label, value] of Object.entries(values)) {
|
||||
if (value === null || !Number.isFinite(value)) continue;
|
||||
this.serverHistory[label] = [
|
||||
...(this.serverHistory[label] ?? []),
|
||||
{ sampledAt: widget.updatedAt, value },
|
||||
].slice(-60);
|
||||
}
|
||||
this.latestServerWidget = { ...widget, history: this.serverHistory };
|
||||
}
|
||||
|
||||
async discover(
|
||||
externalAccess = false,
|
||||
widgetRequests: Array<string | WidgetRequest> = [],
|
||||
): Promise<DiscoveryResult> {
|
||||
const useMocks =
|
||||
process.env.MOCK_DISCOVERY === "true" || process.env.NODE_ENV === "test";
|
||||
const templatePath = process.env.UNRAID_TEMPLATES_PATH;
|
||||
const templates = readTemplates(templatePath);
|
||||
let containers: DockerContainer[] = [];
|
||||
let appOpsMetrics = this.appOpsMetrics;
|
||||
const dockerBase = (
|
||||
process.env.DOCKER_PROXY_URL ?? "http://dockerproxy:2375"
|
||||
).replace(/\/$/, "");
|
||||
let dockerState: IntegrationState;
|
||||
|
||||
if (useMocks) {
|
||||
containers = mockContainers;
|
||||
this.setServerWidget({
|
||||
url: "http://host.local",
|
||||
hostName: "Tower",
|
||||
operatingSystem: "Unraid 7",
|
||||
architecture: "x86_64",
|
||||
dockerVersion: "28.3.3",
|
||||
cpuCount: 12,
|
||||
totalMemoryBytes: 32 * 1024 ** 3,
|
||||
containers: containers.length,
|
||||
containersRunning: containers.filter((item) => item.State === "running")
|
||||
.length,
|
||||
containersPaused: 0,
|
||||
containersStopped: containers.filter((item) => item.State !== "running")
|
||||
.length,
|
||||
images: 18,
|
||||
updatedAt: checkedAt(),
|
||||
freshness: "fresh",
|
||||
native: {
|
||||
arrayState: "Started",
|
||||
parityStatus: "healthy",
|
||||
parityErrors: 0,
|
||||
lastParityCheck: new Date(Date.now() - 86_400_000).toISOString(),
|
||||
resyncPercent: null,
|
||||
hottestTemperatureC: 42,
|
||||
storage: [
|
||||
{
|
||||
name: "Array",
|
||||
kind: "array",
|
||||
status: "DISK_OK",
|
||||
totalBytes: 24 * 1024 ** 4,
|
||||
usedBytes: 14 * 1024 ** 4,
|
||||
freeBytes: 10 * 1024 ** 4,
|
||||
hottestTemperatureC: 39,
|
||||
deviceCount: 4,
|
||||
},
|
||||
],
|
||||
upsStatus: "ONLINE",
|
||||
upsLoadPercent: 18,
|
||||
upsBatteryPercent: 100,
|
||||
updatedAt: checkedAt(),
|
||||
},
|
||||
});
|
||||
dockerState = state(
|
||||
"docker",
|
||||
"Docker proxy",
|
||||
"connected",
|
||||
"Deterministische ontwikkeldata actief",
|
||||
);
|
||||
} else {
|
||||
this.unraidStatus.refresh();
|
||||
const appOpsBase = process.env.APPOPS_URL?.trim().replace(/\/$/, "");
|
||||
if (appOpsBase) {
|
||||
try {
|
||||
const inventory = await fetchJson<AppOpsContainer[]>(
|
||||
`${appOpsBase}/api/containers`,
|
||||
);
|
||||
containers = inventory
|
||||
.slice(0, MAXIMUM_INVENTORY_ITEMS)
|
||||
.map(appOpsContainerToDocker);
|
||||
appOpsMetrics = this.appOpsMetrics;
|
||||
if (widgetRequests.length > 0) this.refreshAppOpsStats(appOpsBase);
|
||||
const capacity = readHostCapacity();
|
||||
this.setServerWidget({
|
||||
url:
|
||||
normalizeUrl(
|
||||
process.env.UNRAID_URL?.trim() ||
|
||||
process.env.UNRAID_HOST ||
|
||||
"host.local",
|
||||
) ?? "http://host.local",
|
||||
hostName: process.env.UNRAID_HOST ?? "Unraid",
|
||||
operatingSystem: "Unraid",
|
||||
architecture: process.arch,
|
||||
dockerVersion: "AppOps read-only",
|
||||
...capacity,
|
||||
containers: containers.length,
|
||||
containersRunning: containers.filter(
|
||||
(item) => item.State === "running",
|
||||
).length,
|
||||
containersPaused: containers.filter(
|
||||
(item) => item.State === "paused",
|
||||
).length,
|
||||
containersStopped: containers.filter(
|
||||
(item) => !["running", "paused"].includes(item.State),
|
||||
).length,
|
||||
images: new Set(containers.map((item) => item.Image)).size,
|
||||
updatedAt: checkedAt(),
|
||||
freshness: "fresh",
|
||||
native: this.unraidStatus.getMetric(),
|
||||
});
|
||||
dockerState = state(
|
||||
"docker",
|
||||
"AppOps inventory",
|
||||
"connected",
|
||||
`${containers.length} containers GET-only gelezen`,
|
||||
);
|
||||
} catch {
|
||||
this.latestServerWidget = null;
|
||||
dockerState = state(
|
||||
"docker",
|
||||
"AppOps inventory",
|
||||
"degraded",
|
||||
"Read-only AppOps inventory is niet bereikbaar",
|
||||
);
|
||||
}
|
||||
} else
|
||||
try {
|
||||
containers = await fetchJson<DockerContainer[]>(
|
||||
`${dockerBase}/containers/json?all=1`,
|
||||
);
|
||||
containers = containers.slice(0, MAXIMUM_INVENTORY_ITEMS);
|
||||
try {
|
||||
const info = await fetchJson<DockerInfo>(`${dockerBase}/info`);
|
||||
this.setServerWidget({
|
||||
url:
|
||||
normalizeUrl(
|
||||
process.env.UNRAID_URL?.trim() ||
|
||||
process.env.UNRAID_HOST ||
|
||||
"host.local",
|
||||
) ?? "http://host.local",
|
||||
hostName: info.Name ?? process.env.UNRAID_HOST ?? "Unraid",
|
||||
operatingSystem: info.OperatingSystem ?? "Unraid",
|
||||
architecture: info.Architecture ?? "unknown",
|
||||
dockerVersion: info.ServerVersion ?? "unknown",
|
||||
cpuCount: info.NCPU ?? 0,
|
||||
totalMemoryBytes: info.MemTotal ?? 0,
|
||||
containers: info.Containers ?? containers.length,
|
||||
containersRunning: info.ContainersRunning ?? 0,
|
||||
containersPaused: info.ContainersPaused ?? 0,
|
||||
containersStopped: info.ContainersStopped ?? 0,
|
||||
images: info.Images ?? 0,
|
||||
updatedAt: checkedAt(),
|
||||
freshness: "fresh",
|
||||
native: this.unraidStatus.getMetric(),
|
||||
});
|
||||
} catch {
|
||||
this.latestServerWidget = null;
|
||||
}
|
||||
dockerState = state(
|
||||
"docker",
|
||||
"Docker proxy",
|
||||
"connected",
|
||||
`${containers.length} containers gelezen`,
|
||||
);
|
||||
} catch {
|
||||
this.latestServerWidget = null;
|
||||
dockerState = state(
|
||||
"docker",
|
||||
"Docker proxy",
|
||||
"degraded",
|
||||
"Read-only proxy is niet bereikbaar",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const unraidState = useMocks
|
||||
? state(
|
||||
"unraid",
|
||||
"Unraid templates",
|
||||
"connected",
|
||||
"WebUI-metadata uit fixtures",
|
||||
)
|
||||
: templates.size > 0
|
||||
? state(
|
||||
"unraid",
|
||||
"Unraid templates",
|
||||
"connected",
|
||||
`${templates.size} templates gelezen`,
|
||||
)
|
||||
: state(
|
||||
"unraid",
|
||||
"Unraid templates",
|
||||
"degraded",
|
||||
"Templatepad ontbreekt of bevat geen templates",
|
||||
);
|
||||
|
||||
let npmHosts: string[] = [];
|
||||
let npmState = state(
|
||||
"npm",
|
||||
"Nginx Proxy Manager",
|
||||
"disabled",
|
||||
"Externe toegang staat uit",
|
||||
);
|
||||
if (externalAccess) {
|
||||
try {
|
||||
npmHosts = useMocks
|
||||
? ["plex.home.test", "immich.home.test"]
|
||||
: await fetchNpmHosts();
|
||||
npmState = state(
|
||||
"npm",
|
||||
"Nginx Proxy Manager",
|
||||
"connected",
|
||||
`${npmHosts.length} hosts read-only gelezen`,
|
||||
);
|
||||
} catch {
|
||||
npmState = state(
|
||||
"npm",
|
||||
"Nginx Proxy Manager",
|
||||
"degraded",
|
||||
"Integratie is niet bereikbaar of niet geconfigureerd",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const host = process.env.UNRAID_HOST ?? "host.local";
|
||||
const now = checkedAt();
|
||||
const apps = containers.map<ClassifiedDiscoveredApp>((container) => {
|
||||
const technicalName = (container.Names[0] ?? container.Id).replace(
|
||||
/^\//,
|
||||
"",
|
||||
);
|
||||
const template = templates.get(technicalName.toLowerCase());
|
||||
const webUi =
|
||||
template?.webUi ?? container.Labels?.["net.unraid.docker.webui"];
|
||||
const appHost = resolveContainerHost(
|
||||
host,
|
||||
container.NetworkSettings?.Networks,
|
||||
);
|
||||
const iconTemplate =
|
||||
template?.icon ?? container.Labels?.["net.unraid.docker.icon"];
|
||||
const displayName =
|
||||
template?.name ||
|
||||
technicalName
|
||||
.replace(/[-_]+/g, " ")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
return {
|
||||
id: container.Id,
|
||||
technicalName,
|
||||
displayName,
|
||||
image: container.Image,
|
||||
icon: resolveUnraidIcon(iconTemplate, appHost, container.Ports),
|
||||
status:
|
||||
container.State.trim().toLocaleLowerCase() === "running"
|
||||
? "online"
|
||||
: "offline",
|
||||
localUrl: resolveUnraidWebUi(webUi, appHost, container.Ports),
|
||||
externalUrl: matchNpmHost(technicalName, npmHosts),
|
||||
personalProject: inferPersonalProject({
|
||||
technicalName,
|
||||
displayName,
|
||||
image: container.Image,
|
||||
}),
|
||||
detectionSource: useMocks
|
||||
? "mock"
|
||||
: template
|
||||
? "docker+unraid"
|
||||
: "docker",
|
||||
lastChecked: now,
|
||||
};
|
||||
});
|
||||
|
||||
const normalizedRequests = widgetRequests.map((request) =>
|
||||
typeof request === "string"
|
||||
? { technicalName: request, refreshIntervalMs: 30_000 }
|
||||
: request,
|
||||
);
|
||||
const requestsByName = new Map(
|
||||
normalizedRequests.map((request) => [
|
||||
request.technicalName.toLowerCase(),
|
||||
request,
|
||||
]),
|
||||
);
|
||||
const widgetContainers = containers.filter((container) => {
|
||||
const name = (container.Names[0] ?? container.Id)
|
||||
.replace(/^\//, "")
|
||||
.toLowerCase();
|
||||
return requestsByName.has(name);
|
||||
});
|
||||
const metricEntries = await mapWithConcurrency(
|
||||
widgetContainers.slice(0, MAXIMUM_INVENTORY_ITEMS),
|
||||
8,
|
||||
async (container, index) => {
|
||||
const technicalName = (container.Names[0] ?? container.Id).replace(
|
||||
/^\//,
|
||||
"",
|
||||
);
|
||||
const request = requestsByName.get(technicalName.toLowerCase())!;
|
||||
const previousMetric = this.latestWidgetMetrics[technicalName];
|
||||
const previousSample = this.metricSamples.get(technicalName) ?? 0;
|
||||
const elapsed = Date.now() - previousSample;
|
||||
if (previousMetric && elapsed < request.refreshIntervalMs) {
|
||||
return [
|
||||
technicalName,
|
||||
{
|
||||
...previousMetric,
|
||||
freshness:
|
||||
elapsed > request.refreshIntervalMs * 2 ? "stale" : "fresh",
|
||||
},
|
||||
] as const;
|
||||
}
|
||||
if (useMocks) {
|
||||
const sampledAt = checkedAt();
|
||||
const history = this.appendHistory(technicalName, {
|
||||
sampledAt,
|
||||
cpuPercent: container.State === "running" ? 1.4 + index : null,
|
||||
memoryPercent:
|
||||
container.State === "running" ? 11 + index * 3 : null,
|
||||
networkRxBytesPerSecond:
|
||||
container.State === "running" ? (3 + index) * 1024 ** 2 : null,
|
||||
});
|
||||
this.metricSamples.set(technicalName, Date.now());
|
||||
return [
|
||||
technicalName,
|
||||
{
|
||||
statusText: container.Status,
|
||||
cpuPercent: container.State === "running" ? 1.4 + index : null,
|
||||
memoryUsageBytes:
|
||||
container.State === "running"
|
||||
? (220 + index * 70) * 1024 ** 2
|
||||
: null,
|
||||
memoryLimitBytes:
|
||||
container.State === "running" ? 2 * 1024 ** 3 : null,
|
||||
networkRxBytes:
|
||||
container.State === "running" ? (18 + index) * 1024 ** 2 : null,
|
||||
networkTxBytes:
|
||||
container.State === "running" ? (7 + index) * 1024 ** 2 : null,
|
||||
networkRxBytesPerSecond:
|
||||
container.State === "running" ? (3 + index) * 1024 ** 2 : null,
|
||||
networkTxBytesPerSecond:
|
||||
container.State === "running"
|
||||
? (180 + index * 40) * 1024
|
||||
: null,
|
||||
pids: container.State === "running" ? 24 + index : null,
|
||||
updatedAt: sampledAt,
|
||||
freshness: "fresh",
|
||||
history,
|
||||
} satisfies WidgetMetric,
|
||||
] as const;
|
||||
}
|
||||
const appOpsMetric = appOpsMetrics.get(technicalName);
|
||||
if (appOpsMetric) {
|
||||
const sampledAt = Date.now();
|
||||
appOpsMetric.history = this.appendHistory(technicalName, {
|
||||
sampledAt: appOpsMetric.updatedAt ?? checkedAt(),
|
||||
cpuPercent: appOpsMetric.cpuPercent,
|
||||
memoryPercent:
|
||||
appOpsMetric.memoryUsageBytes !== null &&
|
||||
appOpsMetric.memoryLimitBytes !== null &&
|
||||
appOpsMetric.memoryLimitBytes > 0
|
||||
? (appOpsMetric.memoryUsageBytes /
|
||||
appOpsMetric.memoryLimitBytes) *
|
||||
100
|
||||
: null,
|
||||
networkRxBytesPerSecond: null,
|
||||
});
|
||||
this.metricSamples.set(technicalName, sampledAt);
|
||||
return [technicalName, appOpsMetric] as const;
|
||||
}
|
||||
try {
|
||||
const stats = await fetchJson<DockerStats>(
|
||||
`${dockerBase}/containers/${encodeURIComponent(container.Id)}/stats?stream=false&one-shot=true`,
|
||||
);
|
||||
const metric = metricFromStats(container.Status, stats);
|
||||
const sampledAt = Date.now();
|
||||
const previous = this.networkSamples.get(technicalName);
|
||||
if (
|
||||
previous &&
|
||||
metric.networkRxBytes !== null &&
|
||||
metric.networkTxBytes !== null
|
||||
) {
|
||||
const seconds = Math.max(
|
||||
0.001,
|
||||
(sampledAt - previous.sampledAt) / 1000,
|
||||
);
|
||||
metric.networkRxBytesPerSecond = Math.max(
|
||||
0,
|
||||
(metric.networkRxBytes - previous.rx) / seconds,
|
||||
);
|
||||
metric.networkTxBytesPerSecond = Math.max(
|
||||
0,
|
||||
(metric.networkTxBytes - previous.tx) / seconds,
|
||||
);
|
||||
}
|
||||
if (metric.networkRxBytes !== null && metric.networkTxBytes !== null)
|
||||
this.networkSamples.set(technicalName, {
|
||||
rx: metric.networkRxBytes,
|
||||
tx: metric.networkTxBytes,
|
||||
sampledAt,
|
||||
});
|
||||
metric.history = this.appendHistory(technicalName, {
|
||||
sampledAt: metric.updatedAt as string,
|
||||
cpuPercent: metric.cpuPercent,
|
||||
memoryPercent:
|
||||
metric.memoryUsageBytes !== null &&
|
||||
metric.memoryLimitBytes !== null &&
|
||||
metric.memoryLimitBytes > 0
|
||||
? (metric.memoryUsageBytes / metric.memoryLimitBytes) * 100
|
||||
: null,
|
||||
networkRxBytesPerSecond: metric.networkRxBytesPerSecond,
|
||||
});
|
||||
this.metricSamples.set(technicalName, sampledAt);
|
||||
return [technicalName, metric] as const;
|
||||
} catch {
|
||||
const unavailable = emptyMetric(container.Status);
|
||||
unavailable.history = this.metricHistory.get(technicalName) ?? [];
|
||||
return [technicalName, unavailable] as const;
|
||||
}
|
||||
},
|
||||
);
|
||||
this.latestWidgetMetrics = Object.fromEntries(metricEntries);
|
||||
|
||||
this.latestStates = [
|
||||
unraidState,
|
||||
...(!useMocks ? [this.unraidStatus.getState()] : []),
|
||||
dockerState,
|
||||
npmState,
|
||||
];
|
||||
return { apps, integrations: this.latestStates };
|
||||
}
|
||||
|
||||
private appendHistory(
|
||||
name: string,
|
||||
point: WidgetMetric["history"][number],
|
||||
): WidgetMetric["history"] {
|
||||
const history = [...(this.metricHistory.get(name) ?? []), point].slice(-20);
|
||||
this.metricHistory.set(name, history);
|
||||
return history;
|
||||
}
|
||||
|
||||
private refreshAppOpsStats(baseUrl: string) {
|
||||
if (
|
||||
this.appOpsMetricsRefresh ||
|
||||
Date.now() - this.appOpsMetricsUpdatedAt < 30_000
|
||||
)
|
||||
return;
|
||||
this.appOpsMetricsRefresh = fetchJson<AppOpsContainer[]>(
|
||||
`${baseUrl}/api/containers?include_stats=true&stats_limit=500`,
|
||||
undefined,
|
||||
15_000,
|
||||
)
|
||||
.then((inventory) => {
|
||||
this.appOpsMetrics = new Map(
|
||||
inventory
|
||||
.slice(0, MAXIMUM_INVENTORY_ITEMS)
|
||||
.map((item) => [item.name, metricFromAppOps(item)]),
|
||||
);
|
||||
this.appOpsMetricsUpdatedAt = Date.now();
|
||||
})
|
||||
.catch(() => {
|
||||
// Inventory and saved navigation stay available when optional stats lag.
|
||||
})
|
||||
.finally(() => {
|
||||
this.appOpsMetricsRefresh = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { detectProvider } from "./provider-widgets/registry.js";
|
||||
export { ProviderWidgetService } from "./provider-widgets/service.js";
|
||||
@@ -0,0 +1,213 @@
|
||||
import type { ProviderKind } from "../../../shared/contracts.js";
|
||||
import type { ProviderDetails } from "./types.js";
|
||||
|
||||
export const providerDetails: Record<ProviderKind, ProviderDetails> = {
|
||||
adguard: {
|
||||
label: "AdGuard Home",
|
||||
description: "DNS protection, query volume and blocking effectiveness.",
|
||||
features: ["Queries", "Blocked requests", "Response time"],
|
||||
credentialMode: "required",
|
||||
recommended: true,
|
||||
},
|
||||
plex: {
|
||||
label: "Plex",
|
||||
description: "See active playback and transcoding before opening Plex.",
|
||||
features: ["Active streams", "Transcodes", "Libraries"],
|
||||
credentialMode: "required",
|
||||
recommended: true,
|
||||
},
|
||||
immich: {
|
||||
label: "Immich",
|
||||
description: "A compact view of the memories protected by your library.",
|
||||
features: ["Photos", "Videos", "Storage"],
|
||||
credentialMode: "required",
|
||||
recommended: true,
|
||||
},
|
||||
homeAssistant: {
|
||||
label: "Home Assistant",
|
||||
description:
|
||||
"Live entity availability, automations and lights at a glance.",
|
||||
features: ["Automations", "Lights on", "Unavailable entities"],
|
||||
credentialMode: "optional",
|
||||
recommended: true,
|
||||
},
|
||||
arr: {
|
||||
label: "Arr service",
|
||||
description: "Queue pressure, warnings and the running service version.",
|
||||
features: ["Queue", "Warnings", "Version"],
|
||||
credentialMode: "required",
|
||||
recommended: true,
|
||||
},
|
||||
ollama: {
|
||||
label: "Ollama",
|
||||
description: "Local model inventory and models currently loaded in memory.",
|
||||
features: ["Installed models", "Loaded models", "Model storage"],
|
||||
credentialMode: "none",
|
||||
recommended: true,
|
||||
},
|
||||
glances: {
|
||||
label: "Glances",
|
||||
description: "Host pressure from the monitoring API without another login.",
|
||||
features: ["CPU", "Memory", "System load"],
|
||||
credentialMode: "none",
|
||||
recommended: true,
|
||||
},
|
||||
tautulli: {
|
||||
label: "Tautulli",
|
||||
description:
|
||||
"Plex activity enriched with direct play and bandwidth context.",
|
||||
features: ["Streams", "Transcodes", "Bandwidth"],
|
||||
credentialMode: "required",
|
||||
recommended: true,
|
||||
},
|
||||
paperless: {
|
||||
label: "Paperless-ngx",
|
||||
description: "Document archive totals and the inbox workload that remains.",
|
||||
features: ["Documents", "Inbox", "Storage"],
|
||||
credentialMode: "required",
|
||||
recommended: false,
|
||||
},
|
||||
gitea: {
|
||||
label: "Gitea",
|
||||
description: "Repository activity, open work and stars from the read API.",
|
||||
features: ["Repositories", "Open issues", "Stars"],
|
||||
credentialMode: "required",
|
||||
recommended: true,
|
||||
},
|
||||
jellyfin: {
|
||||
label: "Jellyfin",
|
||||
description: "Current playback sessions and the size of the media library.",
|
||||
features: ["Sessions", "Movies", "Episodes"],
|
||||
credentialMode: "required",
|
||||
recommended: true,
|
||||
},
|
||||
seerr: {
|
||||
label: "Seerr",
|
||||
description: "Pending media requests, available items and server version.",
|
||||
features: ["Pending requests", "Available media", "Version"],
|
||||
credentialMode: "required",
|
||||
recommended: true,
|
||||
},
|
||||
prowlarr: {
|
||||
label: "Prowlarr",
|
||||
description: "Indexer inventory, health warnings and service version.",
|
||||
features: ["Indexers", "Health warnings", "Version"],
|
||||
credentialMode: "required",
|
||||
recommended: true,
|
||||
},
|
||||
authentik: {
|
||||
label: "Authentik",
|
||||
description: "Identity inventory and recent event volume.",
|
||||
features: ["Users", "Applications", "Events"],
|
||||
credentialMode: "optional",
|
||||
recommended: true,
|
||||
},
|
||||
npm: {
|
||||
label: "Nginx Proxy Manager",
|
||||
description: "Proxy, redirect and dead-host inventory.",
|
||||
features: ["Proxy hosts", "Redirects", "Dead hosts"],
|
||||
credentialMode: "optional",
|
||||
recommended: true,
|
||||
},
|
||||
portainer: {
|
||||
label: "Portainer",
|
||||
description: "Endpoint availability and Docker workload inventory.",
|
||||
features: ["Endpoints", "Online", "Containers"],
|
||||
credentialMode: "required",
|
||||
recommended: true,
|
||||
},
|
||||
grafana: {
|
||||
label: "Grafana",
|
||||
description: "Dashboard inventory and alerting pressure.",
|
||||
features: ["Dashboards", "Alerts", "Database"],
|
||||
credentialMode: "required",
|
||||
recommended: false,
|
||||
},
|
||||
prometheus: {
|
||||
label: "Prometheus",
|
||||
description: "Target health from bounded read-only PromQL queries.",
|
||||
features: ["Targets up", "Targets down", "Health"],
|
||||
credentialMode: "none",
|
||||
recommended: false,
|
||||
},
|
||||
deluge: {
|
||||
label: "Deluge",
|
||||
description:
|
||||
"Queue, transfer and ratio data from a GET-only metrics bridge.",
|
||||
features: ["Queue", "Transfer rate", "Ratio"],
|
||||
credentialMode: "required",
|
||||
recommended: true,
|
||||
},
|
||||
qbittorrent: {
|
||||
label: "qBittorrent",
|
||||
description: "Torrent workload from a configured GET-only metrics bridge.",
|
||||
features: ["Queue", "Transfer rate", "Ratio"],
|
||||
credentialMode: "required",
|
||||
recommended: true,
|
||||
},
|
||||
jdownloader: {
|
||||
label: "JDownloader",
|
||||
description: "Package progress and ETA from a GET-only metrics bridge.",
|
||||
features: ["Packages", "Progress", "ETA"],
|
||||
credentialMode: "required",
|
||||
recommended: true,
|
||||
},
|
||||
tdarr: {
|
||||
label: "Tdarr",
|
||||
description: "Transcode queue and worker health from a GET-only bridge.",
|
||||
features: ["Queue", "Transcodes", "Workers"],
|
||||
credentialMode: "required",
|
||||
recommended: true,
|
||||
},
|
||||
bazarr: {
|
||||
label: "Bazarr",
|
||||
description: "Subtitle queue and language coverage from a GET-only bridge.",
|
||||
features: ["Missing subtitles", "Queue", "Languages"],
|
||||
credentialMode: "required",
|
||||
recommended: true,
|
||||
},
|
||||
vaultwarden: {
|
||||
label: "Vaultwarden",
|
||||
description:
|
||||
"Availability from Vaultwarden's public read-only alive route.",
|
||||
features: ["Health", "Read-only endpoint"],
|
||||
credentialMode: "none",
|
||||
recommended: true,
|
||||
},
|
||||
nextcloud: {
|
||||
label: "Nextcloud",
|
||||
description: "Cloud status, version and user quota from the OCS API.",
|
||||
features: ["Status", "Version", "Quota"],
|
||||
credentialMode: "required",
|
||||
recommended: true,
|
||||
},
|
||||
audiobookshelf: {
|
||||
label: "Audiobookshelf",
|
||||
description: "Library size, listening activity and media composition.",
|
||||
features: ["Libraries", "Books", "Podcasts", "Listening sessions", "Users"],
|
||||
credentialMode: "required",
|
||||
recommended: true,
|
||||
},
|
||||
netdata: {
|
||||
label: "Netdata",
|
||||
description: "Raised alerts and node health from the read-only health API.",
|
||||
features: ["Critical alerts", "Warnings", "Charts", "Version"],
|
||||
credentialMode: "none",
|
||||
recommended: true,
|
||||
},
|
||||
peertube: {
|
||||
label: "PeerTube",
|
||||
description: "Local video inventory and instance configuration.",
|
||||
features: ["Local videos", "Live videos", "Views", "Version"],
|
||||
credentialMode: "none",
|
||||
recommended: true,
|
||||
},
|
||||
custom: {
|
||||
label: "Custom application",
|
||||
description:
|
||||
"App-specific metrics from an optional GET-only JSON endpoint.",
|
||||
features: ["Health", "Activity", "Workload", "Domain metrics"],
|
||||
credentialMode: "optional",
|
||||
recommended: false,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ProviderKind } from "../../../shared/contracts.js";
|
||||
|
||||
export function detectProvider(name: string): ProviderKind | null {
|
||||
const value = name.toLowerCase();
|
||||
const tokens = value.split(/[^a-z0-9]+/).filter(Boolean);
|
||||
const has = (...candidates: string[]) =>
|
||||
tokens.some((token) => candidates.includes(token));
|
||||
if (has("adguard", "adguardhome")) return "adguard";
|
||||
if (has("jdownloader", "jdownloader2")) return "jdownloader";
|
||||
if (has("qbittorrent")) return "qbittorrent";
|
||||
if (has("deluge")) return "deluge";
|
||||
if (has("tdarr") && !has("node")) return "tdarr";
|
||||
if (has("gitea")) return "gitea";
|
||||
if (has("jellyfin")) return "jellyfin";
|
||||
if (has("overseerr", "jellyseerr", "seerr")) return "seerr";
|
||||
if (has("prowlarr")) return "prowlarr";
|
||||
if (has("bazarr")) return "bazarr";
|
||||
if (has("authentik") && !has("worker")) return "authentik";
|
||||
if (has("vaultwarden")) return "vaultwarden";
|
||||
if (has("portainer", "portainerce")) return "portainer";
|
||||
if (has("grafana")) return "grafana";
|
||||
if (has("prometheus")) return "prometheus";
|
||||
if (has("nextcloud")) return "nextcloud";
|
||||
if (has("audiobookshelf")) return "audiobookshelf";
|
||||
if (has("netdata")) return "netdata";
|
||||
if (has("peertube")) return "peertube";
|
||||
if (
|
||||
has("nginxproxymanager", "nginxproxy") ||
|
||||
/nginx[^a-z0-9]*proxy[^a-z0-9]*manager/.test(value)
|
||||
)
|
||||
return "npm";
|
||||
if (has("tautulli")) return "tautulli";
|
||||
if (has("plex", "plexpass")) return "plex";
|
||||
if (
|
||||
has("immich") &&
|
||||
!has("postgres", "postgresql", "redis", "database", "machinelearning") &&
|
||||
!/machine[^a-z0-9]*learning/.test(value)
|
||||
)
|
||||
return "immich";
|
||||
if (has("paperless", "paperlessngx")) return "paperless";
|
||||
if (has("ollama")) return "ollama";
|
||||
if (has("glances")) return "glances";
|
||||
if (
|
||||
has("homeassistant") ||
|
||||
/(?:^|[^a-z0-9])home[^a-z0-9]+assistant(?:[^a-z0-9]|$)/.test(value)
|
||||
)
|
||||
return "homeAssistant";
|
||||
if (has("sonarr", "radarr", "lidarr")) return "arr";
|
||||
return null;
|
||||
}
|
||||