diff --git a/.dockerignore b/.dockerignore index 2914ba74..239ad9ea 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,9 +1,23 @@ .git -node_modules -dist -coverage -playwright-report -test-results -data -.env* -*.log +.venv +venv +__pycache__ +*.pyc +.pytest_cache + +frontend/node_modules +frontend/dist +frontend/*.tsbuildinfo +backend/.pytest_cache +backend/**/*.pyc +backend/**/__pycache__ + +storage +postgres-data +datasets/raw +datasets/processed +datasets/cache +exports +models + +.env diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index 1014ba78..00000000 --- a/.editorconfig +++ /dev/null @@ -1,12 +0,0 @@ -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 diff --git a/.env.example b/.env.example index b6c4c4c5..716e7ce8 100644 --- a/.env.example +++ b/.env.example @@ -1,100 +1,153 @@ -# 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 +# Backend +GEOINTEL_ENV=development +GEOINTEL_API_PREFIX=/api/v1 +DATABASE_URL=postgresql+psycopg://geointel:geointel@localhost:5432/geointel?connect_timeout=1 +STORAGE_ROOT=./storage +MAX_UPLOAD_MB=500 +CORS_ORIGINS=http://localhost:1202,http://127.0.0.1:1202 +ORTHOPHOTO_ENABLED=true +ORTHOPHOTO_WMS_URL=https://geo.api.vlaanderen.be/OMWRGBMRVL/wms +ORTHOPHOTO_WMS_LAYER=Ortho +ORTHOPHOTO_RESOLUTION_M=1.0 +ORTHOPHOTO_MIN_SIDE_M=128 +ORTHOPHOTO_MAX_SIDE_M=1024 +ORTHOPHOTO_CACHE_TTL_HOURS=24 +SOURCE_CATALOG_PROBE_ENABLED=true +SOURCE_CATALOG_GRB_WFS_URL=https://geo.api.vlaanderen.be/GRB/wfs +GRB_ENABLED=true +GRB_OGC_API_URL=https://geo.api.vlaanderen.be/GRB/ogc/features/v1 +GRB_MIN_SIDE_M=10 +GRB_MAX_SIDE_M=20000 +GRB_PAGE_SIZE=1000 +GRB_MAX_PAGES=200 +GRB_MAX_FEATURES=150000 +GRB_TIMEOUT_SECONDS=180 +GRB_MAX_RESPONSE_MB=20 +GRB_MAX_TOTAL_RESPONSE_MB=256 +GRB_CACHE_TTL_HOURS=24 +OFFICIAL_VECTOR_ENABLED=true +BWK_WFS_URL=https://geo.api.vlaanderen.be/BWK/wfs +DOV_SOIL_WFS_URL=https://www.dov.vlaanderen.be/geoserver/wfs +SPW_PICC_ENABLED=true +SPW_PICC_MAPSERVER_URL=https://geoservices.wallonie.be/arcgis/rest/services/TOPOGRAPHIE/PICC_VDIFF/MapServer +SPW_FLOOD_HAZARD_ENABLED=true +SPW_FLOOD_HAZARD_MAPSERVER_URL=https://geoservices.wallonie.be/arcgis/rest/services/EAU/ALEA_INOND/MapServer +URBIS_ENABLED=true +URBIS_WFS_URL=https://geoservices-vector.irisnet.be/geoserver/urbisvector/ows +OFFICIAL_VECTOR_MIN_SIDE_M=10 +OFFICIAL_VECTOR_MAX_SIDE_M=20000 +OFFICIAL_VECTOR_PAGE_SIZE=1000 +OFFICIAL_VECTOR_MAX_PAGES=200 +OFFICIAL_VECTOR_MAX_FEATURES=100000 +OFFICIAL_VECTOR_TIMEOUT_SECONDS=180 +OFFICIAL_VECTOR_MAX_RESPONSE_MB=20 +OFFICIAL_VECTOR_MAX_TOTAL_RESPONSE_MB=256 +OFFICIAL_VECTOR_CACHE_TTL_HOURS=24 +SOURCE_CATALOG_STATBEL_DCAT_URL=https://doc.statbel.be/publications/DCAT/DCAT_opendata_datasets.ttl +SOURCE_CATALOG_STATBEL_MAX_RESPONSE_MB=5 +SOURCE_CATALOG_ALZ_RELEASE_URL=https://landbouwcijfers.vlaanderen.be/open-geodata-landbouwgebruikspercelen +SOURCE_CATALOG_PROBE_TIMEOUT_SECONDS=10 +SOURCE_CATALOG_PROBE_MAX_RESPONSE_MB=2 +SOURCE_CATALOG_PROBE_CACHE_TTL_SECONDS=900 +DHMV_ENABLED=true +DHMV_WCS_URL=https://geo.api.vlaanderen.be/DHMV/wcs +DHMV_RESOLUTION_M=5.0 +DHMV_MIN_SIDE_M=10 +DHMV_MAX_SIDE_M=20000 +DHMV_MAX_PIXELS=12000000 +DHMV_TIMEOUT_SECONDS=300 +DHMV_MAX_RESPONSE_MB=160 +FLOOD_HAZARD_ENABLED=true +FLOOD_HAZARD_WCS_URL=https://geoservice.waterinfo.be/OGRK/wcs +FLOOD_HAZARD_RESOLUTION_M=5.0 +FLOOD_HAZARD_MIN_SIDE_M=10 +FLOOD_HAZARD_MAX_SIDE_M=20000 +FLOOD_HAZARD_MAX_PIXELS=12000000 +FLOOD_HAZARD_TIMEOUT_SECONDS=300 +FLOOD_HAZARD_MAX_RESPONSE_MB=160 +BATHYMETRY_PROFILES_ENABLED=true +BATHYMETRY_PROFILES_LAYER_URL=https://vha.waterinfo.be/arcgis/rest/services/digitale_atlas/MapServer/0 +BATHYMETRY_WATERCOURSE_LAYER_URL=https://vha.waterinfo.be/arcgis/rest/services/digitale_atlas/MapServer/1 +BATHYMETRY_PROFILES_PAGE_SIZE=1000 +BATHYMETRY_PROFILES_MAX_FEATURES=50000 +BATHYMETRY_PROFILES_TIMEOUT_SECONDS=120 +BATHYMETRY_PROFILES_MAX_RESPONSE_MB=32 +MDK_BATHYMETRY_PROBE_ENABLED=true +MDK_BATHYMETRY_WCS_URL=https://bathy.agentschapmdk.be/spatialfusionserver/services/ows/wcs/EL_wcs +MDK_BATHYMETRY_PROBE_TIMEOUT_SECONDS=20 +MDK_BATHYMETRY_PROBE_MAX_RESPONSE_MB=4 +# Bounded MDK acquisition stays fail-closed until the readiness probe reports +# "reachable" and an advertised coverage id is configured explicitly. +MDK_BATHYMETRY_ACQUISITION_ENABLED=false +MDK_BATHYMETRY_COVERAGE_ID= +MDK_BATHYMETRY_REQUEST_CRS=EPSG:4326 +MDK_BATHYMETRY_MAX_BBOX_DEG2=0.25 +MDK_BATHYMETRY_ACQUISITION_TIMEOUT_SECONDS=120 +MDK_BATHYMETRY_ACQUISITION_MAX_RESPONSE_MB=160 +THEMATIC_RASTER_ENABLED=true +THEMATIC_RASTER_WCS_URL=https://www.mercator.vlaanderen.be/raadpleegdienstenmercatorpubliek/wcs +THEMATIC_RASTER_MIN_SIDE_M=100 +THEMATIC_RASTER_MAX_SIDE_M=60000 +THEMATIC_RASTER_MAX_PIXELS=30000000 +THEMATIC_RASTER_TIMEOUT_SECONDS=300 +THEMATIC_RASTER_MAX_RESPONSE_MB=160 +WALOUS_ENABLED=true +WALOUS_SOURCE_DIR=/app/storage/source-cache/walous +WALOUS_ANALYSIS_RESOLUTION_M=10 +WALOUS_MAX_SIDE_M=60000 +WALOUS_MAX_PIXELS=36000000 +YOLO_ENABLED=false +YOLO_MODELS_DIR=/app/models +YOLO_MODEL_PATH= +YOLO_MODEL_ID=yolo-configured +YOLO_MODEL_DISPLAY_NAME=Configured YOLO detector +YOLO_MODEL_VERSION= +YOLO_CONFIG_DIR=./storage/ultralytics +YOLO_DEVICE=cpu +YOLO_IMAGE_SIZE=640 +YOLO_MAX_TILES=100 +YOLO_MAX_DETECTIONS=1000 +YOLO_DUPLICATE_IOU_THRESHOLD=0.5 +YOLO_BATCH_SIZE=1 -# Docker Compose publication (use the Unraid LAN IP for a trusted-LAN deployment) -DOCKDECK_BIND_ADDRESS=127.0.0.1 -DOCKDECK_PORT=1218 -DOCKDECK_INTERNAL_SUBNET=172.31.240.0/28 -DOCKDECK_EGRESS_SUBNET=172.31.240.16/28 +# Local segmentation models. GeoIntel never downloads model weights +# automatically; point these to existing local files to enable inference. +YOLO_SEG_ENABLED=false +YOLO_SEG_MODEL_PATH= +YOLO_SEG_MODEL_ID=yolo-seg-configured +YOLO_SEG_MODEL_DISPLAY_NAME=Configured YOLO segmentation +YOLO_SEG_MODEL_VERSION= +SAM_ENABLED=false +SAM_MODEL_PATH= +SAM_MODEL_ID=sam-configured +SAM_MODEL_DISPLAY_NAME=Configured SAM segmentation +SAM_MODEL_VERSION= +SEGMENTATION_MAX_MASKS_PER_TILE=300 +SEGMENTATION_DUPLICATE_IOU_THRESHOLD=0.5 +ENABLE_GRB_WFS=false +GRB_WFS_URL= +OSM_OVERPASS_URL=https://overpass-api.de/api/interpreter -# 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=tower.local -UNRAID_URL=http://tower.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= +# Install backend raster dependencies when needed: +# python -m pip install rasterio -# Optional read-only Nginx Proxy Manager connection. Keep credentials server-side. -NPM_URL= -NPM_TOKEN= -NPM_USERNAME= -NPM_PASSWORD= +# Frontend +VITE_API_BASE_URL= +VITE_API_PROXY_TARGET=http://localhost:8000 +# Leave empty to use the local/demo OpenStreetMap fallback with visible attribution. +# Set this to a managed MapLibre style URL for production or heavier tile traffic. +VITE_MAP_STYLE_URL= -# 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= +# Docker Compose / Unraid +GEOINTEL_FRONTEND_PORT=1202 +GEOINTEL_BACKEND_PORT=8000 +GEOINTEL_INSTALL_AI=false +GEOINTEL_STORAGE_PATH=./storage +GEOINTEL_BACKUPS_PATH=./backups +GEOINTEL_MODELS_PATH=./models +GEOINTEL_POSTGIS_DATA_PATH=./postgres-data +GEOINTEL_POSTGRES_DB=geointel +GEOINTEL_POSTGRES_USER=geointel +GEOINTEL_POSTGRES_PASSWORD=geointel +GEOINTEL_CORS_ORIGINS=http://localhost:1202,http://127.0.0.1:1202 +GEOINTEL_MAX_UPLOAD_MB=500 diff --git a/.gitattributes b/.gitattributes index 13d18b90..72744fa6 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,13 @@ -* text=auto eol=lf -*.png binary -*.jpg binary -*.jpeg binary -*.webp binary +*.sh text eol=lf +deploy/unraid/gosu-setpriv text eol=lf +*.py text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.toml text eol=lf +*.ini text eol=lf +Dockerfile text eol=lf +*.md text eol=lf +*.tsx text eol=lf +*.ts text eol=lf +*.css text eol=lf +*.json text eol=lf diff --git a/.gitignore b/.gitignore index bc0f0a99..6a412359 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,56 @@ +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ +.env +*.egg-info/ +.pytest_cache/ +.ruff_cache/ + +# Node node_modules/ dist/ -coverage/ -playwright-report/ -test-results/ -data/*.db -data/*.db-* -data/wallpaper.* -.env -.env.* -!.env.example -*.log +build/ *.tsbuildinfo + +# Large local data +/artifacts/ +/.cache/ +/datasets/raw/* +/datasets/processed/* +/datasets/cache/* +/storage/uploads/* +/storage/tiles/* +/storage/masks/* +/storage/reports/* +/storage/exports/* +/storage/rasters/* +/storage/models/* +/storage/operator-data/* +/storage/operator-evidence/* +/storage/release-evidence/* +/storage/previews/* +/storage/training/* +/storage/ultralytics/* +/exports/* +/models/* +/backend/storage/uploads/* +/backend/storage/tiles/* +/backend/storage/masks/* +/backend/storage/reports/* +/backend/storage/exports/* +/backups/* +/postgres-data/* + +# Keep folder placeholders +!**/.gitkeep +!**/README.md + +# Runtime-generated operator documentation is not repository documentation. +/storage/operator-data/README.md + +# OS/editor .DS_Store -.codex-input/ +.vscode/ +.idea/ diff --git a/AGENTS.md b/AGENTS.md index 84ed7459..d9450a2a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,59 +1,44 @@ -# AGENTS.md +# AI Agent Instructions for GeoIntel -Project: DockDeck +## Project identity -Dit project gebruikt het Codex Project Operating System. +GeoIntel is a GeoAI Workbench for Belgium and the Belgian North Sea, not a +generic CRUD app and not a generic dashboard. Mol and the Kempen remain golden +regression areas, not the product boundary. -## Werkmodus +## Required behavior -Volg deze stappen bij elke wijziging: begrijpen, ontwerpen, plannen, bouwen, testen, verbeteren, documenteren, committen. +- Read `docs/CODEX_BOOTSTRAP_PROMPT.md` first. +- Respect `docs/RC_SCOPE_FREEZE_BELGIUM_NORTH_SEA.md`. +- Use `docs/API_CONTRACTS.md` as source of truth for endpoints. +- Use `docs/DATABASE_IMPLEMENTATION_PLAN.md` as source of truth for persistence. +- Use `docs/DEFINITION_OF_DONE.md` to decide whether work is complete. -Startmodus: MVP bouwen +## Agent roles -- Codex mag scaffolden, bouwen, testen, committen en pushen wanneer de repo en remote duidelijk zijn. -- Bouw de kleinste bruikbare verticale flow eerst. -- Vraag alleen om hulp bij ontbrekende credentials, onduidelijke productkeuzes of risicovolle externe acties. +### Architecture Agent -## Autonomie +Owns repository layout, API contracts, database migrations and service boundaries. -- Werk zelfstandig door waar de intentie duidelijk is. -- Stel alleen vragen wanneer een keuze het productgedrag, de architectuur of de veiligheid wezenlijk verandert. -- Houd wijzigingen klein, toetsbaar en passend bij het bestaande project. -- Gebruik de vastgelegde runtime-, sync-, persistentie-, deploy- en repo-keuzes als startpunt. -- Werk projectdocumentatie bij zodra besluiten, risico's of verificatie veranderen. +### GIS Agent -## Autonomiegrenzen +Owns GeoPandas, Shapely, Rasterio, CRS, clipping, buffering, spatial joins and metadata extraction. -- Commit en push alleen wanneer de repo en remote duidelijk zijn. -- Deploy alleen wanneer het deploymentdoel, poort en credentials expliciet bekend zijn. -- Vraag om input bij ontbrekende credentials, betaalde diensten of destructieve acties. -- Stop en vraag bevestiging wanneer een actie gebruikersdata kan wijzigen, verwijderen of publiceren. +### AI Agent -## Veiligheid +Owns YOLO/SAM abstractions, inference contracts, model configuration, detection/segmentation persistence and `not_configured` behavior. -- Commit nooit secrets. -- Plaats geen API keys, tokens, wachtwoorden of klantdata in code, tests of logs. -- Verwacht credentials: Ja. -- Security/privacy notities: Gebruik uitsluitend environmentvariabelen voor gevoelige configuratie. Voorzie minimaal variabelen zoals GITEA_URL, GITEA_TOKEN en GITEA_OWNER voor repository-initialisatie en optionele NPM_URL, NPM_USERNAME en NPM_PASSWORD of een ondersteund read-only token voor Nginx Proxy Manager. Gebruik voor de Docker-koppeling geen onbeperkte socketmount in de applicatiecontainer, maar een afzonderlijke proxy met alleen de minimaal noodzakelijke read-endpoints. Sla secrets nooit op in SQLite, exports, logs, frontendbundels, testfixtures of Git. Toon gevoelige integratiewaarden gemaskeerd in de UI. De app mag geen Docker-mutaties uitvoeren en mag nooit containers starten, stoppen, herstarten, verwijderen of aanpassen. De MVP heeft geen eigen authenticatie en moet daarom standaard alleen op een lokaal bindadres of afgeschermd Docker-netwerk worden gepubliceerd. Externe toegang via Nginx Proxy Manager wordt alleen voorbereid; correcte authenticatie en toegangscontrole via bijvoorbeeld Authentik blijven de verantwoordelijkheid van een latere deploymentstap. +### QA Agent -## Quality Gate +Owns tests, QA/QC metrics, regression checks and acceptance criteria. -- Run de relevante tests voor elke gedragswijziging. -- Run lint/typecheck voor overdracht of commit. -- Noteer testresultaten in TEST_LOG.md. -- Laat bekende risico's achter in RISKS.md en HANDOFF.md. -- Verplichte finale gate: `npm run format:check`, `npm run lint`, `npm run typecheck`, `npm test`, `npm run test:e2e`, `npm run test:security`, `npm run build` en `npm audit --audit-level=moderate`. -- Valideer `docker compose config` en de imagebuild zodra een Docker-runtime beschikbaar is. +### Frontend Agent -## Productgrenzen +Owns React, TypeScript, MapLibre, API client, UI states and workbench UX. -- Houd Docker-, Unraid- en NPM-integraties read-only. -- Voeg nooit Docker start/stop/restart/delete/exec- of andere beheeracties toe. -- Nieuwe discovery-records blijven standaard verborgen; integratiefouten mogen opgeslagen navigatie nooit blokkeren. -- Secrets blijven uitsluitend in environmentvariabelen en mogen niet in API-responses, SQLite, exports, logs, fixtures of browserbundels terechtkomen. -- De app heeft geen authenticatie en blijft standaard op `127.0.0.1` gebonden. +## Never do this -## Resume Instructions - -- Lees AGENTS.md, PROJECT_BRIEF.md, REQUIREMENTS.md, SPEC.md, PLAN.md, DECISIONS.md, TEST_LOG.md, RISKS.md en HANDOFF.md voordat je verder bouwt. -- Vat de huidige staat kort samen, bepaal de volgende stap uit PLAN.md en werk daarna de documentatie bij. +- Do not fake production AI outputs. +- Do not silently skip geospatial validation. +- Do not add auth/multi-user/LiDAR/training before V1 foundation is stable. +- Do not remove documentation to avoid conflicts. diff --git a/DECISIONS.md b/DECISIONS.md deleted file mode 100644 index a302cd97..00000000 --- a/DECISIONS.md +++ /dev/null @@ -1,65 +0,0 @@ -# DECISIONS.md - -Project: DockDeck - -## Decision Log - -| ID | Decision | Rationale | Status | -| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- | -| D-001 | DockDeck is een interactieve webapp. | Past bij browserstartpagina en lokaal beheer. | Accepted | -| D-002 | TypeScript, React/Vite, Fastify, Drizzle/SQLite, Zod, Vitest en Playwright in één repository. | Kleinste stack die de bindende brief volledig en testbaar dekt. | Accepted | -| D-003 | Fastify serveert in productie REST en de gebouwde React-client. | Eén container en één poort beperken Unraid-complexiteit. | Accepted | -| D-004 | Statuspolling is configureerbaar, standaard 30 seconden, en pauzeert op verborgen tabs. | Actuele status zonder flikkering of onnodige achtergrondbelasting. | Accepted | -| D-005 | SQLite staat in `/data/dockdeck.db`; een idempotente migratie draait vóór serverstart. | Eenvoudige persistente back-up zonder externe database. | Accepted | -| D-006 | De hoofdapp krijgt geen Docker-socket; discovery verloopt via een server-side GET-adapter en alleen de optionele fallbackproxy ziet de socket. | Handhaaft de expliciete veiligheidsgrens. | Accepted | -| D-007 | Git gebruikt lokaal `main`; private Gitea-initialisatie gebeurt via een apart environmentgestuurd script. | Credentials waren lokaal niet beschikbaar en worden nooit opgeslagen. | Accepted | -| D-008 | Integraties rapporteren `connected`, `degraded` of `disabled`; opgeslagen navigatie blijft beschikbaar. | Externe uitval mag het dashboard niet blokkeren. | Accepted | -| D-009 | Exportformaat versie 1 bevat alleen gebruikersconfiguratie, nooit secrets. | Veilige en toekomstig migreerbare back-up. | Accepted | -| D-010 | Visuele voorkeuren worden als uitbreidbare velden in SQLite opgeslagen en bij oudere databases idempotent toegevoegd. | Personalisatie blijft na updates behouden zonder een breaking exportwijziging. | Accepted | -| D-011 | DM Sans en Newsreader worden lokaal uit het applicatiepakket geladen. | De startpagina blijft visueel stabiel en heeft geen externe fontrequest nodig. | Accepted | -| D-012 | Servicewidgets gebruiken read-only runtimegegevens voor maximaal acht expliciet gekozen containers; standaard via AppOps en in de optionele fallback via Dockerstats. | Levert CPU-, geheugen- en statusinformatie zonder appcredentials of Dockerbeheerrechten. | Accepted | -| D-013 | Favorieten leiden zonder override hun icoon af als `/favicon.ico` en vallen bij fouten terug op een monogram. | Automatische first-party iconen zonder externe favicon- of trackingdienst. | Accepted | -| D-014 | De standaard Unraid-widget combineert AppOps-inventaris met minimale hostmounts; providers gebruiken expliciete server-side minimum-scope credentials en runtimefallbacks. | Levert echte dienstfunctionaliteit zonder container-environments te inspecteren, secrets op te slaan of de read-only grens te verruimen. | Accepted | -| D-015 | Providercredentials kunnen in Settings worden geschreven naar een atomisch vervangen `0600`-environmentbestand in het persistente volume; de API echoot waarden nooit terug. | Maakt app-specifieke widgets configureerbaar zonder secrets in SQLite, export, logs, frontendbundel of Git te plaatsen. | Accepted | -| D-016 | Widget Studio gebruikt niet-persistente capabilitymetadata; provideradapters mogen de ontdekte lokale app-origin als URL gebruiken en vragen alleen ontbrekende secrets. | Maakt rijke widgets vindbaar en vaak zero-config zonder dubbel URL-beheer of automatische wijziging van gebruikersvoorkeuren. | Accepted | -| D-017 | Een uit DockDeck verwijderde app wordt op technische containernaam persistent onderdrukt; de Docker-container zelf blijft onaangeraakt. | Geeft echte, restartbestendige opruiming binnen het platform zonder de read-only Dockergrens te doorbreken. | Accepted | -| D-018 | De zoekindex is volledig afgeleid uit de actuele dashboardpayload en bevat ook verborgen apps, aliases, URL's, categorieën, favorieten en de Unraid-host. | Hernoemen en configuratiewijzigingen worden direct vindbaar zonder een aparte stale zoekdatabase. | Accepted | -| D-019 | Widgetformaat en informatielagen zijn expliciete, persistente app-/hostvelden; providerdetectie gebruikt servicetokens en sluit supportcontainers uit. | Geeft echte inhoudscontrole en voorkomt dat brede substringmatches een verkeerde providerkaart opleveren. | Accepted | -| D-020 | Widgetvolgorde staat los van apptegelvolgorde; directe editbediening gebruikt semantische knoppen en blijft zonder drag-and-drop volledig bruikbaar. | Behoudt voorspelbare toetsenbord- en schermlezervolgorde in responsive dense grids. | Accepted | -| D-021 | App- en servergrafieken bewaren maximaal zestig punten alleen in serverprocesgeheugen; iedere widget heeft een begrensde refreshcadans, configureerbaar venster en expliciete freshness. | Geeft nuttige korte trends zonder een monitoringdatabase of onbegrensde hostbelasting. | Accepted | -| D-022 | Unraid-native status komt uit specifieke read-only runtime- en hwmonmounts, nooit uit een verruimde Dockerproxy of mutatie-API. | Levert array- en hostdiepgang met een minimale, controleerbare trust boundary. | Accepted | -| D-023 | Beveiligde applicaties zonder GET-only native API gebruiken optioneel een door de gebruiker beheerde metrics bridge. | Houdt DockDeck aantoonbaar GET-only en maakt toch app-specifieke widgets mogelijk. | Accepted | -| D-024 | Layoutpresets zijn afzonderlijke records en exports; toepassen wijzigt alleen widgetpresentatie, nooit appzichtbaarheid, credentials of containers. | Maakt contextwissels veilig en draagbaar zonder een volledige configuratierestore. | Accepted | -| D-025 | De PWA-serviceworker cachet uitsluitend de applicatieshell en weigert API-caching; diagnostics zijn secretvrij. | Voorkomt dat operationele data of credentials als offline autoriteit in de browser blijven staan. | Accepted | -| D-026 | Iedere ontdekte app krijgt capabilitymetadata en een optionele GET-only JSON-koppeling wanneer geen native adapter bestaat. | Dekt ook eigen en niche-apps af zonder onveilige beheer-API's, containerspecifieke secrets of generieke schijnmetrics. | Accepted | -| D-027 | Zichtbaarheid en volgorde van appmetrics worden per widget als labels opgeslagen en met live providerlabels samengevoegd. | Laat inhoud aanpassen zonder het opslagmodel aan iedere nieuwe provider of metric te koppelen. | Accepted | -| D-028 | Tower-identiteit is gebruikersconfiguratie; een ontbrekende poort in de Unraid-basis-URL wordt als `5000` geïnterpreteerd. | Herstelt de werkelijke lokale WebUI-link en houdt toekomstige hostnaam- of poortwijzigingen in de UI beheerbaar. | Accepted | -| D-029 | De standaard Unraid-deployment bestaat uit één branded DockDeck-container en gebruikt de bestaande AppOps GET-inventaris; een losse socketproxy is uitsluitend een optionele overlay. | Voldoet aan eenvoudig Unraid-beheer zonder de Docker-socket of mutatierechten aan DockDeck te geven. | Accepted | -| D-030 | Capabilitymetadata bepaalt ook de niet-gekoppelde kaartinhoud; ontbrekende appdata wordt expliciet als te koppelen getoond en nooit vervangen door generieke schijnmetrics. | Houdt iedere widget domeinspecifiek zonder waarden te verzinnen en scheidt echte Docker-infrastructuurtelemetrie duidelijk. | Accepted | -| D-031 | Unraid-listmetadata gebruikt een echt transparant PNG-bestand, ook al blijft de PWA vrij om het schaalbare SVG-asset te gebruiken. | Sluit aan op Unraids vaste `.png`-cache en voorkomt een onzichtbaar icoon door een fout MIME-/bestandsformaat. | Accepted | -| D-032 | De Unraid-deployment ververst na een icoonwijziging zowel Dockermans persistente als actieve RAM-cache en valideert vooraf het echte PNG-MIME-type. | DockerMan hergebruikt anders een bestaand actief cachebestand, waarna zijn `onerror` het bedoelde icoon stilzwijgend door `question.png` vervangt. | Accepted | -| D-033 | DockDeck Canon v1 is de bindende visuele bron; conflicterende Obsidian Control-, Core Modernist- en oudere Stitch-varianten worden niet gecombineerd. | Eén expliciet ontwerpsysteem voorkomt een hybride interface en houdt tokens, componentvormen en responsive gedrag onderhoudbaar. | Accepted | -| D-034 | Launcherinhoud en Quick Launch vormen de primaire dashboardhiërarchie; bestaande read-only widgets, presets en diagnostics blijven behouden onder een secundair uitklapbaar inzicht en Algemeen. | Behoudt alle operationele functionaliteit zonder de rustige dagelijkse startflow van Canon v1 te verdringen. | Accepted | -| D-035 | Geist wordt lokaal gebundeld en alle Canon-kleuren, spacing, radii en states lopen via semantische tokens voor dark, light en Harbor. | Voorkomt externe fontafhankelijkheden en maakt themagedrag consistent zonder Canon-waarden door component-CSS te verspreiden. | Accepted | -| D-036 | Playwright draait lokaal op de geïsoleerde API-/webpoorten 3101 en 5174, configureerbaar via environmentvariabelen. | Vermijdt botsing met de bestaande WSL-relay op poort 3000 zonder de productiestandaardpoort of runtimearchitectuur te wijzigen. | Accepted | -| D-037 | De Stitch-launcher combineert op desktop een permanente favorietenrail met zichtbare read-only servicepanelen; op tablet en mobiel blijven favorieten en apps vóór de panelen staan. | Herstelt de sterke dagelijkse navigatie en widgetmogelijkheden uit het oorspronkelijke DockDeck zonder de rustige mobiele Canon-hiërarchie te verliezen. | Accepted | -| D-038 | Appiconen gebruiken eerst expliciete Unraid-/gebruikersmetadata, daarna een lokaal gebundeld merkicoon en ten slotte een semantisch service-icoon; lettermonogrammen vervallen. | Geeft iedere app een herkenbare visuele identiteit zonder CDN-afhankelijkheid en houdt onbekende of eigen diensten toch betekenisvol. | Accepted | -| D-039 | Instellingen zijn centraal doorzoekbaar; verversingsinterval en verminderde beweging zijn expliciete, persistente voorkeuren die ook in versie 1-export/import meegaan. | Maakt de volledige instellingenoppervlakte vindbaar en voorkomt dat bereikbaarheid of polling alleen als verborgen backendcontract bestaan. | Accepted | -| D-040 | Nieuwe discovery-records krijgen een persistente `discoveredAt`-tijd en `isNew`-status; bestaande databases migreren records als erkend en een zichtbaarheidshandeling erkent een nieuw record. | Houdt de ontdekbanner en het filter “Nieuw ontdekt” waarheidsgetrouw zonder bewust verborgen bestaande apps als nieuw te presenteren. | Accepted | -| D-041 | Dashboard en instellingen delen voortaan dezelfde gecentreerde platformcanvas; vanaf 1900 px wordt de widgetlaag automatisch als rechterdock geopend en de appgrid vult ultrawide ruimte vloeiend. | Herstelt de visuele consistentie met de nette instellingenzijbalk, gebruikt brede schermen doelgericht en houdt widgets zichtbaar zonder de launcher te verdringen. | Accepted | -| D-042 | Een geslaagde Docker/AppOps-inventaris is autoritatief: ontbrekende apprecords worden automatisch uit DockDeck verwijderd en naamvergelijking is hoofdletterongevoelig; bij een degraded inventaris wordt niets opgeschoond. | Houdt Instellingen gelijk aan de werkelijk bestaande containers, herstelt actuele status zonder duplicaten en voorkomt dat tijdelijke integratie-uitval opgeslagen configuratie wist. | Accepted | -| D-043 | Unraid DockerMan-iconen worden uit een minimale read-only hostmount naar een tijdelijke containercache gekopieerd; daarna draait de applicatie als de niet-geprivilegieerde `dockdeck`-gebruiker en serveert alleen gekende afbeeldingsformaten. | De persistente Unraid-iconen zijn root-only en de runtimecache is niet volledig. Een begrensde initkopie levert alle echte iconen zonder hostpermissies te wijzigen of blijvende rootrechten te geven. | Accepted | -| D-044 | Slimme categorie-indeling is een expliciete gebruikersactie met geordende, geteste serviceregels; alleen bekende services worden verplaatst en onbekende containers en favorieten blijven ongewijzigd. | Corrigeert aantoonbare bulkfouten zonder automatische heartbeats of toekomstige discovery de bewuste persoonlijke indeling van onbekende apps te laten overschrijven. | Accepted | - -| D-045 | Wallpapers worden als één gevalideerd lokaal PNG/JPEG/WebP-bestand naast de database opgeslagen; weerdata komt server-side en tien minuten gecachet van Open-Meteo. Publieke healthroutes leveren een zero-secret status, terwijl tokens alleen optionele detailstatistieken ontsluiten. | Houdt beelddata en integraties uit SQLite, exports en browserbundels, voorkomt secretlekken en levert toch een rijke dagelijkse startpagina met minimale externe belasting. | Accepted | -| D-046 | Een wallpaper wordt in een sticky stage ter hoogte van het zichtbare centrale viewport gerenderd. Passing, X-/Y-focus en zoom zijn afzonderlijke persistente voorkeuren met `contain` als veilige standaard. | Voorkomt dat lange dashboardinhoud de afbeelding sterk afsnijdt, houdt de volledige afbeelding standaard zichtbaar en geeft de gebruiker gecontroleerde compositievrijheid zonder het bronbestand te wijzigen. | Accepted | -| D-047 | Zodra een wallpaper bestaat, vervangt die de achtergrondpreset op het volledige platformvlak onder de topbalk. Een scherpe instelbare beeldlaag ligt boven een zachte `cover`-kopie; rails, widgets en tegels zijn leesbare glaslagen erboven. | Laat de afbeelding ononderbroken van de linker- tot de rechterkolom lopen en tegelijk volledig zichtbaar blijven bij `contain`, zonder Blauwdruk-, Aurora- of Minimaal-lagen door de wallpaper heen te mengen. | Accepted | -| D-048 | Canonieke categorie-iconen gebruiken één gedeelde catalogus voor keuzelijst én renderer; iedere huidige categorie resolveert naar een unieke Lucide-component en nieuwe categorieën kiezen eerst een ongebruikt icoon. | Voorkomt dat geldige opgeslagen waarden stil naar hetzelfde generieke `Layers3`-beeld terugvallen en houdt toekomstige categorieën visueel onderscheidbaar zonder data te herschrijven. | Accepted | - -## Decision Rules - -| Rule | Reason | -| --------------------------------------------------------------- | ------------------------------------- | -| Leg architectuurkeuzes vast voordat ze breed doorwerken. | Voorkomt impliciete afhankelijkheden. | -| Kies de kleinste oplossing die aan de must-haves voldoet. | Beschermt scope en snelheid. | -| Heropen beslissingen wanneer requirements of risico's wijzigen. | Houdt het dossier betrouwbaar. | diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index db961210..00000000 --- a/Dockerfile +++ /dev/null @@ -1,29 +0,0 @@ -# syntax=docker/dockerfile:1.7 -FROM node:22-alpine AS build -WORKDIR /app -RUN apk add --no-cache python3 make g++ -COPY package.json package-lock.json ./ -RUN npm ci -COPY . . -RUN npm run build && npm prune --omit=dev - -FROM node:22-alpine AS runtime -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=build --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"] diff --git a/EXPORT_QUALITY.md b/EXPORT_QUALITY.md deleted file mode 100644 index 65ceb385..00000000 --- a/EXPORT_QUALITY.md +++ /dev/null @@ -1,26 +0,0 @@ -# EXPORT_QUALITY.md - -Project: DockDeck -Generated at: 2026-07-13T18:54:16.667Z -Exportprofiel: Codex volledig -Codex startmodus: MVP bouwen - -## Status - -- Resultaat: Klaar voor Codex -- Samenvatting: De huidige export bevat de verwachte bestanden, metadata en startinstructies. - -## Checks - -| Check | Status | -| ---------------------------------------- | ------ | -| Vereiste input ingevuld | Pass | -| Intake gereed voor autonome start | Pass | -| Bestandenset gecontroleerd | Pass | -| Metadata klopt met profiel en startmodus | Pass | -| Startprompt aanwezig | Pass | -| Autonomiegrenzen aanwezig | Pass | - -## Blokkades - -- Geen blokkades. diff --git a/HANDOFF.md b/HANDOFF.md deleted file mode 100644 index 209547bf..00000000 --- a/HANDOFF.md +++ /dev/null @@ -1,173 +0,0 @@ -# HANDOFF.md - -Project: DockDeck -Updated: 2026-07-22 - -## Current status - -Release `9236094` draait vanaf `main` op `192.168.10.150:1218`. De bestaande `.env` bleef byte-identiek, het named volume, de configuratie en de geüploade wallpaper van 3.245.835 bytes zijn behouden. Image `1cf11fc02e7e…` is healthy met 0 herstarts. DockDeck bevat exact 90 bestaande containers, 48 zichtbare apps, 16 categorieën en 1 favoriet. De zeven livepanelen (zes apps plus Tower) zijn allemaal connected. - -## Current update - -De actuele pass corrigeert de resterende widescreenpunten uit een echte Chrome-audit op 3440×1215. De rechter widgetdock is 540 px breed, zichtbare widget- en instellingentekst blijft minimaal 11–12 px, extreem lange beheeroppervlakken zijn begrensd en Quick Launch laadt het echte favoriete favicon. De expliciete actie `Slim herindelen` gebruikt geteste serviceregels en laat onbekende containers en favorieten ongemoeid. Live zijn 14 foutieve categorieën gecorrigeerd; onder meer Deluge/JDownloader2 staan bij Downloads, Glances/unraid-stats bij Monitoring en Nginx Proxy Manager bij Network & Admin. - -De premium afwerkingspass is live voltooid en op 3440×1215 in Chrome gecontroleerd. App Beheer gebruikt een expliciete 52px-track met een knop die 24px tussen links en rechts beweegt. De vier genoemde beveiligings-/automatiseringsdiensten gebruiken compacte launchertegels. De topbalk toont gecachete actuele Open-Meteo-data voor Mol; Weergave bevat locatie-instellingen en een gevalideerde lokale wallpaper-upload. De placeholders DockDeck, GPU-Hot en Deluge zijn in het widgetdock vervangen door werkende compacte Authentik-, NPM- en Vaultwardenpanelen. Plex en Gitea hebben echte read-only providerdata; Home Assistant blijft correct offline zolang de container niet draait. - -De daaropvolgende achtergrondfix herstelt de feitelijke rendering van Aurora, Blauwdruk, Minimaal en de geüploade wallpaper. De persistentie en wallpaper-API waren correct; een niet-bestaande CSS-token maakte de samengestelde achtergrondregels ongeldig en een te zware overlay hield de afbeelding vrijwel volledig tegen. De achtergrond wordt nu rechtstreeks op de centrale canvas samengesteld, met een afzonderlijke leesbare wallpaper-overlay per preset en expliciete E2E-dekking. - -De live controle op 3440×1215 heeft alle drie presets via de instellingeninterface doorlopen. Hun berekende canvaslagen zijn onderling verschillend, de bestaande JPEG blijft in iedere preset aanwezig, er is geen horizontale overflow en Chrome rapporteert geen fouten. Blauwdruk is als actieve gebruikersvoorkeur hersteld. - -De daaropvolgende wallpaperstudio corrigeert de resterende uitsnede: de afbeelding wordt voortaan tegen het zichtbare centrale viewport geschaald in plaats van tegen de volledige scrollhoogte. `Passend` is de veilige standaard; `Vullend`, horizontale/verticale focus en 80–160% zoom zijn via een gecentreerde live preview instelbaar. Deze waarden zijn server-side gevalideerd, persistent, legacy-compatibel en exporteerbaar. - -De live Chrome-hercontrole op 3440×1215 meet een wallpaperstage van 2381×1143 binnen de 3015 px hoge dashboardcanvas en een gecentreerde studiopreview van 1600×686. De afbeelding is volledig geladen met `Passend`, 50/50-focus en 100% zoom; er is geen horizontale overflow en Chrome rapporteert geen fouten. De draftflow `Vullend` → `Herstellen` is getest zonder de opgeslagen voorkeur of wallpaper te wijzigen. - -De aansluitende platformpass haalt de wallpaper uit de middenkolom. De fixed stage begint exact onder de 72px-topbalk, bestrijkt de volledige viewportbreedte en -hoogte, blijft bij scrollen staan en schakelt de presetcompositie uit zolang een wallpaper bestaat. Een ambient `cover`-kopie vult ultrawide zijruimte achter het scherpe instelbare beeld; de linkerrail, apptegels en rechter widgetdock zijn doorlopende glaslagen erboven. De preview weerspiegelt dezelfde compositie. - -De live Chrome-audit op 3440×1215 meet de stage van x=0/y=72 tot de volledige 3425×1143 browsercontentruimte. Bij 900 px scroll blijft de stage exact op y=72, de presetcanvas is transparant, beide zijpanelen gebruiken 62% glas en er is geen horizontale overflow. Chrome rapporteert geen applicatiefouten; alleen de geïnstalleerde WideFrog-extensie schreef één eigen debug-timeout. - -De categorie-icooncorrectie vult de frontendcatalogus aan met de tien eerder ontbrekende geldige waarden. Daardoor tonen Media, Downloads, Books & Documents, Photos, Home & Automation, Security & Identity, Network & Admin, Monitoring, Development & Projects, AI & ML, Databases & Storage, Infrastructure, Games & Emulation, Tools & Productivity, Experiments & Archived en Favorites ieder een eigen passend pictogram. Nieuwe categorieën kiezen automatisch een nog ongebruikte optie. - -De live Chrome-hercontrole bevestigt 16 categoriekaarten, 16 verschillende opgeslagen waarden en 16 verschillende gerenderde SVG-signaturen. De categorie-iconen zijn zowel in Instellingen als bij de dashboardsecties visueel onderscheidend; Chrome rapporteert 0 waarschuwingen of fouten. - -## What works - -- DockDeck Canon v1 als geïntegreerd productoppervlak, gebaseerd op de expliciete Canon-bronnen uit ZIP en Stitch MCP; oudere conflicterende designrichtingen zijn genegeerd. -- Lokale merkassets voor app/favicon/wordmark en lokaal gebundeld Geist; geen CDN- of tijdelijke beeldafhankelijkheid. -- Launcher-first dashboards met Canon-topbar, ontdekbanner, categoriechips, apptegels en states; desktop voegt een sticky favorietenrail met klok, deckstatus en widgetbeheer toe. -- Echte Unraid-/gebruikersiconen blijven eerste keuze; herkenbare lokale merkiconen en semantische service-iconen vervangen alle lettermonogrammen. -- Quick Launch als modal command palette met Apps, Favorieten en Google, toetsenbordnavigatie, Enter/Escape en focusstijl. -- Compact appsbeheer met filters, zichtbaarheid en paneelkeuze in de rij; naam/categorie/URL/icoon/sortering/verwijderen in een focus-trapped drawer met focusherstel. -- Geïsoleerde E2E-poorten 3101/5174 zodat lokale diensten op 3000 de browsertests niet blokkeren. -- Centrale instellingenzoeker met sectiegerichte trefwoorden en scrollherstel bij sectiewissel. -- Persistente verminderde beweging en configureerbare globale pollingcadans, inclusief legacy-databasemigratie en export/import. -- Canon-categoriekaarten met echte iconen/itemtellingen en veilige back-upimport met JSON-/groottevalidatie, dropzone en herstelbevestiging. -- Expliciete slimme categorieorganisatie voor bekende homelabservices, met behoud van onbekende persoonlijke toewijzingen en favorieten. - -- Single-container read-only discovery via AppOps GET, met een gesaneerde Unraid XML/WebUI-metadataweergave en alleen voor andere hosts een optionele restricted-proxyoverlay. -- Nieuwe containers standaard verborgen; online/offline-status en 30-secondenpolling die pauzeert op verborgen tabs. -- Verdwenen containers worden na een geslaagde autoritatieve inventarislezing automatisch uit DockDeck verwijderd; degraded inventarischecks verwijderen niets. Terugkerende/actief geworden containers en hoofdlettervarianten krijgen bij iedere heartbeat hun actuele status zonder dubbele offline records. -- Volledig appbeheer: zichtbaarheid, naam, categorie, sortering, lokale/externe URL-override en icoonfallback. -- Apps kunnen permanent uit DockDeck worden verwijderd en blijven op technische containernaam onderdrukt bij volgende discovery; Docker blijft onaangeraakt. -- Categorieën, favorieten, totaaloverzicht en categorie-tabs. -- Zoekoverlay voor apps/favorieten plus Google-fallback; alle doelen in een nieuw tabblad. -- De zoekindex bevat actuele namen, technische namen, categorieën, images, URL's, verborgen apps, favorieten en Tower; hernoemen werkt zonder stale index. -- Porcelain, Midnight en Harbor, vier accenten, drie dichtheden, drie contentbreedtes en drie achtergronden; alle voorkeuren zijn persistent. -- Vloeiende mobiele, tablet-, desktop- en ultrawide-layouts met optionele hero/statusweergave en lokale fonts. -- Doorzoekbaar appbeheer met zichtbaarheidsfilters, volledig favorietenbeheer en configureerbare categorie-iconen. -- Configureerbare read-only servicewidgets voor maximaal acht containers met compact/standaard/breed formaat, app-specifieke informatieblokken en eerlijke provider-/infrastructuurstatus. -- Onafhankelijke widgetvolgorde, statselectie/-volgorde/-limiet, metricvolgorde, value/gauge/progress-presentatie, custom label, waarschuwing op iedere numerieke metric, refreshcadans, configureerbare lijn-/vlak-/staafgrafiek tot 60 punten en directe toetsenbordtoegankelijke dashboardeditmodus. -- Layoutpresets kunnen los van de volledige backup worden opgeslagen, toegepast, verwijderd, geëxporteerd en geïmporteerd. -- Een standaard inschakelbare Unraid-hostwidget met eigen formaat-/inhoudskeuzes, selecteerbare serverstats en configureerbare grafieken voor containers, images, geheugen, opslag, temperatuur, parity en UPS. -- Native Unraid array-, parity-, disk-/pool-, temperatuur- en optionele UPS-data via minimale read-only mounts. -- Provider-aware read-only verrijking voor AdGuard, Plex, Immich, Home Assistant en Sonarr/Radarr/Lidarr; zonder credential blijft iedere kaart werken met Dockerstats. -- Uitgebreide GET-only providers voor Gitea, Jellyfin, Seerr, Prowlarr, Authentik, NPM, Portainer, Grafana, Prometheus en Nextcloud, plus configureerbare GET-only bridges voor Deluge, qBittorrent, JDownloader, Tdarr, Bazarr en Vaultwarden. -- Widget Studio met zoeken, aanbevolen/alle/actieve filters, zichtbare metricfeatures, credentialstatus, acht-panelencapaciteit en directe activatie. -- Widget Studio dekt alle 97 opgeslagen apps met 97 expliciete live-inventarisprofielen voor applicaties, API's, frontends, workers, databases, caches, mail, GPU, queues en eigen projecten; er rest geen generiek profiel. -- Per app zijn metriclabels zichtbaar/verbergbaar, tot 24 labels rangschikbaar en persistent in SQLite, presets en exports. -- Apps zonder native provider ondersteunen een optionele GET-only JSON-URL en bearer-token rechtstreeks vanuit hun Widget Studio-kaart; secrets blijven server-side. -- Audiobookshelf, Netdata en PeerTube hebben aanvullende native GET-only adapters. -- Tower-naam en Unraid WebUI-URL zijn persistent instelbaar; een URL zonder poort krijgt standaard `5000`. -- Nieuwe providerwidgets voor Ollama en Glances (zero-config via ontdekte URL), plus Tautulli en Paperless-ngx met minimum-scope credential. -- Extra app-specifieke Dockerpresentaties voor AI-runtimes, documentworkflows en beveiligde diensten. -- Provider-specifieke, gemaskeerde credentialformulieren onder Settings → Integrations; writes zijn same-origin, worden nooit geëchood of geëxporteerd en testen de verbinding direct. -- App-specifieke fallbackkaarten: JDownloader toont actuele download-/uploadsnelheid en AdGuard DNS-verkeer plus een duidelijke API-lockstatus; andere servicetypen krijgen eigen labels en betekenisvolle workloadweergave. -- Automatische first-party favicons voor favorieten, met handmatige override en lokale merk-/semantische fallback. -- Informatief desktopzijpaneel met klok, deck health en snelle links; bredere content- en tegelverdeling op 2560/3440-schermen. -- Favorieten staan als faviconlinks in het scrollbare desktopzijpaneel en de Tower-titel opent rechtstreeks de geconfigureerde Unraid-URL. -- Dashboard en instellingen schalen op dezelfde platformbreedte tot ultrawide; vanaf 1900 px staat de widgetlaag als rechterdock open en gebruikt de appgrid de extra ruimte met begrensde kaartbreedtes. -- Quick Launch toont zonder zoekterm functionele snelkoppelingskaarten en gebruikt bij favorietresultaten dezelfde echte favicon-/override-/fallbackketen als het dashboard. -- Dashboardcategorieknoppen filteren in zowel overzichts- als categoriemodus; de geselecteerde categorie beperkt tegelijk apps en favorieten. -- De topbalk behoudt op Dashboard en Instellingen dezelfde merk-, zoek-, navigatie- en actiekolommen. Dashboard/Instellingen hebben zichtbare hover- en focusstates en Instellingen biedt ook handmatige statusverversing. -- Integraties gebruikt geen compacte kaartmatrix meer maar brede configuratieregels met leesbare URL-/credentialvelden. Paneelstudio toont een duidelijke kies-toevoegen-afstemmenflow en één brede service per rij. -- SQLite-persistentie, idempotente eerste migratie en versie 1 export/import zonder secrets. -- Optionele read-only NPM-hostlezing en ondubbelzinnige matching met veilige fallback. -- Productiebuild, health endpoint, Dockerfile, hardened Compose en veilige Gitea-init/pushscript. -- Installable PWA-shell zonder API-cache, freshness/stale-cache en secretvrije operationele diagnostics. - -## Directe Stitch-pass - -- Het dashboard volgt nu rechtstreeks de Canon-volgorde: compacte topbar, begroeting/ontdekking, categoriepilletjes, favorieten, standaard servicekaarten en brede featurekaarten. -- Quick Launch gebruikt de centrale Canon-sheet met gegroepeerde Apps, Favorieten en Google, inclusief toetsenbordselectie. -- App Beheer is de Canon-tabel met status, categorie, zichtbaarheid en actie; de editor is een vaste rechter drawer met zichtbaarheid, categorie en echte URL-contracten. -- Weergave zet de drie themapreviews en de Canon-keuzes voor dashboardmodus/grid-dichtheid in de eerste viewport; aanvullende bestaande voorkeuren blijven lager beschikbaar. -- De mobiele compositie gebruikt de Canon-ontdekkaart, aparte zoekbalk, ronde categorieknoppen, touchvriendelijke kaartfamilies en vaste ondernavigatie. -- Read-only servicepanelen zijn opnieuw een zichtbare Stitch-sectie op desktop en staan op compactere viewports na de launcherinhoud; de volledige Paneelstudio blijft rechtstreeks bereikbaar onder Instellingen → Algemeen. -- Finale screenshots staan in `docs/design/dockdeck-canon-v1/implementation/` en worden door Playwright op 390×844, 768×1024, 1440×900 en 3440×1440 vastgelegd. - -## Run instructions - -Lokaal: - -```bash -cp .env.example .env -npm install -npm run dev -``` - -Open `http://127.0.0.1:5173`. Met `MOCK_DISCOVERY=true` worden deterministische voorbeeldcontainers gebruikt. Lokale data staat in `./data/dockdeck.db`. - -Unraid gebruikt `deploy/unraid.env.example` als `.env`: - -```dotenv -DOCKDECK_BIND_ADDRESS=192.168.10.150 -DOCKDECK_PORT=1218 -DOCKDECK_EGRESS_SUBNET=172.31.240.16/28 -APPOPS_URL=http://192.168.10.150:1216 -UNRAID_TEMPLATES_DIR=/mnt/user/appdata/dockdeck/unraid-templates -UNRAID_HOST=192.168.10.150 -``` - -```bash -python3 deploy/sanitize-unraid-templates.py \ - /boot/config/plugins/dockerMan/templates-user \ - /mnt/user/appdata/dockdeck/unraid-templates -docker compose up -d --build -docker compose ps -sh deploy/refresh-unraid-icon.sh -``` - -De deployment staat in `/mnt/user/appdata/dockdeck`. De base Compose bevat één service/container met de naam `DockDeck`; `docker-compose.socket-proxy.yml` is alleen een fallback voor een host zonder AppOps. De standaardpoort is `1218` en bindt zonder override alleen op `127.0.0.1`. De huidige deployment bindt bewust aan `192.168.10.150` voor het vertrouwde LAN. Bescherm iedere reverse-proxyroute, want de MVP heeft geen login. Persistente data staat in named volume `dockdeck_dockdeck_data`: configuratie in `/data/dockdeck.db` en via Settings ingevoerde secrets in `/data/integrations.env` (`0600`). Bescherm ruwe volumeback-ups. - -## Validation - -```bash -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 -``` - -Laatste gate: format, lint en typecheck groen; 48/48 unit-/integratietests; 5/5 securitytests; 12 uitgevoerde en 12 project-gededupliceerde Playwright-checks op vier viewports; production build; 0 kwetsbaarheden. Op Tower zijn `docker compose config --quiet`, imagebuild en `up -d` geslaagd. De live container is healthy met 0 herstarts, AppOps leest 90 containers GET-only en de browseraudit rapporteert 0 consolefouten/-warnings. - -### Latest local gate - -Format, lint en typecheck zijn groen; 48/48 unit-/integratietests; 5/5 securitytests; 12 uitgevoerde en 12 project-gededupliceerde Playwright-checks op vier viewports; production build; 0 kwetsbaarheden. De clientbuild is 364,55 kB JS (113,69 kB gzip) en 170,68 kB CSS (28,53 kB gzip); de serverbundle is 167,2 kB. De live browseraudit op 1440×900 mat op beide pagina's topbar `left=32`/`height=72`, settings-sidebar `left=0`, geen overflow en een werkende hoverstate. De schone live tab rapporteerde 0 consolefouten/-warnings; alle 5 lokaal ontdekte Unraid-iconen gaven HTTP 200. - -## Environment - -- App: `HOST`, `PORT`, `DATABASE_PATH`, `INTEGRATION_ENV_PATH`, `POLLING_INTERVAL_MS`. -- Deployment: `DOCKDECK_BIND_ADDRESS`, `DOCKDECK_PORT`, `DOCKDECK_EGRESS_SUBNET`. -- Discovery: standaard `APPOPS_URL`; optioneel `DOCKER_PROXY_URL` via de fallbackoverlay; daarnaast `UNRAID_TEMPLATES_PATH`, `UNRAID_ICONS_SOURCE_PATH`, `UNRAID_ICONS_PATH`, `UNRAID_HOST` en `UNRAID_URL`. `MOCK_DISCOVERY` is alleen voor dev/test. -- Optioneel NPM: aanbevolen `NPM_URL` + `NPM_TOKEN`, of `NPM_USERNAME` + `NPM_PASSWORD`. -- Optionele widgets: bestaande providercredentials, `AUDIOBOOKSHELF_TOKEN` en per-app `CUSTOM_WIDGET__URL`/`_TOKEN`; alles is via Widget Studio instelbaar. URL's worden waar mogelijk uit discovery afgeleid. Ollama, Glances, Netdata en PeerTube hebben standaard geen credential nodig. -- Repository: `GITEA_URL`, `GITEA_OWNER`, `GITEA_TOKEN` voor `npm run gitea:init`. - -## Repository status - -- Lokale Git-branch: `codex/stitch-canon-ui-upgrade`. -- Remote: `origin` is geconfigureerd als `gitea-widefrog:NuklearRabbit/DockDeck.git`. -- Private repository: `NuklearRabbit/DockDeck`; SSH-auth werkt en `codex/stitch-canon-ui-upgrade` volgt de gelijknamige gepushte `origin`-branch. - -## Open external checks - -1. Indien gewenst: NPM-token instellen en één ondubbelzinnige externe host controleren. -2. Na wijzigingen aan Unraid-templates: de sanitizer opnieuw uitvoeren en DockDeck synchroniseren. -3. De huidige testrelease zelf beoordelen op `http://192.168.10.150:1218`. De live codevervanging naar commit `41942d5` is uitgevoerd via SSH-alias `unraid-widefrog`; image `af96b8ac7a04...` draait healthy met 0 restarts. De live configuratiecategorieën zijn herverdeeld over 16 categorieën; export-backup staat lokaal in `.codex-input/dockdeck-categories-before-20260722-052942.json`. - -## Rollback - -Gebruik de vorige stabiele Git-commit/image. Herstel `dockdeck_dockdeck_data` uit een beveiligde volumeback-up of importeer een versie 1 configuratie-export. De JSON-export bevat geen secrets; herstel die via Settings/deploymentenvironment of uit de apart beschermde volumeback-up. diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index bcc24605..00000000 --- a/PLAN.md +++ /dev/null @@ -1,52 +0,0 @@ -# PLAN.md - -Goal: De eerste bruikbare versie draait als Docker-container op Unraid en kan als browserstartpagina worden ingesteld. Na de eerste start ontdekt de app de beschikbare Docker-containers, toont ze in een instellingenoverzicht en laat de gebruiker selecteren welke apps op het dashboard verschijnen. Zichtbare apps staan netjes gegroepeerd in configureerbare categorieën, tonen naam, icoon en actuele online- of offline-status en openen hun lokale WebUI in een nieuw tabblad. Favoriete websites kunnen handmatig worden toegevoegd. De zoekbalk vindt apps en favorieten en kan rechtstreeks een Google-zoekopdracht starten. Alle instellingen blijven na een herstart behouden en kunnen worden geëxporteerd en opnieuw geïmporteerd. De totale ervaring moet eruitzien als een afgewerkt premium product dat de gebruiker met plezier dagelijks als startpagina gebruikt. - -## Execution Rules - -- Startmodus: MVP bouwen -- Codex mag scaffolden, bouwen, testen, committen en pushen wanneer de repo en remote duidelijk zijn. -- Bouw de kleinste bruikbare verticale flow eerst. -- Vraag alleen om hulp bij ontbrekende credentials, onduidelijke productkeuzes of risicovolle externe acties. -- Werk in kleine, verifieerbare stappen. -- Houd requirements, spec, beslissingen, risico's en handoff actueel. -- Respecteer runtime-, sync-, persistentie-, deploy- en repo-keuzes uit SPEC.md en DECISIONS.md. -- Test voor overdracht en commit. -- Commit pas wanneer de relevante quality gate is gehaald. - -## Task Table - -| Phase | Task | Done When | Status | -| -------- | ------------------------------------------------------------------- | ------------------------------------------ | ------ | -| Intake | Valideer briefing, aannames en open vragen. | PROJECT_BRIEF.md is actueel. | Done | -| Design | Werk requirements en SPEC.md bij. | Belangrijke keuzes staan in DECISIONS.md. | Done | -| Build | Implementeer de kleinste bruikbare verticale stap. | Must-haves worden aantoonbaar ondersteund. | Done | -| Test | Run unit, integratie en handmatige checks passend bij de wijziging. | TEST_LOG.md bevat resultaat en datum. | Done | -| Document | Werk handoff, risico's en gebruikersnotities bij. | Volgende stap is duidelijk. | Done | -| Commit | Review diff en commit zonder secrets. | Commit bevat alleen bedoelde wijzigingen. | Done | - -## Current Phase - -De volledige visuele audit van DockDeck Canon v1 is afgerond en releasecommit `5e411d1` draait op Unraid via `192.168.10.150:1218`. De dagelijkse launcher gebruikt de Stitch-hiërarchie met desktopfavorietenrail, echte appiconen, een compacte uitklapbare widgetlaag en een viewportbrede Quick Launch. Alle instellingen blijven beschikbaar, maar grote catalogi en geavanceerde personalisatie zijn progressief ontsloten; mobiel en tablet gebruiken een horizontale instellingenbalk. Lokale quality gate, Compose-config, imagebuild, persistentie, read-only integraties en finale live browseraudit zijn groen. - -## Current Phase Update - -De inventaris- en instellingenpass is live afgerond. Een verbonden AppOps/Docker-heartbeat heeft 139 historische records naar exact 90 bestaande containers gereconcilieerd: 0 stale namen, 0 ontbrekende namen, 0 dubbele namen en 0 statusafwijkingen. Dashboardfilters werken onafhankelijk van de weergavemodus. Dashboard en Instellingen delen exact dezelfde topbalkgeometrie en navigatiestates; Integraties gebruikt leesbare formulierregels en Paneelstudio een brede driestappenflow. Alle lokaal ontdekte Unraid-iconen geven live HTTP 200 vanuit de begrensde tijdelijke cache. - -De daaropvolgende echte Chrome-audit op 3440×1215 vond geen overflow of browserfouten, maar wel resterende microtypografie van 6–8 px, te lange instellingenrijen en foutieve categorieën voor onder meer Deluge, JDownloader2, Glances en Nginx Proxy Manager. Release `16bd8c7` corrigeert dit live met een 540px-widgetdock, leesbare cascade guard, begrensde instellingenoppervlakken en expliciete veilige categorieorganisatie. De lokale gate, Tower Compose/imagebuild, read-only heartbeat en tweede live Chrome-audit zijn groen; de testtab blijft open voor gebruikerscontrole. - -De huidige afwerkingspass voegt een echte links/rechts-schakelaar toe aan App Beheer, maakt Home Assistant, Authentik, Vaultwarden en Nginx Proxy Manager compact, en verrijkt de centrale launcher met subtiele omgevingsdiepte, een lokale wallpaper-upload en een configureerbare Open-Meteo-badge voor Mol. Publieke GET-healthchecks geven Authentik, NPM, Vaultwarden en Home Assistant zonder secret al een eerlijke status; minimum-scope tokens blijven optioneel voor detailstatistieken. - -De achtergrondcorrectie maakt de drie opgeslagen achtergrondmodi daadwerkelijk zichtbaar en rendert de lokale wallpaper in de centrale dashboardcanvas. Een end-to-end browsertest wisselt alle presets, vergelijkt hun berekende achtergronden, uploadt via de echte instellingenflow en controleert vervolgens `has-wallpaper` en de server-URL in de zichtbare CSS-compositie. - -De responsieve wallpaperpass koppelt het beeld los van de totale scrollhoogte en rendert het in een sticky viewportstage binnen het centrale vlak. Weergave bevat nu een begrensde live preview met Passend/Vullend, horizontale en verticale focus, zoom en herstel; alle keuzes migreren idempotent, blijven persistent en reizen mee in versie 1-back-ups. - -De platformbrede wallpaperpass verplaatst die stage uit de middenkolom naar een fixed laag van direct onder de topbalk tot de viewportonderrand. Een wallpaper neutraliseert voortaan de actieve achtergrondpreset, loopt achter linkerrail, appcanvas en rechter widgetdock door en gebruikt bij `Passend` een zachte ambient-kopie om ultrawide zijruimte zonder harde kleurvlakken te vullen. - -De categorie-icoonpass verbindt alle zestien live opgeslagen iconen met een eigen Lucide-component en dezelfde volledige keuzecatalogus in Instellingen. Nieuwe categorieën starten met een nog ongebruikt pictogram; onbekende geïmporteerde waarden behouden een veilige generieke fallback. - -## Verification Rules - -- Testing expectations: Unit tests voor URL-normalisatie, categorie- en sorteervoorwaarden, zoekresultaten, configuratievalidatie, import- en exportversies, container-naar-Nginx-hostmatching en permissiegrenzen; backend-integratietests met gemockte Unraid-templategegevens, Docker Socket Proxy en Nginx Proxy Manager; SQLite-migratie- en persistentietests; tests die aantonen dat geen Docker-mutatie-endpoints of beheeracties worden gebruikt; browsertests voor eerste ingebruikname, containers zichtbaar maken, categorieën beheren, favorieten toevoegen, zoeken, Google-fallback, thema wisselen, weergavemodus wijzigen, URL openen en configuratie exporteren en importeren; visuele UI-smoketests voor kernschermen zoals dashboard, categorie-overzicht en instellingen; Docker-buildtest en Compose-smoketest; een live read-only validatie op Unraid waarbij discovery, status en lokale WebUI-URL's worden gecontroleerd zonder containers te wijzigen. -- Quality bar: De interface moet eruitzien als een professioneel premium product en niet als een simpel hobbydashboard. Verwacht een moderne, luxueuze en verzorgde UI met uitgebalanceerde witruimte, consistente kaartcomponenten, hoogwaardige iconografie, duidelijke typografie, nette schaduwen of diepte waar passend, subtiele animaties, verzorgde empty states, sterke visuele hiërarchie en een kalme maar rijke look-and-feel. De belangrijkste startpagina moet snel laden en direct bruikbaar zijn wanneer integraties traag of tijdelijk offline zijn. Fouten moeten begrijpelijk worden getoond zonder technische stacktraces. Ontbrekende iconen of URL's moeten nette fallbacks hebben. Instellingen moeten veilig opgeslagen worden en bij foutieve invoer gevalideerde feedback geven. De interface moet responsief zijn voor desktop, tablet en basisgebruik op mobiel. Toetsenbordnavigatie, zichtbare focus, semantische HTML en voldoende kleurcontrast zijn vereist. Statuspolling mag geen zichtbare flikkering veroorzaken en moet stoppen wanneer de pagina niet actief is of verstandig worden vertraagd. De code moet modulair, getypeerd, gedocumenteerd en zonder onnodige complexiteit zijn. Esthetische kwaliteit is voor deze MVP een kernvereiste en niet louter een extra. -- Noteer niet-geteste onderdelen expliciet in TEST_LOG.md. diff --git a/PROJECT_BRIEF.md b/PROJECT_BRIEF.md deleted file mode 100644 index b5b85c82..00000000 --- a/PROJECT_BRIEF.md +++ /dev/null @@ -1,39 +0,0 @@ -# PROJECT_BRIEF.md - -## Project - -- Projectnaam: DockDeck -- Korte beschrijving: Een premium ogend persoonlijk startdashboard voor Unraid dat Docker-apps automatisch ontdekt, stijlvol groepeert en snelle navigatie biedt naar lokale diensten en eigen favorieten. -- Projectidee: Bouw een persoonlijke webapp die als standaard startpagina van de browser dient en een prachtig, professioneel en strak afgewerkt overzicht geeft van de diensten op de Unraid-server. De app ontdekt Docker-containers automatisch via Unraid en een beperkte read-only Docker-socketproxy, maar toont nieuwe containers niet automatisch op het hoofddashboard. In het instellingenmenu kan de gebruiker per container zichtbaarheid, categorie, sortering, icoon, naam en URL beheren. Voor Docker-apps wordt standaard de lokale WebUI-URL uit de Unraid-configuratie gebruikt. Daarnaast kunnen handmatig favoriete externe websites worden toegevoegd met naam, URL, icoon, categorie en sorteerpositie. Het dashboard bevat een centrale zoekbalk die zowel zichtbare Docker-apps als favorieten doorzoekt en, wanneer geen lokaal resultaat wordt gekozen, de invoer als Google-zoekopdracht in een nieuw tabblad opent. De gebruiker kan kiezen tussen één overzicht met categorieën onder elkaar en een weergave met afzonderlijke categoriepagina's of tabbladen. Thema's, weergavevoorkeuren en openingsgedrag worden via instellingen beheerd. Externe toegang kan optioneel worden ingeschakeld; wanneer die actief is, haalt een read-only koppeling met Nginx Proxy Manager beschikbare externe URL's op en koppelt die aan de juiste apps, met steeds een handmatige override als terugval. De UI moet aanvoelen als een hoogwaardige, moderne productinterface met sterke visuele hiërarchie, nette details, subtiele animaties en een verzorgde dashboardervaring. -- Probleem: De gebruiker heeft meerdere Docker-diensten op een Unraid-server en moet momenteel afzonderlijke URL's onthouden, bladwijzers beheren of via verschillende beheerinterfaces navigeren. Bestaande startpagina's sluiten niet volledig aan op de gewenste combinatie van automatische Unraid-detectie, configureerbare zichtbaarheid, categorie-indeling, statusweergave, lokale en externe URL's, favoriete websites, geïntegreerde zoekfunctie en een echt professioneel ogende premium UI. -- Doelgebruikers: Eén primaire gebruiker: de eigenaar en beheerder van de Unraid-server. Deze gebruiker verwacht zonder technische omwegen snel naar lokale Docker-diensten en favoriete websites te kunnen navigeren, zelf te bepalen welke apps zichtbaar zijn, categorieën en sortering te beheren, de startpagina visueel aan te passen en optioneel lokale of externe app-URL's te gebruiken. Er zijn in de MVP geen afzonderlijke gebruikersrollen, accounts of gedeelde profielen. -- Gewenste uitkomst: De eerste bruikbare versie draait als Docker-container op Unraid en kan als browserstartpagina worden ingesteld. Na de eerste start ontdekt de app de beschikbare Docker-containers, toont ze in een instellingenoverzicht en laat de gebruiker selecteren welke apps op het dashboard verschijnen. Zichtbare apps staan netjes gegroepeerd in configureerbare categorieën, tonen naam, icoon en actuele online- of offline-status en openen hun lokale WebUI in een nieuw tabblad. Favoriete websites kunnen handmatig worden toegevoegd. De zoekbalk vindt apps en favorieten en kan rechtstreeks een Google-zoekopdracht starten. Alle instellingen blijven na een herstart behouden en kunnen worden geëxporteerd en opnieuw geïmporteerd. De totale ervaring moet eruitzien als een afgewerkt premium product dat de gebruiker met plezier dagelijks als startpagina gebruikt. -- Succescriteria: De MVP is klaar wanneer: de applicatie via Docker Compose op Unraid kan worden gestart; beschikbare Docker-containers automatisch worden ontdekt zonder schrijf- of beheerrechten op Docker; nieuw ontdekte containers standaard verborgen blijven; de gebruiker containers zichtbaar of verborgen kan maken; naam, icoon, categorie, sortering en lokale URL per app kunnen worden aangepast; de lokale WebUI-URL waar mogelijk automatisch uit Unraid-templategegevens wordt afgeleid; zichtbare apps naam, icoon en correcte online- of offline-status tonen; status bij het openen en daarna ongeveer elke 30 seconden wordt vernieuwd; categorieën kunnen worden aangemaakt, hernoemd, gesorteerd en toegewezen; zowel een volledig overzicht als categoriegebaseerde navigatie configureerbaar is; favorieten met naam, URL, icoon, categorie en sortering kunnen worden beheerd; de zoekbalk apps en favorieten doorzoekt en overige invoer naar Google stuurt; alle links in een nieuw tabblad openen; licht, donker en minstens één aanvullend thema selecteerbaar zijn; configuratie persistent blijft na container- en serverherstart; back-up en herstel via export en import werken; de app uitsluitend voor lokaal gebruik is ingericht en geen container start-, stop-, restart- of wijzigingsacties bevat; een private Gitea-repository automatisch wordt aangemaakt, lokaal wordt geïnitialiseerd en een eerste bruikbare commit bevat; en de UI een consistent, professioneel en visueel hoogwaardig geheel vormt met nette kaartlay-outs, sterke typografie, duidelijke statusindicatoren, vloeiende maar subtiele interacties en een verzorgde instellingenervaring. - -## Scope - -- Must-haves: Automatische read-only ontdekking van Unraid Docker-containers; nieuwe containers standaard verborgen; instellingenpagina voor zichtbaarheid, naam, icoon, categorie, sortering en URL-overrides; automatische afleiding van lokale WebUI-URL's uit Unraid-templategegevens; read-only containerstatus met verversing bij laden en ongeveer elke 30 seconden; configureerbare categorieën; configureerbare keuze tussen één totaaloverzicht en categoriepagina's of tabbladen; handmatige favorieten voor externe websites; centrale zoekbalk voor apps, favorieten en Google; openen van alle doelen in een nieuw tabblad; licht, donker en aanvullende ingebouwde thema's; persistent bewaren van instellingen in SQLite; export en import van configuratie; Docker- en Docker Compose-configuratie voor Unraid; beperkte Docker-socketproxy zonder beheerrechten; optionele read-only Nginx Proxy Manager-koppeling voor externe URL's met handmatige fallback; duidelijke foutstatus wanneer Unraid, Docker-proxy of Nginx Proxy Manager tijdelijk onbereikbaar is; automatische initialisatie van een private Gitea-repository via environmentvariabelen; en een uitzonderlijk verzorgde professionele UI met premium dashboardlook, consistente componenten, strak kaartdesign, goede witruimte, duidelijke visuele hiërarchie, subtiele hover- en transition-effecten, nette iconografie en een afgewerkte instellingenomgeving. -- Nice-to-haves: Drag-and-drop sortering van apps en categorieën; automatisch herkennen van bekende applicatie-iconen; favicon-import voor favorieten; meerdere aanvullende kleurthema's; compacte en ruime tegelweergave; achtergrondafbeeldingen of subtiele dashboard-achtergronden; sneltoets om de zoekbalk te focussen; recente of meest gebruikte apps; optionele statuslatentie en responstijd; import van bestaande browserbladwijzers; meerdere dashboards of profielen; PWA-installatie; mobiele optimalisaties buiten de basisresponsiviteit; geavanceerde automatische matching tussen Nginx Proxy Manager-hosts en Docker-containers; handmatige statuscontrole voor favoriete websites; extra micro-interacties en visuele personalisatieopties zolang die de strakke premium uitstraling ondersteunen. -- Buiten scope: Containers starten, stoppen, herstarten, verwijderen of wijzigen; Docker Compose-stacks beheren; Unraid-systeembeheer; terminal- of shelltoegang; logviewer; resourcegrafieken en uitgebreide monitoring; notificaties en incidentbeheer; publieke internettoegang configureren; Authentik- of andere SSO-integratie; eigen gebruikersaccounts, rollen of multi-userondersteuning; opslag van echte API-tokens in de repository of browser; automatische wijzigingen aan Nginx Proxy Manager; cloudhosting; mobiele native apps; AI-functionaliteit; synchronisatie tussen meerdere Unraid-servers; automatische publicatie van diensten naar het internet. -- Constraints: De MVP moet als Docker-container op de bestaande Unraid-server draaien en alleen vanaf het lokale netwerk bereikbaar zijn. De app moet zonder eigen login bruikbaar zijn, omdat netwerktoegang de primaire beveiligingsgrens vormt. Alle Docker-toegang moet read-only verlopen via een beperkte socketproxy; de hoofdcontainer krijgt geen directe onbeperkte Docker-socket. Unraid-templategegevens mogen alleen worden gelezen. Nginx Proxy Manager wordt uitsluitend read-only benaderd. De applicatie moet bruikbaar blijven wanneer Nginx Proxy Manager niet is geconfigureerd of tijdelijk niet bereikbaar is. Er mogen geen betaalde externe diensten nodig zijn. Alle configuratie moet persistent blijven bij herstart en eenvoudig te back-uppen zijn. Codex moet een nieuwe private repository in de persoonlijke Gitea-omgeving aanmaken en mag geen secrets hardcoderen. Visuele kwaliteit is belangrijk, maar de UI mag niet onnodig zwaar of traag worden; professionele polish moet samengaan met vlotte prestaties als browserstartpagina. - -## Eerste Aannames - -| Aanname | Status | -| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | -| Het projecttype is webapp. | Te valideren | -| De voorkeursstack is TypeScript als hoofdtaal; React met Vite voor de frontend; Node.js met Fastify voor de backend; SQLite met Drizzle ORM voor persistentie en migraties; gedeelde types en validaties met Zod; een eenvoudige REST-API tussen frontend en backend; een zorgvuldig opgebouwd design system met design tokens, CSS-variabelen en herbruikbare UI-componenten; een moderne componentbibliotheek of headless aanpak voor toegankelijke basiscomponenten; iconenset van hoge kwaliteit; subtiele motion via lichte animatielogica; Docker multi-stage build en Docker Compose voor Unraid; Vitest voor unit- en integratietests; Playwright voor browsertests. Gebruik een overzichtelijke repository met duidelijk gescheiden frontend-, backend- en shared-mappen. Richt de frontend expliciet in op een premium dashboardervaring met consistente spacing, kaartsystemen, typografie, kleurgebruik, statusbadges en instellingenpanelen.. | Te valideren | -| Runtime: Interactieve app. | Te valideren | -| Update/sync: Polling. | Te valideren | -| Persistentie: SQLite. | Te valideren | -| Deployment: Docker op Unraid. | Te valideren | -| Repository: Repo nog nodig. | Te valideren | -| De primaire gebruikers zijn Eén primaire gebruiker: de eigenaar en beheerder van de Unraid-server. Deze gebruiker verwacht zonder technische omwegen snel naar lokale Docker-diensten en favoriete websites te kunnen navigeren, zelf te bepalen welke apps zichtbaar zijn, categorieën en sortering te beheren, de startpagina visueel aan te passen en optioneel lokale of externe app-URL's te gebruiken. Er zijn in de MVP geen afzonderlijke gebruikersrollen, accounts of gedeelde profielen.. | Te valideren | - -## Open Questions - -| Vraag | Waarom dit nodig is | -| ----------------------------------------------------------------- | ------------------------------------ | -| Welke randgevallen moeten expliciet ondersteund worden? | Maakt requirements toetsbaar. | -| Welke data mag lokaal, extern of helemaal niet opgeslagen worden? | Bepaalt security en privacy ontwerp. | -| Welke definitie van klaar geldt voor de eerste release? | Houdt scope en planning scherp. | diff --git a/README.md b/README.md index 827c0beb..378dbf5a 100644 --- a/README.md +++ b/README.md @@ -1,139 +1,292 @@ -# DockDeck +# GeoIntel Belgium and the Belgian North Sea -DockDeck is a calm, premium personal start dashboard for Unraid. Its normal Unraid profile is one branded application container, reads container inventory through the existing AppOps GET API and enriches it with sanitized local WebUI metadata. +GeoIntel is a map-first GeoAI Workbench for Belgium and the Belgian North Sea. +It combines governed official-source coverage, raster/vector processing, +historical comparison, computer vision, QA/QC and geospatial exports. -## What the MVP includes +Mol and the Kempen remain deep regression and model-validation references. The +release scope is all of Belgium plus legally labelled Belgian maritime zones; +source coverage remains explicit per theme and jurisdiction. -- automatic read-only container discovery with new apps hidden by default; -- live online/offline status and 30-second visibility-aware polling; -- editable app name, icon fallback, category, order, local URL and external URL fallback; -- configurable categories, favorites and overview/category navigation; -- fast local search with a Google fallback, always opening in a new tab; -- Porcelain, Midnight and Harbor themes, four accents, three density and content-width profiles, and selectable backgrounds; -- responsive layouts from compact mobile screens through tablets, desktops and ultrawide displays; -- optional dashboard hero and status badges, plus editable category and favorite icons; -- up to eight configurable service widgets with independent order, compact/standard/wide layouts, custom labels, metric order, per-metric value/gauge/progress presentation, primary metric, any-signal warning thresholds and refresh cadence; -- line, area or bar charts with a selectable app/provider series, legend and 10–60 in-memory samples; -- a default native Unraid widget with configurable statistics and charts for Docker inventory, array, parity, disk/pool, temperature and optional UPS status; -- rich GET-only providers for AdGuard, Plex, Immich, Home Assistant, Sonarr/Radarr/Lidarr, Ollama, Glances, Tautulli, Paperless, Gitea, Jellyfin, Seerr, Prowlarr, Authentik, NPM, Portainer, Grafana, Prometheus and Nextcloud; -- optional GET-only metrics bridges for Deluge, qBittorrent, JDownloader, Tdarr, Bazarr and Vaultwarden; -- a searchable Widget Studio that explains available metrics, recommends useful panels and activates up to eight widgets; -- persistent per-app metric visibility, order, presentation and card limits, with a tailored profile for every discovered app even before its optional data endpoint is connected; -- native Audiobookshelf, Netdata and PeerTube readers plus an optional GET-only JSON endpoint for every custom or niche app; -- an editable Tower name and Unraid WebUI URL that defaults to port `5000`; -- masked provider credential forms in Settings, with immediate read-only connection testing and no secret echo; -- automatic first-party favorite favicons with a resilient local monogram fallback; -- a useful desktop sidebar with time, deck health and quick service links; -- SQLite persistence plus versioned JSON export/import; -- reusable layout presets with independent import/export, direct dashboard edit mode and secret-free diagnostics; -- an installable PWA shell that explicitly excludes runtime API data from caching; -- optional read-only Nginx Proxy Manager host matching; -- one branded, read-only DockDeck container with a true transparent PNG Unraid-list icon, WebUI metadata and no Docker-socket mount. +GeoIntel is not a generic dashboard or chatbot. The core product is: -## Local development +> data → processing → geospatial output → QA/QC → export -Requirements: Node.js 22 or newer and npm 10 or newer. +## Current milestone + +**v1.0.0 - Belgium/North Sea release** + +The canonical release controls are: + +- `docs/00-start/START_HERE.md` +- `docs/RC_SCOPE_FREEZE_BELGIUM_NORTH_SEA.md` +- `docs/RC_ROADMAP_BELGIUM_NORTH_SEA.md` +- `docs/RELEASE_RUNBOOK.md` +- `docs/KNOWN_LIMITATIONS.md` +- `docs/DEFINITION_OF_DONE.md` + +Older milestone and sprint handoff files remain historical evidence. They do +not override the active national/maritime scope freeze or RC roadmap. + +## Core V1 vertical slice + +The first implementation target is: + +1. Project + Area creation. +2. Dataset registration/upload and metadata extraction. +3. Reference building layer loading. +4. Predicted detection layer loading/import. +5. QA/QC matching against reference polygons. +6. Metrics and false positive/false negative outputs. +7. GeoJSON export. +8. Minimal map/workbench UI. + +## Primary stack + +- Frontend: React, TypeScript, MapLibre GL, Deck.gl, Tailwind. +- Backend: FastAPI, Python. +- Database: PostgreSQL + PostGIS. +- GIS processing: GeoPandas, Shapely, Rasterio, PyProj, GDAL. +- AI: PyTorch, Ultralytics YOLO, SAM-compatible architecture. +- Jobs: Redis + RQ. +- Storage: local filesystem first, MinIO-compatible later. + +## Codex instructions + +Codex must start with: + +1. `docs/00-start/START_HERE.md` +2. `prompts/codex/M11_ARCHITECT_MASTER_PROMPT.md` + +Then follow the build order in: + +- `docs/build/BUILD_ORDER_DEPENDENCY_GRAPH.md` +- `docs/build/CODEX_OPERATING_SYSTEM.md` + +Before every implementation pass, run available preflight/smoke scripts where applicable. + +## Repo principle + +This is a documentation-driven engineering repo. The documentation is not decorative; it is the control system for autonomous implementation. + +## Fastest Day 1 command path ```bash -cp .env.example .env -npm install -npm run dev +make readiness ``` -Open `http://127.0.0.1:5173`. The example environment enables deterministic discovery fixtures. Runtime data is written to `./data/dockdeck.db`. +## Unraid / Tower deployment -Quality commands: +GeoIntel runs on Unraid as an all-in-one DockerMan-native container. The container embeds PostGIS, runs the FastAPI backend internally, and serves the frontend through nginx on one editable web port. + +Unraid template assets live in: + +- `deploy/unraid/geointel.env.example` +- `deploy/unraid/geointel-unraid-template.xml` +- `deploy/unraid/geointel-icon.svg` +- `deploy/unraid/geointel-icon.png` +- `docker-compose.unraid.yml` + +Copy the Unraid env template to `.env` in the checkout and edit ports/paths there: ```bash -npm run format:check -npm run lint +cd /mnt/user/appdata/geointel +cp deploy/unraid/geointel.env.example .env +nano .env +docker build -f deploy/unraid/Dockerfile.all-in-one -t geointel-all-in-one:latest . +bash deploy/unraid/run-dockerman-container.sh +``` + +Common editable values: + +```env +GEOINTEL_FRONTEND_PORT=1202 +GEOINTEL_STORAGE_PATH=/mnt/user/appdata/geointel/storage +GEOINTEL_POSTGIS_DATA_PATH=/mnt/user/appdata/geointel/postgres-data +``` + +The backend and PostGIS ports are intentionally not exposed to the LAN in the all-in-one runtime. See `deploy/unraid/README.md` for full setup, port-change and cleanup notes. + +On Tower/Unraid, `scripts/deploy_tower.ps1` and `scripts/deploy_tower.sh` validate the Compose reference but build with plain `docker build`, then automatically install the editable DockerMan template as `/boot/config/plugins/dockerMan/templates-user/my-geointel.xml`, install the PNG icon as `/boot/config/plugins/dockerMan/images/geointel-icon.png`, remove any old Compose-owned `geointel` container and start the final container with DockerMan labels. + +## Sprint 2 quick start + +- Update dependencies: + +```bash +python -m pip install -e backend/.[dev] +cd frontend && npm install +``` + +- Run full readiness checks (with no scope expansion): + +```bash +python -m compileall backend/app +cd backend && python -m pytest +cd ../frontend && npm run typecheck && npm run build +bash scripts/run_readiness_check.sh +``` + +- Raster workflow validation command (backend only): + +```bash +bash scripts/smoke_backend_import.sh +cd backend && python -c "from app.main import app; print(app.title)" +``` + +If `rasterio` is not installed, raster metadata endpoints return `RASTER_PROCESSING_UNAVAILABLE` and the frontend displays the +state as failed until the dependency is added. + +## Sprint 4 raster foundation + +- Raster operations now support: + - raster metadata extraction, + - raster preview generation, + - raster clip by area (with provenance on derived datasets), + - raster tile generation with manifest output. +- Raster services are dependency-aware: + - if `rasterio` is unavailable, endpoints return `RASTER_PROCESSING_UNAVAILABLE`. + - if preview dependencies (`numpy`, `pillow`) are unavailable, preview generation is unavailable with a clear error. +- Enable raster stack explicitly when needed: + +```bash +cd backend && python -m pip install -e .[dev,raster] +``` + +## Sprint 5 raster analytics hardening + +- Added raster band statistics (min/max/mean/std, nodata ratio/count, valid pixel count, dtype, optional histograms). +- Added raster reproject workflow with CRS validation and provenance persistence. +- Extended tile manifest expectations (`tile_set_id`, `tile_size`, `overlap`, `bounds`, `source_raster_id`, `tile_paths`, `tile_server`). +- Clarified raster operation availability in frontend/backend docs (`RASTER_PROCESSING_UNAVAILABLE` and invalid-CRS cases). + +- Raster workflow command set (where available): + +```bash +cd backend +python -m pip install -e .[dev,raster] +python -m pytest +cd ../frontend npm run typecheck -npm test -npm run test:e2e npm run build ``` -## Unraid / Docker Compose +Then give Codex the prompt in: -Create an `.env` beside `docker-compose.yml`: +- `prompts/codex/final/DAY_1_MASTER_PROMPT.md` -```dotenv -DOCKDECK_BIND_ADDRESS=192.168.10.150 -DOCKDECK_PORT=1218 -DOCKDECK_EGRESS_SUBNET=172.31.240.16/28 -APPOPS_URL=http://192.168.10.150:1216 -UNRAID_TEMPLATES_DIR=/mnt/user/appdata/dockdeck/unraid-templates -UNRAID_HOST=192.168.10.150 -UNRAID_URL=http://192.168.10.150:5000 -``` -For the current Unraid host, `deploy/unraid.env.example` contains this non-secret profile and can be copied to `.env`. It pins a small egress subnet because this Unraid host has exhausted its automatic address pools; change it if the host network layout changes. +## M13 Codex optimization -Unraid keeps its source templates root-only and templates can contain sensitive values. Create DockDeck's sanitized metadata mirror before starting Compose; the helper copies only `Name`, `WebUI` and `Icon`: +For the first serious Codex build run, use: + +- `prompts/codex/m13/DAY_1_OPTIMIZED_MASTER_PROMPT.md` + +Codex should also use the relevant reusable skill under `skills/` for each implementation pass. Validate the optimization assets with: ```bash -python3 deploy/sanitize-unraid-templates.py \ - /boot/config/plugins/dockerMan/templates-user \ - /mnt/user/appdata/dockdeck/unraid-templates +make m13 ``` -Then run. The base Compose file creates exactly one container named `DockDeck`: +The full readiness path remains: + +```bash +make readiness +``` + + +## M14 Build Launch + +For the first serious implementation run, use: + +- `docs/40-build-launch/SPRINT_1_SCOPE_FREEZE.md` +- `docs/40-build-launch/BUILD_SUCCESS_DEFINITION.md` +- `docs/40-build-launch/CODEX_STOP_RULES.md` +- `prompts/codex/m14/CODEX_FIRST_DAY_MASTER_PROMPT.md` + +Validate launch assets with: + +```bash +make m14 +``` + +Full readiness remains: + +```bash +make readiness +``` + +## Sprint 1 execution (Sprint 1 only) + +From a clean machine: + +```bash +cd backend && python -m pip install -e .[dev] +cd .. +make backend-install +make frontend-install +make readiness +``` + +Copy `.env.example` to `.env` only when you want local overrides. Docker Compose has safe defaults for the local PostGIS/backend/frontend stack and does not require a root `.env` file to exist. + +With Docker Compose, open the workbench at `http://localhost:1202`. + +The Docker frontend is served by nginx and proxies `/api` and `/health` to the backend container, so browser clients should use the frontend URL only, for example `http://192.168.10.150:1202` on a LAN host. + +Runtime containers include healthchecks for PostGIS, backend and frontend. After +startup, inspect them with: ```bash -docker compose up -d --build docker compose ps -sudo sh deploy/refresh-unraid-icon.sh ``` -The final helper validates that the configured `` URL returns a real PNG and replaces both DockDeck-specific caches used by Unraid DockerMan: the persistent cache and the active RAM cache. Run it after changing the icon or recreating the container. It never changes Docker state or touches another container's files. An explicit URL can be supplied as the second argument when no user template exists. - -On a host without AppOps, use the optional hardened fallback overlay. Only that additional proxy sees the socket and it disables POST and all unused API families: +Verify the browser-facing API proxy after rebuilding Docker images: ```bash -docker compose -f docker-compose.yml -f docker-compose.socket-proxy.yml up -d --build +bash scripts/verify_browser_runtime.sh http://localhost:1202 http://localhost:8000/health ``` -By default Compose publishes DockDeck only on `127.0.0.1:1218`. For this Unraid host, set `DOCKDECK_BIND_ADDRESS=192.168.10.150` so the trusted LAN can reach `http://192.168.10.150:1218`. DockDeck has no built-in authentication; do not publish it directly to the internet. +Verify the Docker GIS runtime after rebuilding the backend image: -Persistent configuration lives in the named volume `dockdeck_data`. Normal preferences use `/data/dockdeck.db`; credentials entered through Settings use the permission-restricted `/data/integrations.env`. Protect volume backups because that environment file contains plaintext secrets. The JSON export never includes it. +```bash +bash scripts/verify_gis_runtime.sh http://localhost:1202 +``` -## Environment variables +On the LAN host use the published browser URL, for example: -| Variable | Purpose | -| ------------------------------------------------------- | --------------------------------------------------------------------------------------------- | -| `DATABASE_PATH` | SQLite file path; defaults to `./data/dockdeck.db` locally and `/data/dockdeck.db` in Docker. | -| `INTEGRATION_ENV_PATH` | Permission-restricted environment file used for credentials entered in Settings. | -| `DOCKDECK_BIND_ADDRESS`, `DOCKDECK_PORT` | Host interface and port published by Compose; safely default to `127.0.0.1:1218`. | -| `DOCKDECK_EGRESS_SUBNET` | Explicit small subnet for the app's outbound network. | -| `APPOPS_URL` | Existing AppOps origin used for GET-only inventory and measured CPU/memory status. | -| `DOCKER_PROXY_URL` | Optional internal URL used only by the socket-proxy fallback overlay. | -| `UNRAID_TEMPLATES_PATH` | Read-only XML template mount inside the app. | -| `UNRAID_HOST` | Host used to resolve `[IP]` in WebUI templates; defaults to `tower.local`. | -| `UNRAID_URL` | Initial Tower/Unraid link; Settings can override it and a missing port defaults to `5000`. | -| `UNRAID_RUNTIME_PATH`, `UNRAID_HWMON_PATH` | Container paths for minimum read-only Unraid runtime and hardware-monitor mounts. | -| `UNRAID_UPS_STATUS_PATH` | Optional read-only APC/UPS status file inside the app container. | -| `MOCK_DISCOVERY` | Enables deterministic local/test fixtures; never enable for production discovery. | -| `NPM_URL`, `NPM_TOKEN` | Optional server-only Nginx Proxy Manager read credential. | -| `NPM_USERNAME`, `NPM_PASSWORD` | Optional server-only alternative for requesting an NPM session token. | -| `ADGUARD_URL`, `ADGUARD_USERNAME`, `ADGUARD_PASSWORD` | Optional AdGuard Home statistics for its selected widget. | -| `PLEX_URL`, `PLEX_TOKEN` | Optional Plex sessions, transcodes and library count. | -| `IMMICH_URL`, `IMMICH_API_KEY` | Optional Immich photo, video and storage totals. | -| `HOME_ASSISTANT_URL`, `HOME_ASSISTANT_TOKEN` | Optional Home Assistant automation, light and availability totals. | -| `OLLAMA_URL`, `GLANCES_URL` | Optional overrides; their discovered local app URLs provide zero-config read-only metrics. | -| `TAUTULLI_URL`, `TAUTULLI_API_KEY` | Optional Plex activity, transcode and bandwidth metrics. | -| `PAPERLESS_URL`, `PAPERLESS_TOKEN` | Optional Paperless document, inbox and archive-size metrics. | -| `AUDIOBOOKSHELF_URL`, `AUDIOBOOKSHELF_TOKEN` | Optional library, book, podcast and item totals. | -| `NETDATA_URL`, `PEERTUBE_URL` | Optional discovered-URL overrides for their native GET-only readers. | -| `SONARR_*`, `RADARR_*`, `LIDARR_*` | Optional provider URL and API key pairs for queue and version data. | -| `GITEA_WIDGET_*`, `JELLYFIN_*`, `SEERR_*`, `PROWLARR_*` | Optional server-side URL/token pairs for native GET-only metrics. | -| `AUTHENTIK_*`, `PORTAINER_*`, `GRAFANA_*` | Optional server-side URL/token pairs for administrative summary metrics. | -| `PROMETHEUS_URL`, `NEXTCLOUD_*`, `NPM_*` | Optional read-only monitoring, cloud and proxy metrics. | -| `*_METRICS_URL`, `*_METRICS_TOKEN` | Optional GET-only bridge for providers whose native API would require POST-based control. | -| `CUSTOM_WIDGET__URL`, `CUSTOM_WIDGET__TOKEN` | Settings-managed GET-only JSON metrics endpoint and optional bearer token for any other app. | -| `GITEA_URL`, `GITEA_OWNER`, `GITEA_TOKEN` | Used only by `npm run gitea:init` to create/push the private repository. | +```bash +bash scripts/verify_gis_runtime.sh http://192.168.10.150:1202 +``` -Provider secrets are never stored in SQLite, JSON exports, logs or tracked files and are never returned to the browser. Values entered in Settings are persisted as environment assignments in `INTEGRATION_ENV_PATH` with mode `0600`; use DockDeck only on a trusted LAN or behind authenticated HTTPS, and protect raw volume backups. +Load the explicit offline demo workflow: -## Repository initialization +```bash +curl -X POST http://192.168.10.150:1202/api/v1/demo/workflow +``` -After a passing quality gate and with the three Gitea variables exported, run `npm run gitea:init`. The script refuses an unexpected existing remote, creates only a private repository, keeps the token out of Git configuration, and pushes `main` through a temporary authorization header. +If `/api/v1/projects` returns frontend HTML instead of a JSON envelope, rebuild +and restart the frontend container. + +Useful direct verification commands: + +```bash +python -m compileall backend/app +cd backend && python -c "from app.main import app; print(app.title)" +python -m pytest +cd ../frontend && npm run typecheck +cd ../frontend && npm run build +docker compose config +bash scripts/run_readiness_check.sh +``` + +If `make` or `docker` are unavailable in your shell, run the equivalent script entrypoints directly: + +```bash +bash scripts/backend_install.sh +bash scripts/backend_test.sh +bash scripts/frontend_install.sh +bash scripts/frontend_typecheck.sh +bash scripts/frontend_build.sh +bash scripts/run_readiness_check.sh +``` diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md deleted file mode 100644 index 39f7f45f..00000000 --- a/REQUIREMENTS.md +++ /dev/null @@ -1,88 +0,0 @@ -# REQUIREMENTS.md - -Project: DockDeck - -## Functional Requirements - -Implementatiestatus 2026-07-14: FR-1 tot en met FR-19 zijn gebouwd en lokaal geverifieerd. De private Gitea-repository bestaat en productie draait op Unraid-poort `1218`. Alleen optionele integratiecredentials en een geauthenticeerde externe proxyroute blijven omgevingskeuzes; zie `TEST_LOG.md` en `docs/IMPLEMENTATION_STATUS.md`. - -| ID | Requirement | Source | Status | -| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ------ | -| FR-1 | Implementeer de must-haves: Automatische read-only ontdekking van Unraid Docker-containers; nieuwe containers standaard verborgen; instellingenpagina voor zichtbaarheid, naam, icoon, categorie, sortering en URL-overrides; automatische afleiding van lokale WebUI-URL's uit Unraid-templategegevens; read-only containerstatus met verversing bij laden en ongeveer elke 30 seconden; configureerbare categorieën; configureerbare keuze tussen één totaaloverzicht en categoriepagina's of tabbladen; handmatige favorieten voor externe websites; centrale zoekbalk voor apps, favorieten en Google; openen van alle doelen in een nieuw tabblad; licht, donker en aanvullende ingebouwde thema's; persistent bewaren van instellingen in SQLite; export en import van configuratie; Docker- en Docker Compose-configuratie voor Unraid; beperkte Docker-socketproxy zonder beheerrechten; optionele read-only Nginx Proxy Manager-koppeling voor externe URL's met handmatige fallback; duidelijke foutstatus wanneer Unraid, Docker-proxy of Nginx Proxy Manager tijdelijk onbereikbaar is; automatische initialisatie van een private Gitea-repository via environmentvariabelen; en een uitzonderlijk verzorgde professionele UI met premium dashboardlook, consistente componenten, strak kaartdesign, goede witruimte, duidelijke visuele hiërarchie, subtiele hover- en transition-effecten, nette iconografie en een afgewerkte instellingenomgeving. | Intake | Done | -| FR-2 | Ondersteun het kernidee: Bouw een persoonlijke webapp die als standaard startpagina van de browser dient en een prachtig, professioneel en strak afgewerkt overzicht geeft van de diensten op de Unraid-server. De app ontdekt Docker-containers automatisch via Unraid en een beperkte read-only Docker-socketproxy, maar toont nieuwe containers niet automatisch op het hoofddashboard. In het instellingenmenu kan de gebruiker per container zichtbaarheid, categorie, sortering, icoon, naam en URL beheren. Voor Docker-apps wordt standaard de lokale WebUI-URL uit de Unraid-configuratie gebruikt. Daarnaast kunnen handmatig favoriete externe websites worden toegevoegd met naam, URL, icoon, categorie en sorteerpositie. Het dashboard bevat een centrale zoekbalk die zowel zichtbare Docker-apps als favorieten doorzoekt en, wanneer geen lokaal resultaat wordt gekozen, de invoer als Google-zoekopdracht in een nieuw tabblad opent. De gebruiker kan kiezen tussen één overzicht met categorieën onder elkaar en een weergave met afzonderlijke categoriepagina's of tabbladen. Thema's, weergavevoorkeuren en openingsgedrag worden via instellingen beheerd. Externe toegang kan optioneel worden ingeschakeld; wanneer die actief is, haalt een read-only koppeling met Nginx Proxy Manager beschikbare externe URL's op en koppelt die aan de juiste apps, met steeds een handmatige override als terugval. De UI moet aanvoelen als een hoogwaardige, moderne productinterface met sterke visuele hiërarchie, nette details, subtiele animaties en een verzorgde dashboardervaring. | Intake | Done | - -## Users And Roles - -- Primaire gebruikers/rollen: Eén primaire gebruiker: de eigenaar en beheerder van de Unraid-server. Deze gebruiker verwacht zonder technische omwegen snel naar lokale Docker-diensten en favoriete websites te kunnen navigeren, zelf te bepalen welke apps zichtbaar zijn, categorieën en sortering te beheren, de startpagina visueel aan te passen en optioneel lokale of externe app-URL's te gebruiken. Er zijn in de MVP geen afzonderlijke gebruikersrollen, accounts of gedeelde profielen. -- Beheerrollen en permissies: Nog in te vullen. - -## Core Workflows - -1. Gebruiker start met: Bouw een persoonlijke webapp die als standaard startpagina van de browser dient en een prachtig, professioneel en strak afgewerkt overzicht geeft van de diensten op de Unraid-server. De app ontdekt Docker-containers automatisch via Unraid en een beperkte read-only Docker-socketproxy, maar toont nieuwe containers niet automatisch op het hoofddashboard. In het instellingenmenu kan de gebruiker per container zichtbaarheid, categorie, sortering, icoon, naam en URL beheren. Voor Docker-apps wordt standaard de lokale WebUI-URL uit de Unraid-configuratie gebruikt. Daarnaast kunnen handmatig favoriete externe websites worden toegevoegd met naam, URL, icoon, categorie en sorteerpositie. Het dashboard bevat een centrale zoekbalk die zowel zichtbare Docker-apps als favorieten doorzoekt en, wanneer geen lokaal resultaat wordt gekozen, de invoer als Google-zoekopdracht in een nieuw tabblad opent. De gebruiker kan kiezen tussen één overzicht met categorieën onder elkaar en een weergave met afzonderlijke categoriepagina's of tabbladen. Thema's, weergavevoorkeuren en openingsgedrag worden via instellingen beheerd. Externe toegang kan optioneel worden ingeschakeld; wanneer die actief is, haalt een read-only koppeling met Nginx Proxy Manager beschikbare externe URL's op en koppelt die aan de juiste apps, met steeds een handmatige override als terugval. De UI moet aanvoelen als een hoogwaardige, moderne productinterface met sterke visuele hiërarchie, nette details, subtiele animaties en een verzorgde dashboardervaring. -2. Gebruiker voert of beheert de belangrijkste gegevens: Docker-apps met container-ID, technische naam, weergavenaam, afbeelding, ontdekt icoon, online- of offline-status, lokale WebUI-URL, optionele externe URL, zichtbaarheid, categorie, sorteerpositie, detectiebron en laatste statuscontrole; categorieën met naam, icoon, sorteerpositie en weergave-instellingen; favorieten met naam, URL, icoon, categorie en sorteerpositie; dashboardinstellingen met gekozen thema, weergavemodus, openingsgedrag, zoekprovider, pollinginterval en externe-toegangsschakelaar; integratie-instellingen zonder geheime waarden; import- en exportbestand met versieerbaar configuratieschema; synchronisatiestatus en beperkte foutinformatie voor Unraid, Docker-proxy en Nginx Proxy Manager. -3. Systeem levert de gewenste uitkomst: De eerste bruikbare versie draait als Docker-container op Unraid en kan als browserstartpagina worden ingesteld. Na de eerste start ontdekt de app de beschikbare Docker-containers, toont ze in een instellingenoverzicht en laat de gebruiker selecteren welke apps op het dashboard verschijnen. Zichtbare apps staan netjes gegroepeerd in configureerbare categorieën, tonen naam, icoon en actuele online- of offline-status en openen hun lokale WebUI in een nieuw tabblad. Favoriete websites kunnen handmatig worden toegevoegd. De zoekbalk vindt apps en favorieten en kan rechtstreeks een Google-zoekopdracht starten. Alle instellingen blijven na een herstart behouden en kunnen worden geëxporteerd en opnieuw geïmporteerd. De totale ervaring moet eruitzien als een afgewerkt premium product dat de gebruiker met plezier dagelijks als startpagina gebruikt. - -## Non-Functional Requirements - -- Quality bar: De interface moet eruitzien als een professioneel premium product en niet als een simpel hobbydashboard. Verwacht een moderne, luxueuze en verzorgde UI met uitgebalanceerde witruimte, consistente kaartcomponenten, hoogwaardige iconografie, duidelijke typografie, nette schaduwen of diepte waar passend, subtiele animaties, verzorgde empty states, sterke visuele hiërarchie en een kalme maar rijke look-and-feel. De belangrijkste startpagina moet snel laden en direct bruikbaar zijn wanneer integraties traag of tijdelijk offline zijn. Fouten moeten begrijpelijk worden getoond zonder technische stacktraces. Ontbrekende iconen of URL's moeten nette fallbacks hebben. Instellingen moeten veilig opgeslagen worden en bij foutieve invoer gevalideerde feedback geven. De interface moet responsief zijn voor desktop, tablet en basisgebruik op mobiel. Toetsenbordnavigatie, zichtbare focus, semantische HTML en voldoende kleurcontrast zijn vereist. Statuspolling mag geen zichtbare flikkering veroorzaken en moet stoppen wanneer de pagina niet actief is of verstandig worden vertraagd. De code moet modulair, getypeerd, gedocumenteerd en zonder onnodige complexiteit zijn. Esthetische kwaliteit is voor deze MVP een kernvereiste en niet louter een extra. -- Testing expectations: Unit tests voor URL-normalisatie, categorie- en sorteervoorwaarden, zoekresultaten, configuratievalidatie, import- en exportversies, container-naar-Nginx-hostmatching en permissiegrenzen; backend-integratietests met gemockte Unraid-templategegevens, Docker Socket Proxy en Nginx Proxy Manager; SQLite-migratie- en persistentietests; tests die aantonen dat geen Docker-mutatie-endpoints of beheeracties worden gebruikt; browsertests voor eerste ingebruikname, containers zichtbaar maken, categorieën beheren, favorieten toevoegen, zoeken, Google-fallback, thema wisselen, weergavemodus wijzigen, URL openen en configuratie exporteren en importeren; visuele UI-smoketests voor kernschermen zoals dashboard, categorie-overzicht en instellingen; Docker-buildtest en Compose-smoketest; een live read-only validatie op Unraid waarbij discovery, status en lokale WebUI-URL's worden gecontroleerd zonder containers te wijzigen. -- Performance, toegankelijkheid en observability: Lokale fonts, een compacte productieclient, zichtbare toetsenbordfocus, semantische navigatiestatus, gereduceerde animatie waar gevraagd, visibility-aware polling, gestructureerde serverlogs en `/api/health`. De UI wordt zonder horizontale overflow getest op 390×844, 768×1024, 1440×900 en 3440×1440. - -## UX Expectations - -- UX verwachtingen: Een rustige premium startpagina die van smalle telefoon tot ultrawide proportioneel schaalt. De gebruiker kan thema, accentkleur, dichtheid, contentbreedte, achtergrond, wallpaperpassing/focus/zoom, dashboardhero, statusbadges en overzichtsmodus aanpassen; voorkeuren blijven persistent. De wallpapereditor toont vóór toepassen een realistische preview van het volledige platformvlak. Een geüploade wallpaper heeft voorrang op Aurora, Blauwdruk en Minimaal en loopt ononderbroken onder alle dashboardkolommen. -- Belangrijkste schermen en interacties: Dashboard met hero, zoekbalk, live overzicht, responsieve apptegels en maximaal acht configureerbare servicewidgets met read-only containerstatistieken; instellingen voor uiterlijk, apps, categorieën, favorieten, integraties en back-up. Appbeheer ondersteunt zoeken, zichtbaarheids- en widgetfilters; iedere canonieke categorie heeft een afzonderlijk semantisch pictogram, categorie-iconen zijn bewerkbaar en favorieticonen worden automatisch first-party geladen met een handmatige override. - -## Data - -- Data involved: Docker-apps met container-ID, technische naam, weergavenaam, afbeelding, ontdekt icoon, online- of offline-status, lokale WebUI-URL, optionele externe URL, zichtbaarheid, categorie, sorteerpositie, detectiebron en laatste statuscontrole; categorieën met naam, icoon, sorteerpositie en weergave-instellingen; favorieten met naam, URL, icoon, categorie en sorteerpositie; dashboardinstellingen met gekozen thema, weergavemodus, openingsgedrag, zoekprovider, pollinginterval en externe-toegangsschakelaar; integratie-instellingen zonder geheime waarden; import- en exportbestand met versieerbaar configuratieschema; synchronisatiestatus en beperkte foutinformatie voor Unraid, Docker-proxy en Nginx Proxy Manager. -- Integrations: Unraid Docker-templatebestanden of beschikbare Unraid-metadata voor containernaam, WebUI en iconen; een beperkte read-only Docker Socket Proxy voor containerinventaris en runtime-status; Nginx Proxy Manager API in read-only modus voor optionele externe hostnamen en URL's; Google Search via een gewone zoek-URL zonder API-key; Gitea API voor het aanmaken van een private repository en het configureren van de Git-remote. - -## Deployment And Runtime - -- Preferred stack/runtime: TypeScript als hoofdtaal; React met Vite voor de frontend; Node.js met Fastify voor de backend; SQLite met Drizzle ORM voor persistentie en migraties; gedeelde types en validaties met Zod; een eenvoudige REST-API tussen frontend en backend; een zorgvuldig opgebouwd design system met design tokens, CSS-variabelen en herbruikbare UI-componenten; een moderne componentbibliotheek of headless aanpak voor toegankelijke basiscomponenten; iconenset van hoge kwaliteit; subtiele motion via lichte animatielogica; Docker multi-stage build en Docker Compose voor Unraid; Vitest voor unit- en integratietests; Playwright voor browsertests. Gebruik een overzichtelijke repository met duidelijk gescheiden frontend-, backend- en shared-mappen. Richt de frontend expliciet in op een premium dashboardervaring met consistente spacing, kaartsystemen, typografie, kleurgebruik, statusbadges en instellingenpanelen. -- Runtime model: Interactieve app -- Update/sync strategy: Polling -- Persistence: SQLite -- Deployment target: Docker op Unraid -- Repository state: private Gitea-repository `NuklearRabbit/DockDeck`; lokale `main` volgt de remote. -- Omgevingen: lokale development, geïsoleerde Vitest/Playwright-tests en productie op Unraid `192.168.10.150:1218`. - -## Security And Privacy - -- Credentials verwacht: Ja -- Security/privacy notes: Gebruik uitsluitend environmentvariabelen voor gevoelige configuratie. Voorzie minimaal variabelen zoals GITEA_URL, GITEA_TOKEN en GITEA_OWNER voor repository-initialisatie en optionele NPM_URL, NPM_USERNAME en NPM_PASSWORD of een ondersteund read-only token voor Nginx Proxy Manager. Gebruik voor de Docker-koppeling geen onbeperkte socketmount in de applicatiecontainer, maar een afzonderlijke proxy met alleen de minimaal noodzakelijke read-endpoints. Sla secrets nooit op in SQLite, exports, logs, frontendbundels, testfixtures of Git. Toon gevoelige integratiewaarden gemaskeerd in de UI. De app mag geen Docker-mutaties uitvoeren en mag nooit containers starten, stoppen, herstarten, verwijderen of aanpassen. De MVP heeft geen eigen authenticatie en moet daarom standaard alleen op een lokaal bindadres of afgeschermd Docker-netwerk worden gepubliceerd. Externe toegang via Nginx Proxy Manager wordt alleen voorbereid; correcte authenticatie en toegangscontrole via bijvoorbeeld Authentik blijven de verantwoordelijkheid van een latere deploymentstap. -- Secrets mogen nooit worden gecommit. - -## Out Of Scope - -Containers starten, stoppen, herstarten, verwijderen of wijzigen; Docker Compose-stacks beheren; Unraid-systeembeheer; terminal- of shelltoegang; logviewer; resourcegrafieken en uitgebreide monitoring; notificaties en incidentbeheer; publieke internettoegang configureren; Authentik- of andere SSO-integratie; eigen gebruikersaccounts, rollen of multi-userondersteuning; opslag van echte API-tokens in de repository of browser; automatische wijzigingen aan Nginx Proxy Manager; cloudhosting; mobiele native apps; AI-functionaliteit; synchronisatie tussen meerdere Unraid-servers; automatische publicatie van diensten naar het internet. - -## Open Questions - -- Welke requirements zijn release-blocking? -- Welke externe systemen moeten eerst gevalideerd worden? - -## Widget Extension 2026-07-14 - -- FR-3 (Done): toon standaard een configureerbare Unraid-hostwidget met host-, CPU-, geheugen-, image- en containerinformatie uit Docker `/info`. -- FR-4 (Done): verrijk geselecteerde AdGuard-, Plex-, Immich-, Home Assistant-, Sonarr-, Radarr- en Lidarr-widgets met provider-specifieke read-only gegevens wanneer uitsluitend server-side environmentcredentials beschikbaar zijn. -- FR-5 (Done): een ontbrekende of falende provider-API valt altijd terug op generieke Dockerstats en blokkeert discovery of navigatie nooit. -- FR-6 (Done): apps kunnen uit DockDeck worden verwijderd zonder Docker te wijzigen; de technische containernaam blijft persistent onderdrukt bij discovery en wordt meegenomen in back-ups. -- FR-7 (Done): de dashboardzoekfunctie indexeert actuele appnamen, technische namen, categorieën, URL's, images, favorieten en de Unraid-host en beweegt direct mee met configuratiewijzigingen. -- FR-8 (Done): iedere servicewidget ondersteunt persistent een compact, standaard of breed formaat en afzonderlijke inhoudslagen voor samenvatting, appmetrics, Dockertelemetrie en voortgang. -- FR-9 (Done): de Unraid-hostwidget heeft eigen persistente formaat- en inhoudsinstellingen; alle widgetconfiguratie blijft exporteerbaar, legacy-compatibel en responsief. -- FR-10 (Done): widgets hebben een onafhankelijke volgorde met toetsenbordbediening en een directe dashboardbewerkingsmodus. -- FR-11 (Done): iedere widget ondersteunt metricvolgorde, primaire metric, aangepast label, waarschuwingsdrempel, sparkline en een veilige eigen verversingscadans. -- FR-12 (Done): de Unraid-hostwidget leest read-only array-, parity-, disk-/pool-, temperatuur- en optionele UPS-status uit minimaal gemounte hostbronnen. -- FR-13 (Done): app-specifieke providers zijn uitgebreid met Gitea, Jellyfin, Seerr, Prowlarr, Authentik, NPM, Portainer, Grafana, Prometheus en Nextcloud; beveiligde POST-gebaseerde API's gebruiken optionele GET-only metrics bridges. -- FR-14 (Done): widgets tonen expliciet `fresh`, `stale` of `unavailable` en behouden laatst succesvolle providergegevens bij tijdelijke uitval. -- FR-15 (Done): layoutpresets kunnen afzonderlijk worden opgeslagen, toegepast, verwijderd, geëxporteerd en geïmporteerd. -- FR-16 (Done): het platform biedt secretvrije diagnostics en een installable PWA-shell die nooit `/api`-responses cachet. -- FR-17 (Done): de Unraid-hostnaam en WebUI-URL zijn persistent instelbaar onder Widgets; Tower gebruikt standaard expliciet poort `5000` en de aangepaste waarde stuurt zowel de hostkaart als de dynamische zoekindex. -- FR-18 (Done): iedere ontdekte app staat in Widget Studio. Native providers krijgen gedocumenteerde read-only adapters; overige apps krijgen een optionele, server-side GET-only JSON-koppeling met configureerbare URL en bearer-token. -- FR-19 (Done): per widget kan de gebruiker afzonderlijke appmetrics tonen/verbergen, tot 24 metrics rangschikken, de primaire metric kiezen en alle keuzes meenemen in presets en back-ups. De live inventaris heeft specifieke profielen voor applicaties, workers, databases, caches, queues, mail, GPU, media, downloads en eigen projecten. -- FR-20 (Done): de standaard Unraid-installatie bestaat uit precies één container met de vaste naam `DockDeck`, eigen icoon en correcte WebUI op poort `1218`. Discovery gebruikt uitsluitend GET-verzoeken naar de bestaande AppOps-inventaris en mount nooit de Docker-socket; voor hosts zonder AppOps blijft een expliciete, afgescheiden socket-proxyoverlay beschikbaar. -- FR-21 (Done): iedere appwidget kan een echte provider- of infrastructuurreeks als lijn-, vlak- of staafgrafiek tonen, 10–60 samples kiezen, de legenda schakelen, het aantal stats begrenzen, iedere metric als waarde/gauge/progress presenteren en op iedere numerieke metric boven of onder een configureerbare drempel waarschuwen. -- FR-22 (Done): de Tower-widget ondersteunt dezelfde diepe configuratie voor zichtbare serverstats, volgorde, statlimiet en grafieken van containers, images, opslag, temperatuur, parity en UPS. Provider- en serverhistoriek blijft maximaal 60 punten uitsluitend in procesgeheugen. -- FR-23 (Done): Unraid krijgt een werkelijk PNG-gecodeerd transparant DockDeck-icoon; de Compose-label en het gebruikerssjabloon verwijzen niet langer naar SVG-inhoud die Unraid foutief als `.png` cachet, en de deployment ververst zowel de persistente als actieve DockerMan-cache. diff --git a/RISKS.md b/RISKS.md deleted file mode 100644 index 836aa3e6..00000000 --- a/RISKS.md +++ /dev/null @@ -1,75 +0,0 @@ -# RISKS.md - -## Aanvulling 2026-07-22 - -- Open-Meteo is een externe, ongeauthenticeerde databron. DockDeck cachet tien minuten, gebruikt een korte timeout en laat alleen de weerbadge zonder actuele waarde bij uitval; navigatie, widgets en instellingen blijven volledig bruikbaar. -- Een wallpaper kan de leesbaarheid verminderen. DockDeck beperkt bestandstype en grootte, bewaart het bestand lokaal, gebruikt een rustige uniforme contrastlaag en maakt rails, widgets en tegels doorschijnende glasoppervlakken; de gebruiker kan de afbeelding direct verwijderen. Bij `Passend` vult een vervaagde kopie de ultrawide zijruimte, terwijl het scherpe hoofdbeeld volledig zichtbaar blijft. -- `Passend` behoudt de volledige afbeelding en kan bij een afwijkende beeldverhouding rustige zij- of bovenruimte tonen; `Vullend` benut het vlak volledig maar kan randen afsnijden. De preview, focusregelaars en begrensde zoom maken deze afweging expliciet zonder het originele bestand te bewerken. - -Project: DockDeck -Updated: 2026-07-22 - -## Assumptions - -- Eén lokale beheerder; geen accounts of multi-userrollen in de MVP. -- Docker- en Unraid-metadata zijn read-only en bereikbaar vanuit het afgeschermde Compose-netwerk. -- Nginx Proxy Manager is optioneel; een handmatige/lokale URL blijft altijd de fallback. -- SQLite is de persistente bron voor normale configuratie; providercredentials uit Settings staan afzonderlijk als environmentassignments in het named volume. - -## Open risks - -- Unraid WebUI-templatevelden verschillen per communitytemplate. De parser ondersteunt de gangbare `[IP]`, `[HOST]`, `[PORT]` en `[PORT:n]` vormen; onbekende vormen vragen een handmatige URL-override. -- Een container zonder WebUI of icoon gebruikt een nette fallback maar kan pas navigeren na een URL-override. -- Automatische NPM-matching accepteert alleen één ondubbelzinnige hostname; complexe namen blijven bewust handmatig. -- DockDeck heeft geen eigen authenticatie. De standaardbind is daarom `127.0.0.1`; een LAN-bind of reverse proxy vereist bewuste netwerktoegangscontrole. -- Credentialinvoer over de huidige LAN-HTTP-deployment is niet transportversleuteld. Gebruik dit scherm alleen op het vertrouwde LAN; zet voor toegang buiten dat segment een geauthenticeerde HTTPS-reverse proxy in. -- `/data/integrations.env` bevat plaintext providercredentials met bestandmodus `0600`. JSON-exports sluiten dit bestand uit, maar een ruwe volumeback-up bevat het wel en moet als secretmateriaal worden beschermd. -- De Unraid-host heeft zijn automatische Docker-address-pools opgebruikt. DockDeck gebruikt daarom twee expliciete, kleine subnetten (`172.31.240.0/28` en `172.31.240.16/28`); controleer deze opnieuw bij netwerkherconfiguratie. -- Unraid-templates kunnen secrets bevatten. De app krijgt uitsluitend een gegenereerde metadata-mirror met `Name`, `WebUI` en `Icon`; voer de sanitizer opnieuw uit nadat templates wijzigen. -- Niet iedere website publiceert een bruikbaar icoon op `/favicon.ico`; zulke favorieten en apps vallen terug op een lokaal merk- of semantisch service-icoon en kunnen in Settings nog steeds een expliciete icoon-URL krijgen. -- De standaard single-containerdeployment is voor discovery afhankelijk van de bestaande AppOps-gateway. Bij uitval blijven alle opgeslagen navigatie en instellingen bruikbaar en krijgt de integratie een degraded status; discovery hervat automatisch zodra AppOps terug is. -- AppOps levert actuele CPU- en geheugenwaarden, maar momenteel geen netwerkdoorvoer of PID-aantal. Native providers en GET-only metrics bridges blijven die app-specifieke lagen leveren; zonder zo'n koppeling toont DockDeck voor die velden een eerlijke unavailable-status. -- Provider-API's verschillen per versie en vereisen credentials met uiteenlopende scopes. DockDeck gebruikt alleen gedocumenteerde GET-routes en korte timeouts; zonder endpoint toont de kaart haar eigen capabilitylabels met een expliciete connectiestatus naast gescheiden echte Dockertelemetrie. Configureer waar mogelijk een afzonderlijke minimum-scope token en roteer die periodiek via Settings of de deploymentenvironment. -- Custom widgetendpoints zijn een expliciet lokaal vertrouwensbesluit: DockDeck doet alleen een GET met korte timeout, begrenst labels/waarden en echoot tokens niet, maar de gebruiker blijft verantwoordelijk voor een read-only endpoint en een minimum-scope token. Gebruik geen URL die bij een GET een mutatie veroorzaakt. -- App-specifieke profielen beschrijven mogelijke metriekvelden; eigen applicaties moeten het gedocumenteerde JSON-contract zelf aanbieden voordat deze waarden live worden. Zonder endpoint blijft de kaart volledig bruikbaar met read-only Dockertelemetrie. -- Grafiekhistoriek is bewust procesgeheugen: maximaal zestig samples per reeks, geen geheimen en geen langdurige opslag. Een containerrestart wist de trend zonder configuratie of navigatie te verliezen. -- Unraid cachet containericonen tweemaal onder een vaste `.png`-naam: persistent in `/var/lib/docker/unraid/images` en actief onder `/usr/local/emhttp/state`. Alleen de persistente cache vervangen is onvoldoende zolang de actieve cache bestaat; `deploy/refresh-unraid-icon.sh` valideert daarom het echte PNG-MIME-type en vervangt uitsluitend beide DockDeck-bestanden. -- Gemengde widgetformaten gebruiken CSS-grid-dense plaatsing. De onafhankelijke widgetvolgorde blijft gelijk aan de DOM-/toetsenbordvolgorde; directe editknoppen behouden deze toegankelijkheidsregel zonder drag-and-drop. -- Unraid hwmon-classlinks kunnen buiten de beperkte mount wijzen. DockDeck behandelt iedere onleesbare sensor afzonderlijk als optioneel en behoudt array-/disktemperaturen uit `disks.ini`; de Dockerproxy wordt niet verruimd. -- Ollama en Glances gebruiken standaard de ontdekte lokale app-origin zonder credential. Een reverse-proxysubpad of afwijkende API-root vraagt een expliciete URL-override in Settings; een fout valt terug op app-specifieke Dockertelemetrie. -- Tautulli plaatst zijn API-key volgens het officiële contract in de querystring van de uitgaande LAN-request. DockDeck logt die URL niet, maar de doelserver kan dat wel doen; gebruik daarom een afzonderlijke key en bescherm targetlogs. -- De huidige AdGuard API is met Basic Auth beveiligd en JDownloader publiceert alleen noVNC; zonder aanvullende credentials/API-activering tonen deze kaarten daarom veilige app-specifieke live Dockertelemetrie (waaronder transfersnelheid), maar geen querylog, blocktotalen of downloadqueue. -- Een verwijderde app blijft onderdrukt zolang de technische containernaam gelijk blijft. Als Docker de container later bewust onder een andere naam aanbiedt, behandelt DockDeck dit als een nieuw ontdekte app en blijft die standaard verborgen. -- LAN-adresdetectie gebruikt alleen expliciete `br0`-netwerken. Afwijkende macvlan-netwerknamen of meerdere gepubliceerde poorten blijven bewust handmatig overschrijfbaar om geen onzekere URL te kiezen. -- Nieuwe apps blijven als “nieuw ontdekt” gemarkeerd totdat hun zichtbaarheid bewust wordt gewijzigd. Dat is een eenvoudige erkenningshandeling zonder aparte inbox; een gebruiker die alleen andere metadata bewerkt, blijft de ontdekmarkering zien. -- De directe Stitch-pass ligt bewust als laatste, geïsoleerde presentatielaag in `stitch-direct.css` boven de oudere stylesheets. Dat houdt de gedragswijziging klein en reviewbaar, maar een toekomstige frontendopschoning kan de nu overschreven legacyregels veilig verwijderen zodra alle zeldzame settingsstates opnieuw visueel zijn geïnventariseerd. -- De herkenbare merkfallbacks komen uit de vastgepinde MIT-package `@icons-pack/react-simple-icons@13.8.0`. Expliciete appiconen uit Unraid of Settings blijven leidend; niet-afgedekte eigen diensten gebruiken bewust een semantisch Lucide-icoon. -- De lokale Windows-omgeving heeft geen `docker`-CLI/runtime. Compose-config en de klassieke imagebuild zijn daarom op Tower uitgevoerd en voor release `72879e1` groen; de bekende inconsistente BuildKit-cache blijft hostbrede technische schuld. - -- De gedeelde ultrawide-canvas en rechter widgetdock zijn bewust in dezelfde presentatielaag geimplementeerd. De E2E-suite valideert geen horizontale overflow en open dock-state op 3440 px, maar zeldzame combinaties van veel brede widgets kunnen later nog een fijnere masonry-/virtualisatielaag vragen. -- Automatische inventarisopschoning verwijdert alleen DockDeck-records die ontbreken in een aantoonbaar verbonden AppOps/Docker-respons. Een foutief maar succesvol leeg antwoord van de externe inventarisbron zou daardoor als autoritatief gelden; maak voor deployment een secretvrije export en bewaak de gerapporteerde containertelling. De Docker-containers zelf worden nooit gewijzigd. -- De Unraid-iconcache wordt bij containerstart uit de root-only DockerMan-iconenmap gevuld en is bewust niet persistent. Een gewijzigd icoon wordt daarom na een containerrecreate zichtbaar; de hostbron blijft read-only en het blijvende applicatieproces draait als UID 100. -- De slimme categorieactie is bewust niet onderdeel van discovery of heartbeat. Nieuwe of hernoemde niche-services blijven in hun bestaande categorie tot de beheerder de actie opnieuw uitvoert of ze handmatig wijzigt; regels moeten bij nieuwe canonical servicetypen expliciet en getest worden uitgebreid. -- Een geïmporteerd of handmatig opgeslagen categorie-icoon buiten de ondersteunde catalogus gebruikt bewust `Layers3` als fallback. Het blijft in Instellingen als aangepast zichtbaar en kan daar zonder dataverlies naar een van de afzonderlijke ondersteunde pictogrammen worden omgezet. - -## Blockers - -- Geen releaseblockers. Live NPM/providerdiepgang en UPS-status wachten uitsluitend op optionele minimum-scope omgevingsconfiguratie. - -## Technical debt - -- De eerste migratie is idempotente SQL naast Drizzle-schema's; bij een volgende schemawijziging hoort een genummerde migratietabel/runner. -- NPM-auth met username/password vraagt tijdelijk een token op; een expliciet read-only `NPM_TOKEN` blijft de aanbevolen productieconfiguratie. -- Playwright gebruikt één sequentiële testdatabase. Een toekomstige grotere suite kan per worker een geïsoleerde databasefixture gebruiken. -- De voorkeurenmigratie voegt kolommen idempotent toe aan bestaande databases. Bij verdere schema-uitbreiding blijft een genummerde migratierunner de gewenste vervolgstap. -- De Unraid-host heeft een inconsistente BuildKit-cachelaag. DockDeck kon veilig met de klassieke builder worden gebouwd; herstel van de hostbrede BuildKit-cache valt buiten dit project. - -## Security and privacy - -- DockDeck leest nooit container-environments of applicatieconfiguratie om providercredentials automatisch te vinden; provider-URL's en tokens worden expliciet als server-side environmentvariabelen aangeleverd. - -- Secrets bestaan uitsluitend als procesenvironment en, bij invoer via Settings, in het permission-restricted environmentbestand. Ze worden niet in SQLite of JSON-export opgeslagen en nooit via een API-response teruggegeven. -- De appcontainer krijgt geen Docker-socket. De standaardroute leest alleen AppOps `GET /api/containers`; de optionele fallbackproxy is niet gepubliceerd, heeft `POST=0`, minimale GET-families en een intern netwerk. -- De app definieert geen start-, stop-, restart-, delete-, exec- of andere Docker-mutaties. -- NPM wordt alleen gelezen; DockDeck wijzigt of publiceert geen proxyhosts. -- De sanitizer schrijft alleen toegestane metadata en verifieert de resulterende XML voordat DockDeck die leest. diff --git a/SPEC.md b/SPEC.md deleted file mode 100644 index 0d5f7847..00000000 --- a/SPEC.md +++ /dev/null @@ -1,88 +0,0 @@ -# SPEC.md - -## Summary - -Bouw een persoonlijke webapp die als standaard startpagina van de browser dient en een prachtig, professioneel en strak afgewerkt overzicht geeft van de diensten op de Unraid-server. De app ontdekt Docker-containers automatisch via Unraid en een beperkte read-only Docker-socketproxy, maar toont nieuwe containers niet automatisch op het hoofddashboard. In het instellingenmenu kan de gebruiker per container zichtbaarheid, categorie, sortering, icoon, naam en URL beheren. Voor Docker-apps wordt standaard de lokale WebUI-URL uit de Unraid-configuratie gebruikt. Daarnaast kunnen handmatig favoriete externe websites worden toegevoegd met naam, URL, icoon, categorie en sorteerpositie. Het dashboard bevat een centrale zoekbalk die zowel zichtbare Docker-apps als favorieten doorzoekt en, wanneer geen lokaal resultaat wordt gekozen, de invoer als Google-zoekopdracht in een nieuw tabblad opent. De gebruiker kan kiezen tussen één overzicht met categorieën onder elkaar en een weergave met afzonderlijke categoriepagina's of tabbladen. Thema's, weergavevoorkeuren en openingsgedrag worden via instellingen beheerd. Externe toegang kan optioneel worden ingeschakeld; wanneer die actief is, haalt een read-only koppeling met Nginx Proxy Manager beschikbare externe URL's op en koppelt die aan de juiste apps, met steeds een handmatige override als terugval. De UI moet aanvoelen als een hoogwaardige, moderne productinterface met sterke visuele hiërarchie, nette details, subtiele animaties en een verzorgde dashboardervaring. - -## Architecture - -- Project type: webapp -- Preferred stack: TypeScript als hoofdtaal; React met Vite voor de frontend; Node.js met Fastify voor de backend; SQLite met Drizzle ORM voor persistentie en migraties; gedeelde types en validaties met Zod; een eenvoudige REST-API tussen frontend en backend; een zorgvuldig opgebouwd design system met design tokens, CSS-variabelen en herbruikbare UI-componenten; een moderne componentbibliotheek of headless aanpak voor toegankelijke basiscomponenten; iconenset van hoge kwaliteit; subtiele motion via lichte animatielogica; Docker multi-stage build en Docker Compose voor Unraid; Vitest voor unit- en integratietests; Playwright voor browsertests. Gebruik een overzichtelijke repository met duidelijk gescheiden frontend-, backend- en shared-mappen. Richt de frontend expliciet in op een premium dashboardervaring met consistente spacing, kaartsystemen, typografie, kleurgebruik, statusbadges en instellingenpanelen. -- Runtime model: Interactieve app -- Update/sync strategy: Polling -- Persistence: SQLite -- Deployment target: Docker op Unraid -- Deployment profile: de standaard Compose-installatie maakt precies één branded container `DockDeck` op poort `1218`. Containerinventaris en CPU/geheugenstatus komen via GET van de bestaande AppOps-gateway; de applicatie mount nooit de Docker-socket. Een afzonderlijke beperkte socketproxy blijft alleen als optionele fallbackoverlay beschikbaar. -- Repository state: private Gitea-repository `NuklearRabbit/DockDeck`; `main` volgt de remote. -- Constraints: De MVP moet als Docker-container op de bestaande Unraid-server draaien en alleen vanaf het lokale netwerk bereikbaar zijn. De app moet zonder eigen login bruikbaar zijn, omdat netwerktoegang de primaire beveiligingsgrens vormt. Alle Docker-toegang moet read-only verlopen via een beperkte socketproxy; de hoofdcontainer krijgt geen directe onbeperkte Docker-socket. Unraid-templategegevens mogen alleen worden gelezen. Nginx Proxy Manager wordt uitsluitend read-only benaderd. De applicatie moet bruikbaar blijven wanneer Nginx Proxy Manager niet is geconfigureerd of tijdelijk niet bereikbaar is. Er mogen geen betaalde externe diensten nodig zijn. Alle configuratie moet persistent blijven bij herstart en eenvoudig te back-uppen zijn. Codex moet een nieuwe private repository in de persoonlijke Gitea-omgeving aanmaken en mag geen secrets hardcoderen. Visuele kwaliteit is belangrijk, maar de UI mag niet onnodig zwaar of traag worden; professionele polish moet samengaan met vlotte prestaties als browserstartpagina. - -## Module Boundaries - -| Module | Responsibility | Notes | -| -------------- | ------------------------------------------- | --------------------------- | -| UI / Interface | Gebruikersinteractie en invoer verzamelen. | Afstemmen op projecttype. | -| Domain | Businessregels en pure transformaties. | Houd afhankelijkheden laag. | -| Infrastructure | Data, integraties en runtime adapters. | Isoleer externe systemen. | -| Documentation | Projectdossier, beslissingen en overdracht. | Altijd actueel houden. | - -## Dataflow - -```text -Gebruiker -> Interface -> Domain logic -> Data/integraties -> Resultaat -> Documentatie/testlog -``` - -## Interfaces And Contracts - -- Inputdata: Docker-apps met container-ID, technische naam, weergavenaam, afbeelding, ontdekt icoon, online- of offline-status, lokale WebUI-URL, optionele externe URL, zichtbaarheid, categorie, sorteerpositie, detectiebron en laatste statuscontrole; categorieën met naam, icoon, sorteerpositie en weergave-instellingen; favorieten met naam, URL, icoon, categorie en sorteerpositie; dashboardinstellingen met gekozen thema, weergavemodus, openingsgedrag, zoekprovider, pollinginterval en externe-toegangsschakelaar; integratie-instellingen zonder geheime waarden; import- en exportbestand met versieerbaar configuratieschema; synchronisatiestatus en beperkte foutinformatie voor Unraid, Docker-proxy en Nginx Proxy Manager. -- Integraties: Unraid Docker-templatebestanden of beschikbare Unraid-metadata voor containernaam, WebUI en iconen; een beperkte read-only Docker Socket Proxy voor containerinventaris en runtime-status; Nginx Proxy Manager API in read-only modus voor optionele externe hostnamen en URL's; Google Search via een gewone zoek-URL zonder API-key; Gitea API voor het aanmaken van een private repository en het configureren van de Git-remote. -- Update/sync: Polling -- Persistence: SQLite -- API/UI contracten: JSON REST onder `/api`: dashboardaggregaat, discovery-sync, gevalideerde PATCH voor apps/preferences, CRUD voor categorieën/favorieten, een same-origin credential-write-endpoint en versie 1 export/import. Zod valideert iedere mutatiegrens; credentialwrites antwoorden uitsluitend met `saved: true` en geen response bevat credentialwaarden. -- Categorie-icooncontract: één gedeelde frontendcatalogus voedt de renderer en de instellingenkeuzelijst. Alle canonieke categoriewaarden verwijzen naar een afzonderlijke Lucide-component; onbekende geïmporteerde waarden vallen terug op `Layers3` en nieuwe categorieën krijgen eerst een nog ongebruikt ondersteund pictogram. -- Visuele voorkeuren: `theme`, `accent`, `density`, `contentWidth`, `backgroundStyle`, `wallpaperFit`, `wallpaperPositionX`, `wallpaperPositionY`, `wallpaperZoom`, `showHero`, `showStatus`, `viewMode`, `pollingInterval` en `externalAccess` worden server-side gevalideerd en in SQLite bewaard. Oudere databases krijgen ontbrekende voorkeurkolommen idempotent; versie 1 exports blijven achterwaarts compatibel. De wallpaper schaalt tegen het volledige zichtbare platformviewport onder de topbalk, niet tegen de scrollhoogte of alleen de middenkolom, en vervangt daar de actieve achtergrondpreset. -- Responsive contract: het document krijgt alleen gevalideerde `data-*` attributen voor de visuele tokens. De grid gebruikt vloeiende kolommen en begrensde contentprofielen voor mobiel, tablet, desktop en ultrawide. DM Sans en Newsreader worden lokaal gebundeld. -- Widgetcontract: apps hebben persistente velden voor inschakeling, formaat, onafhankelijke volgorde, informatielagen, custom label, metricvolgorde, primaire metric, statlimiet, per-metric value/gauge/progress-presentatie, boven-/onderdrempel, grafiekreeks, lijn-/vlak-/staaftype, legenda, samplevenster en een begrensde verversingscadans. De discoverylaag leest voor maximaal acht geselecteerde containers alleen `GET /containers/{id}/stats`, houdt maximaal zestig punten uitsluitend in procesgeheugen bij en geeft freshness plus CPU-, geheugen-, netwerk-, proces- en uptimetelemetrie door. Een mislukte statsread blokkeert navigatie of discovery niet. -- Hostwidgetcontract: `showServerWidget`, formaat, volgorde, statselectie/-volgorde/-limiet, configureerbare grafiek en capaciteit-, detail-, voortgangs- en Unraid-native lagen staan standaard aan en worden persistent en exporteerbaar opgeslagen. De hostkaart combineert Docker `GET /info` met read-only mounts voor Unraid runtimebestanden en hwmon; er is geen shell-, socket- of beheeractie. Array-, parity-, opslag-, temperatuur- en optionele UPS-data worden tot een klein presentatiecontract en maximaal zestig geheugenpunten gereduceerd. -- Providerwidgetcontract: de adapterlaag ondersteunt AdGuard Home, Plex, Immich, Home Assistant, Sonarr/Radarr/Lidarr, Ollama, Glances, Tautulli, Paperless-ngx, Gitea, Jellyfin, Seerr, Prowlarr, Authentik, NPM, Portainer, Grafana, Prometheus, Nextcloud, Audiobookshelf, Netdata en PeerTube. Alle netwerkrequests zijn GET en time-bounded. Deluge, qBittorrent, JDownloader, Tdarr, Bazarr en Vaultwarden kunnen een door de gebruiker geconfigureerde GET-only metrics bridge lezen omdat hun native beheer-API geen zuivere GET-only grens biedt. Iedere overige ontdekte app krijgt een eigen allowlisted `CUSTOM_WIDGET__URL` en optionele bearer-token voor een klein JSON-contract met `summary` en maximaal twaalf `stats`; waarden blijven server-side en worden begrensd. Laatst succesvolle data blijft bij fouten als stale cache beschikbaar; app-specifieke Dockerstats blijven de fallback. -- Widgetinhoudcontract: Widget Studio publiceert voor iedere app metriekdefinities met betekenis, groep, unit, grafiekgeschiktheid, standaardpresentatie en aanbevolen zichtbaarheid. De gebruiker kan tot 24 labels persistent verbergen, rangschikken en presenteren; runtime-metrics die een gekoppelde app aanvullend levert worden dynamisch toegevoegd. Zonder koppeling blijven dezelfde app-specifieke labels zichtbaar met een expliciete connectiestatus; Dockertelemetrie wordt niet als fictieve appstat vermomd. Layoutpresets en volledige back-ups bewaren deze keuzes legacy-compatibel. -- Serveridentiteitscontract: `serverName` en `serverUrl` staan in SQLite, export/import en Settings. Zonder opgeslagen URL wordt `UNRAID_URL` of `UNRAID_HOST` gebruikt en, wanneer geen poort is opgegeven, poort `5000` toegevoegd. Dezelfde actuele waarden voeden hostkaart en zoekresultaten. -- Single-containercontract: het AppOps-contract leest uitsluitend `GET /api/containers`, reduceert de response direct tot noodzakelijke inventaris- en metriekvelden en rapporteert uitval als degraded zonder opgeslagen navigatie te blokkeren. CPU- en geheugencapaciteit komen uit twee specifieke read-only `/proc`-bestandsmounts. -- Layoutcontract: `/api/widget-layout` bewaart de afzonderlijke widgetvolgorde. Layoutpresets hebben een eigen gevalideerd record en eigen import/export-API, onafhankelijk van de volledige versie 1-back-up. -- Operationscontract: `/api/diagnostics` retourneert uitsluitend health-, integratie-, discovery- en configuratiestatus zonder secrets of ruwe providerpayloads. De PWA-cache bevat alleen statische shellassets en sluit iedere `/api/`-request expliciet uit. -- Faviconcontract: een expliciete favorieticoon-URL blijft leidend. Zonder override probeert de browser alleen `/favicon.ico`; een fout levert een lokaal monogram en veroorzaakt geen server-side fetch of third-party request. -- Appverwijdercontract: `DELETE /api/apps/:id` verwijdert uitsluitend het DockDeck-record en schrijft de technische containernaam naar `removed_apps`. Discovery slaat die naam voortaan over; Docker en Unraid worden nooit gemuteerd. Versie 1-back-ups mogen de optionele `removedApps`-lijst bevatten. -- Zoekcontract: de zoekindex wordt uitsluitend afgeleid van de actuele dashboardpayload en indexeert zowel zichtbare als verborgen apps, weergave- en technische namen, categorieën, images, URL-varianten, favorieten en de Unraid-host. Lokaal resultaat, toetsenbordselectie en expliciete Google-fallback blijven afzonderlijke acties. -- URL-resolutiecontract: `[IP]` gebruikt voor een expliciet `br0`-netwerk het LAN-adres van de container en anders `UNRAID_HOST`. Een exact gepubliceerde poort blijft leidend; bij een verouderde templatepoort mag één ondubbelzinnige gepubliceerde TCP-poort als fallback dienen. Overrides blijven altijd leidend. - -## File Structure - -- Broncode, tests en documentatie worden gescheiden per verantwoordelijkheid. -- Nieuwe modules krijgen colocated tests waar dat past bij de stack. - -## Error Handling - -- Valideer gebruikersinput dicht bij de grens van het systeem. -- Toon herstelbare fouten duidelijk en log technische details zonder secrets. - -## Security And Privacy - -- Credentials expected: Ja -- Security/privacy notes: Gebruik uitsluitend environmentvariabelen voor gevoelige configuratie. Voorzie minimaal variabelen zoals GITEA_URL, GITEA_TOKEN en GITEA_OWNER voor repository-initialisatie en optionele NPM_URL, NPM_USERNAME en NPM_PASSWORD of een ondersteund read-only token voor Nginx Proxy Manager. Gebruik voor de Docker-koppeling geen onbeperkte socketmount in de applicatiecontainer, maar een afzonderlijke proxy met alleen de minimaal noodzakelijke read-endpoints. Sla secrets nooit op in SQLite, exports, logs, frontendbundels, testfixtures of Git. Toon gevoelige integratiewaarden gemaskeerd in de UI. De app mag geen Docker-mutaties uitvoeren en mag nooit containers starten, stoppen, herstarten, verwijderen of aanpassen. De MVP heeft geen eigen authenticatie en moet daarom standaard alleen op een lokaal bindadres of afgeschermd Docker-netwerk worden gepubliceerd. Externe toegang via Nginx Proxy Manager wordt alleen voorbereid; correcte authenticatie en toegangscontrole via bijvoorbeeld Authentik blijven de verantwoordelijkheid van een latere deploymentstap. - -## Test Strategy - -- Testing expectations: Unit tests voor URL-normalisatie, categorie- en sorteervoorwaarden, zoekresultaten, configuratievalidatie, import- en exportversies, container-naar-Nginx-hostmatching en permissiegrenzen; backend-integratietests met gemockte Unraid-templategegevens, Docker Socket Proxy en Nginx Proxy Manager; SQLite-migratie- en persistentietests; tests die aantonen dat geen Docker-mutatie-endpoints of beheeracties worden gebruikt; browsertests voor eerste ingebruikname, containers zichtbaar maken, categorieën beheren, favorieten toevoegen, zoeken, Google-fallback, thema wisselen, weergavemodus wijzigen, URL openen en configuratie exporteren en importeren; visuele UI-smoketests voor kernschermen zoals dashboard, categorie-overzicht en instellingen; Docker-buildtest en Compose-smoketest; een live read-only validatie op Unraid waarbij discovery, status en lokale WebUI-URL's worden gecontroleerd zonder containers te wijzigen. -- Quality bar: De interface moet eruitzien als een professioneel premium product en niet als een simpel hobbydashboard. Verwacht een moderne, luxueuze en verzorgde UI met uitgebalanceerde witruimte, consistente kaartcomponenten, hoogwaardige iconografie, duidelijke typografie, nette schaduwen of diepte waar passend, subtiele animaties, verzorgde empty states, sterke visuele hiërarchie en een kalme maar rijke look-and-feel. De belangrijkste startpagina moet snel laden en direct bruikbaar zijn wanneer integraties traag of tijdelijk offline zijn. Fouten moeten begrijpelijk worden getoond zonder technische stacktraces. Ontbrekende iconen of URL's moeten nette fallbacks hebben. Instellingen moeten veilig opgeslagen worden en bij foutieve invoer gevalideerde feedback geven. De interface moet responsief zijn voor desktop, tablet en basisgebruik op mobiel. Toetsenbordnavigatie, zichtbare focus, semantische HTML en voldoende kleurcontrast zijn vereist. Statuspolling mag geen zichtbare flikkering veroorzaken en moet stoppen wanneer de pagina niet actief is of verstandig worden vertraagd. De code moet modulair, getypeerd, gedocumenteerd en zonder onnodige complexiteit zijn. Esthetische kwaliteit is voor deze MVP een kernvereiste en niet louter een extra. - -## Deployment Design - -- Runtime/stack: TypeScript als hoofdtaal; React met Vite voor de frontend; Node.js met Fastify voor de backend; SQLite met Drizzle ORM voor persistentie en migraties; gedeelde types en validaties met Zod; een eenvoudige REST-API tussen frontend en backend; een zorgvuldig opgebouwd design system met design tokens, CSS-variabelen en herbruikbare UI-componenten; een moderne componentbibliotheek of headless aanpak voor toegankelijke basiscomponenten; iconenset van hoge kwaliteit; subtiele motion via lichte animatielogica; Docker multi-stage build en Docker Compose voor Unraid; Vitest voor unit- en integratietests; Playwright voor browsertests. Gebruik een overzichtelijke repository met duidelijk gescheiden frontend-, backend- en shared-mappen. Richt de frontend expliciet in op een premium dashboardervaring met consistente spacing, kaartsystemen, typografie, kleurgebruik, statusbadges en instellingenpanelen. -- Deployment target: Docker op Unraid -- Repository state: private Gitea-repository `NuklearRabbit/DockDeck`, remote en deployment zijn operationeel. -- Build, release en rollback stappen: `npm run build`; lokaal `npm start`; op Unraid `docker compose up -d --build`. Rollback via vorige Git-commit/image en herstel van het `dockdeck_data` volume of een versie 1 export. - -## Open Design Questions - -- Opgelost: SQLite met named volume is voldoende voor de eerste release. -- Opgelost: Unraid, Docker Proxy en NPM hebben deterministische adapters/fixtures. -- Opgelost: gestructureerde Fastify-requestlogs plus niet-gevoelige integratiestatus en `/api/health` vormen de minimale observability. diff --git a/START_PROMPT.md b/START_PROMPT.md deleted file mode 100644 index 7f1c150a..00000000 --- a/START_PROMPT.md +++ /dev/null @@ -1,24 +0,0 @@ -# START_PROMPT.md - -Start dit project volgens mijn Codex Project Operating System. -Gebruik de bestanden in deze map als projectdossier. - -Project: DockDeck -Projectidee: Bouw een persoonlijke webapp die als standaard startpagina van de browser dient en een prachtig, professioneel en strak afgewerkt overzicht geeft van de diensten op de Unraid-server. De app ontdekt Docker-containers automatisch via Unraid en een beperkte read-only Docker-socketproxy, maar toont nieuwe containers niet automatisch op het hoofddashboard. In het instellingenmenu kan de gebruiker per container zichtbaarheid, categorie, sortering, icoon, naam en URL beheren. Voor Docker-apps wordt standaard de lokale WebUI-URL uit de Unraid-configuratie gebruikt. Daarnaast kunnen handmatig favoriete externe websites worden toegevoegd met naam, URL, icoon, categorie en sorteerpositie. Het dashboard bevat een centrale zoekbalk die zowel zichtbare Docker-apps als favorieten doorzoekt en, wanneer geen lokaal resultaat wordt gekozen, de invoer als Google-zoekopdracht in een nieuw tabblad opent. De gebruiker kan kiezen tussen één overzicht met categorieën onder elkaar en een weergave met afzonderlijke categoriepagina's of tabbladen. Thema's, weergavevoorkeuren en openingsgedrag worden via instellingen beheerd. Externe toegang kan optioneel worden ingeschakeld; wanneer die actief is, haalt een read-only koppeling met Nginx Proxy Manager beschikbare externe URL's op en koppelt die aan de juiste apps, met steeds een handmatige override als terugval. De UI moet aanvoelen als een hoogwaardige, moderne productinterface met sterke visuele hiërarchie, nette details, subtiele animaties en een verzorgde dashboardervaring. -Startmodus: MVP bouwen - -Belangrijke intakekeuzes: - -- Runtime model: Interactieve app -- Update/sync strategy: Polling -- Persistence: SQLite -- Deployment target: Docker op Unraid -- Repository state: Repo nog nodig - -Lees voordat je bouwt: AGENTS.md, PROJECT_BRIEF.md, REQUIREMENTS.md, SPEC.md, PLAN.md, DECISIONS.md, TEST_LOG.md, RISKS.md en HANDOFF.md. - -- Codex mag scaffolden, bouwen, testen, committen en pushen wanneer de repo en remote duidelijk zijn. -- Start met de kleinste werkende MVP-flow en verifieer die end-to-end. - -Stel alleen vragen wanneer dat nodig is om een wezenlijke product-, architectuur- of veiligheidskeuze te maken. -Werk daarna de docs bij, bouw de kleinste nuttige stap, test aantoonbaar, commit zonder secrets en rond af met een heldere handoff. diff --git a/TEST_LOG.md b/TEST_LOG.md deleted file mode 100644 index 5e153eea..00000000 --- a/TEST_LOG.md +++ /dev/null @@ -1,365 +0,0 @@ -# TEST_LOG.md - -## Unieke categorie-iconen - 2026-07-22 - -| Check | Resultaat | -| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | -| Live inventaris | Pass; 16 categorieën bevatten al 16 unieke semantische waarden, waarvan 10 eerder niet door de frontendrenderer werden herkend | -| Renderer en instellingen | Pass; één catalogus levert 19 opties en alle 16 canonieke categorieën resolven naar een afzonderlijke Lucide-component | -| Nieuwe categorie | Pass; kiest automatisch het eerste nog ongebruikte ondersteunde pictogram | -| Onbekende importwaarde | Pass; veilige `Layers3`-fallback blijft behouden en verschijnt als Aangepast in Instellingen | -| `npm run format:check` / lint / typecheck | Pass | -| Unit/integratie / security | Pass; 57/57 en 5/5 | -| Playwright | Pass; 13 uitgevoerd en 15 project-gededupliceerd op desktop, tablet, mobiel en ultrawide | -| Build / audit | Pass; JS 377,08 kB, CSS 187,64 kB, server 182,0 kB en 0 kwetsbaarheden | -| React-review | Pass; named components, stabiele catalogus, afgeleide icoonkeuze en behouden semantische SVG-presentatie | - -De release draait live op Tower met image `sha256:1cf11fc02e7e…`, is healthy en heeft 0 herstarts. De `.env` en bestaande JPEG-wallpaper van 3.245.835 bytes bleven byte-identiek. Chrome bevestigt in Categorieën 16 opgeslagen icoonwaarden, 16 gerenderde SVG's en 16 unieke SVG-signaturen; het dashboard toont dezelfde semantische pictogrammen en rapporteert 0 browserwaarschuwingen of -fouten. Er was geen databasewijziging nodig: de bestaande unieke waarden worden uitsluitend voortaan correct gerenderd. - -## Platformbrede wallpaperlaag - 2026-07-22 - -| Check | Resultaat | -| ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -| Geometrie | Pass; stage start op 72 px, eindigt op de viewportonderrand en is exact viewportbreed vanaf x=0 | -| Presetprioriteit | Pass; canvas heeft bij een wallpaper `background-image: none` en een transparante achtergrond | -| Compositie | Pass; scherp instelbaar beeld plus zachte `cover`-ambientlaag, met glasoppervlakken voor linkerrail, tegels en rechter widgetdock | -| Scrollgedrag | Pass; fixed stage blijft onder de topbalk staan terwijl de dashboardinhoud onafhankelijk doorloopt | -| `npm run format:check` / lint / typecheck | Pass | -| Unit/integratie / security | Pass; 54/54 en 5/5 | -| Playwright | Pass; 13 uitgevoerd en 15 project-gededupliceerd op desktop, tablet, mobiel en ultrawide; geen horizontale overflow | -| Build / audit | Pass; JS 375,95 kB, CSS 187,64 kB, server 182,0 kB en 0 kwetsbaarheden | -| React-review | Pass; decoratieve ambientbeelden zijn verborgen, de bestaande semantiek en lokale draftstate blijven intact | - -De live Tower-release draait op image `sha256:f5d62a9e30f6…`, is healthy en heeft 0 herstarts. De `.env` en bestaande JPEG-wallpaper van 3.245.835 bytes bleven byte-identiek; Unraid, AppOps en alle zeven livepanelen zijn connected. Chrome bevestigt op 3440×1215 een fixed stage van x=0/y=72 tot de volledige 3425×1143 browsercontentruimte. Na 900 px scroll blijft y=72 behouden, de presetcanvas blijft transparant, beide zijpanelen gebruiken 62% glas en er is geen horizontale overflow of applicatiefout. - -## Responsieve wallpaperstudio - 2026-07-22 - -| Check | Resultaat | -| ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -| Reproductie | Pass; `cover` schaalde tegen een 3077×3015 contentcanvas in plaats van het zichtbare centrale viewport van 3077×1143 | -| Viewportstage | Pass; wallpaper schaalt tegen `100svh - 72px`, blijft tijdens scrollen in het centrale vlak en verstoort het ultrawide dock niet | -| Preview en bediening | Pass; Passend/Vullend, X-/Y-focus, 80–160% zoom, herstel en expliciet toepassen | -| Persistentie/migratie/export | Pass; vier nieuwe gevalideerde voorkeuren, legacydefaults en versie 1 round-trip | -| `npm run format:check` / lint / typecheck | Pass | -| Unit/integratie / security | Pass; 54/54 en 5/5 | -| Playwright | Pass; 13 uitgevoerd en 15 project-gededupliceerd op desktop, tablet, mobiel en ultrawide | -| Build / audit | Pass; JS 375,56 kB, CSS 185,13 kB, server 182,0 kB en 0 kwetsbaarheden | -| React-review | Pass; lokale draftstate, volledige dependencies, semantische controls, toetsenbordbediening en decoratieve alttekst | - -De live Tower-release draait op image `sha256:606051e4f146…`, is healthy en heeft 0 herstarts. De `.env` en bestaande JPEG-wallpaper van 3.245.835 bytes bleven byte-identiek. Chrome bevestigt op 3440×1215 een wallpaperstage van 2381×1143 binnen een 3015 px hoge scrollcanvas: `Passend`, 50/50-focus en 100% zoom, zonder horizontale overflow of browserfouten. De gecentreerde studio-preview is 1600×686; `Vullend` wijzigt alleen de draft en `Herstellen` zet die terug zonder de live voorkeur te overschrijven. - -## Achtergrond- en wallpaperherstel - 2026-07-22 - -| Check | Resultaat | -| ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Reproductie live | Pass; voorkeur `grid` en JPEG-wallpaper van 3,2 MB waren opgeslagen, maar de CSS-compositie werd ongeldig verklaard door een ontbrekende semantische token | -| Achtergrondpresets | Pass; Aurora, Blauwdruk en Minimaal leveren drie verschillende berekende canvasachtergronden | -| Wallpaperflow | Pass; upload via Instellingen verschijnt als preview, zet `has-wallpaper` en wordt als `/api/wallpaper` in de zichtbare canvas gerenderd | -| `npm run format:check` / lint / typecheck | Pass | -| Unit/integratie / security | Pass; 54/54 en 5/5 | -| Playwright | Pass; 13 uitgevoerd en 15 project-gededupliceerd, inclusief de nieuwe end-to-end achtergrondflow | -| Build / audit | Pass; JS 371,81 kB, CSS 180,35 kB, server 180,6 kB en 0 kwetsbaarheden | -| Live Unraid-release | Pass; image `sha256:977791a84355…` healthy met 0 herstarts, `.env` byte-identiek en bestaande JPEG-wallpaper van 3.245.835 bytes behouden | -| Live Chrome-audit | Pass op 3440×1215; alle presets visueel en berekend verschillend, wallpaper in iedere compositie, 0 overflow en 0 browserfouten | - -De audit heeft via de echte instellingeninterface Aurora, Minimaal en Blauwdruk geactiveerd. De wallpaper bleef tijdens iedere wissel zichtbaar; de canvascomposities gebruikten respectievelijk kleurlichten, een rustige overlay en een raster van 40 px. Blauwdruk is na de controle als actieve gebruikersvoorkeur hersteld. - -## Premium wallpaper-, weer- en controlpass - 2026-07-22 - -| Check | Resultaat | -| ---------------------------- | ---------------------------------------------------------------------------------------------------- | -| Typecheck en unit/integratie | Pass; 54/54 tests inclusief weer-cache en wallpaperformaten | -| Playwright | Pass; 12 uitgevoerd, 12 project-gededupliceerd; desktop/tablet/mobile/3440px, 0 browserfouten | -| Toggle-geometrie | Pass; E2E bevestigt meer dan 18px verplaatsing tussen beide zichtbare posities | -| Visuele screenshots | Pass; weerbadge, compacte Home Assistant-tegel, consistente rails en geen horizontale overflow | -| React-review | Pass; effectcleanup, locatie-afhankelijke revalidatie, semantische switches en server-side datafetch | - -De finale lint, typecheck, unit/integratie, Playwright, security, build en audit zijn groen. Build: client-JS 371,81 kB (115,70 kB gzip), CSS 178,48 kB (30,05 kB gzip), server 180,6 kB; `npm audit` meldt 0 kwetsbaarheden. Tower `docker compose config` en imagebuild zijn groen; image `a371cab602ce…` draait healthy met 0 restarts en een byte-identieke `.env`. Live wallpapercyclus: PUT 201, GET 200, DELETE 204. Open-Meteo gaf voor Mol 21,2 °C/bewolkt. Alle zes appwidgets zijn connected; de ultrawide Chrome-audit toont 0 functionele fouten. - -Project: DockDeck -Laatste gate: 2026-07-22 - -## Autoritatieve inventarisheartbeat en instellingen-UX — 2026-07-22 - -| Check | Resultaat | -| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | -| `npm run format:check` | Pass, alle bestanden Prettier-conform | -| `npm run lint` | Pass, 0 waarschuwingen | -| `npm run typecheck` | Pass | -| `npm test` | Pass, 48/48; inclusief connected-only opschoning, degraded behoud, deduplicatie en read-only/case-insensitieve Unraid-iconen | -| `npm run test:security` | Pass, 5/5 | -| `npm run test:e2e` | Pass, 12 uitgevoerd / 12 project-gededupliceerd op vier viewports; dashboardcategoriefilter en 0 console-/paginafouten | -| `npm run build` | Pass, JS 364,55 kB / 113,69 kB gzip; CSS 170,68 kB / 28,53 kB gzip; server 167,2 kB | -| `npm audit --audit-level=moderate` | Pass, 0 kwetsbaarheden | -| In-app browseraudit | Pass op 1440×900: topbars beide `left=32`/`height=72`, filter reduceert tot één groep, verticale Integraties, 14px kleine tekst, brede Paneelstudio | -| React-review | Pass: single-flight heartbeat, cleanup van timer/listeners, afgeleide filterstate, semantische knoppen, stabiele ids en lokaal begrensde state | - -De browseraudit mat geen horizontale overflow en bevatte geen browserwarnings of -errors. De opschoonregel wijzigt uitsluitend DockDecks SQLite-records na een verbonden inventarisrespons; Docker blijft read-only en een degraded heartbeat verwijdert niets. - -Live release `5e411d1`: Compose-config en imagebuild groen; image `sha256:2d320ff25b66eb84e3fdad27f5437ad8c9732e99891ceb14d714e95655c6db2c`; container healthy met 0 herstarts en PID 1 als UID 100. De secretvrije pre-sync-export bevat 139 apps, 16 categorieën en 1 favoriet (SHA-256 `35d1cf987511177a7246e906afd27f42f87f714f4b621358aaa21ab22b5873ea`). Na de verbonden heartbeat: 90 apps, 50 zichtbaar, 16 categorieën, 0 stale/ontbrekende/dubbele namen en 0 statusafwijkingen tegenover AppOps. Alle 5 lokale Unraid-iconen geven HTTP 200 met een afbeelding-MIME-type. - -## Finale visuele audit — 2026-07-22 - -| Check | Resultaat | -| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------- | -| `npm run format:check` | Pass, alle bestanden Prettier-conform | -| `npm run lint` | Pass, 0 waarschuwingen | -| `npm run typecheck` | Pass | -| `npm test` | Pass, 42/42; inclusief persistente echte ontdekstatus en legacy-migratie | -| `npm run test:security` | Pass, 5/5 | -| `npm run test:e2e` | Pass, 11 uitgevoerd / 9 project-gededupliceerd op vier viewports; 0 console-/paginafouten | -| `npm run build` | Pass, JS 361,49 kB / 113,01 kB gzip; CSS 147,26 kB / 25,19 kB gzip; server 164,6 kB | -| `npm audit --audit-level=moderate` | Pass, 0 kwetsbaarheden | -| In-app Chrome-audit | Pass: dashboard, Quick Launch en alle zeven instellingensecties; 390, 768, 1440 en 3440 px; geen horizontale overflow | -| React-review | Pass: afgeleide catalogusstate, begrensde renders, effect-cleanup, semantische controls, stabiele keys en portalfocus | -| Secret-/mutatiescan | Pass; geen toegevoegde secrets, externe UI-assets of Docker-mutaties | -| Lokale Docker-validatie | Niet beschikbaar: Windows-omgeving heeft geen Docker-CLI; Compose en imagebuild worden op Tower uitgevoerd | - -De audit heeft de operationele servicepanelen onder een compacte maar zichtbare **Live inzicht**-rij geplaatst, zonder de Paneelstudio of configuratie te verwijderen. Appbeheer gebruikt maximaal twintig rijen per pagina en een echte `isNew`-status; Widget Studio toont twaalf mogelijkheden per tranche. Geavanceerde weergavekeuzes en favoriete-editorvelden blijven volledig werkend via expliciete uitklapsecties. De Quick Launch-portal vult op ultrawide het volledige viewport en herstelt de invoerfocus na de portalovergang. - -### Live release `72879e1` - -| Check | Resultaat | -| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| Pre-deploybehoud | Secretvrije export opgeslagen; `.env`-SHA-256 vóór/staging/na identiek; named volume `dockdeck_dockdeck_data` behouden | -| `docker compose config --quiet` | Pass op huidige en gestagede bron | -| Klassieke Docker-imagebuild | Pass; image `sha256:5025a6599410…`, bundels identiek aan de lokale build | -| Compose release | Pass; één `DockDeck`-container, healthy, 0 herstarts op `192.168.10.150:1218` | -| Persistentie/schema | Pass; 138 apps, 55 zichtbaar, 1 favoriet, 3 categorieën, 6 appwidgets en dark theme; alle API-records hebben `isNew` | -| Read-only integraties | Pass; 233 templates, 3 array-/poolgroepen en 89 AppOps-containers connected; NPM bewust disabled | -| Live Chrome-audit | Pass op 3440×1271: favorietenrail, echte iconen, 0 monogrammen, Quick Launch 3440 px met focus, alle zeven instellingensecties en 0 overflow | - -De vorige bronversie blijft recoverable onder `/mnt/user/appdata/dockdeck-prev-20260722-025420`; de secretvrije pre-release-export en behoudsmetingen staan afgeschermd onder `/mnt/user/appdata/dockdeck-release-backups/72879e1-20260722-025420`. - -## Checks - -| Check | Command / evidence | Result | -| -------------------------- | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -| Dependency-installatie | `npm install` | Pass | -| Formatting | `npm run format:check` | Pass | -| Lint | `npm run lint` | Pass, 0 warnings | -| TypeScript | `npm run typecheck` | Pass | -| Unit/integratie/migratie | `npm test` | Pass, 42/42; inclusief app-/serverhistoriek, widgetpersistentie, capabilityprofielen en duale Unraid-icooncachehelper | -| Read-only security | `npm run test:security` | Pass, 5/5; inclusief GET-only providergrens, serviceworker zonder API-cache en cachehelper zonder Docker-mutaties | -| Browserflows | `npm run test:e2e` | Pass, 10 uitgevoerd / 18 bewust overgeslagen; vier viewports, advanced widgets, presets, diagnostics en editmodus | -| Production build | `npm run build` | Pass; client 305.49 kB / 91.03 kB gzip, CSS 69.77 kB / 12.85 kB gzip, serverbundle 163.0 kB | -| Dependency security | `npm audit --audit-level=moderate` | Pass, 0 vulnerabilities | -| Production smoke | `PORT=3100 npm start` + HTTP checks | Pass: health `ok`, HTML 200, 4 mockapps, alle nieuw verborgen | -| Visuele browseraudit | In-app Chromium, echte lokale UI | Pass: diepe widgetinstellingen op 375×812 en 2560×1440; mobiele codecontract-overflow hersteld, 0 consolefouten | -| Secret-/mutatiescan | `rg` plus securitytests | Pass; alleen verwachte envnamen en socketmount in de aparte proxy | -| Unraid Compose-config | `docker compose config --quiet` | Pass op Tower/Unraid | -| Unraid image/build | Klassieke Docker-builder | Pass; de BuildKit-cache op de host was inconsistent, de klassieke builder bouwde `dockdeck:local` | -| Unraid live smoke | Compose, health en HTTP API | Pass: healthy, 0 restarts, `192.168.10.150:1218`, persistent named volume | -| Live read-only discovery | Socketproxy + metadata-mirror | Pass: 77 containers, 228 bruikbare templates; 230 templates gesaneerd en geverifieerd tot Name/WebUI/Icon | -| Responsive live release | Unraid Compose + Chromium | Pass: `192.168.10.150:1218`, healthy, 0 restarts, 77 apps, nieuwe voorkeurvelden en geen browserconsolefouten | -| Live servicewidget | Docker stats + Chromium 2560×1080 | Pass: AdGuard gemigreerd, CPU/geheugen/netwerk/uptime gevuld, integraties connected, geen overflow/fouten | -| Providerwidget release | Vitest + in-app Chromium | Pass: Unraid-hostwidget en providerfallback op 390×844 en 2560×1440; geen overflow, overlay of consolefouten | -| React review | React best-practices checklist | Pass: named configurator, stabiele keys, geen extra effects, semantische buttons/switches en zichtbare focus | -| Live hostwidget release | Unraid Compose + API + Chromium | Pass: healthy op `192.168.10.150:1218`; Tower, 77 containers, 72 running, 20 cores, 62 GB, geen overflow/fouten | -| Live tailored widgets | API-delta + Chromium desktop/mobile | Pass: AdGuard DNS-layout/API-lock, JDownloader down-/uploadsnelheid, correcte links, 0 restarts en geen overflow/fouten | -| Live credential settings | API + in-app Chromium | Pass: AdGuard toont alleen ontbrekende username/password, password masking, geen overflow/consolefouten, geen dummywrite | -| Widget Studio lokaal | In-app Chromium + Playwright | Pass: catalogus/filter/search op desktop, responsive op vier viewports, gelijke kaarten, geen overflow/consolefouten | -| Widget Studio live | Unraid API + Compose health | Pass: 41 capabilities voor 82 apps, 2 bestaande widgets behouden, healthy met 0 restarts; credentialstore niet aangemaakt | -| Navigatie/beheer lokaal | Vitest + Playwright + Chromium | Pass: 30 tests, 8 browserchecks; remove-suppressie, rename-search, Tower, Google, sidebarfavorieten en 390px zonder overflow | -| Navigatie/beheer live | Unraid API + in-app Chromium | Pass: healthy/0 restarts; Tower-search/link, Tweakers-sidebar, gecorrigeerde AdGuard- en Immich-URL's, geen fouten/overflow | -| Widgetconfiguratie lokaal | Vitest + Playwright + agent-browser | Pass: drie formaten, inhoudslagen, import/export, 390/3440 px en geen overflow | -| Widgetconfiguratie live | Tower Compose + API + browser | Pass: healthy/0 restarts op `:1218`; 80 apps, 55 zichtbaar, 5 widgets; juiste AnythingLLM/Immich-attributie | -| Volledige widgetroadmap | Vitest + Playwright + API | Pass: onafhankelijke volgorde, metriccompositie, presets, freshness, cadence, warnings, sparklines, PWA en diagnostics | -| Native Unraid live | Read-only mounts + API | Pass: array STARTED, parity healthy/0 fouten, 3 opslaggroepen, 57 °C hoogste meting; integratie connected | -| Finale live responsiviteit | Chromium 390×844 en 3440×1440 | Pass: 7 live panelen incl. Tower, geen positieve overflow, geen Vite-overlay en PWA-manifest aanwezig | -| Persoonlijke widgetdiepte | API + Vitest + Chromium | Pass: 80/80 capabilities, 55 custom GET-profielen, 23 native providers en gescheiden app/worker/database-metrics | -| Tower poort/configuratie | SQLite + live API + Chromium | Pass: naam/URL zichtbaar in Settings; voorkeur en hostkaart wijzen naar `http://192.168.10.150:5000` | -| Finale Unraid release | Compose config/build/up + health | Pass: klassieke imagebuild, healthy, 0 restarts, 80 apps/55 zichtbaar/6 appwidgets; named-volumeconfiguratie behouden | -| Single-container release | AppOps + Compose + API + browser | Pass: exact één `DockDeck`, oude proxy verwijderd, 83 live containers, icoon/WebUI correct, data-ID en 55 zichtbare apps behouden | -| Diepe widgetconfiguratie | Vitest + Playwright + browser | Pass: statselectie/-limiet/-stijl, elke numerieke waarschuwing, 3 grafiektypes, 60-punt provider- en Towerhistoriek | -| App-specifieke fallback | Capabilityaudit + browser | Pass: kaarten behouden eigen metrics zonder endpoint en tonen eerlijke `Connect data`; alle geaudite generieke profielen weg | -| Unraid PNG-icoon | Asset, HTTP, beide caches en Chrome | Pass live: actieve SVG-cache vervangen; Unraid rendert nu de echte 512×512 PNG in DockDecks rij zonder `question.png`-fallback | -| Finale capabilityaudit | Live API + capabilityvergelijking | Pass: 97 opgeslagen apps, 97 capabilityprofielen, nul generieke profielen; BlockPilot en LumaOps expliciet gedekt | -| Diepe widgetrelease live | Compose + API + in-app Chromium | Pass: healthy/0 restarts, 74 ontdekt/69 actief, 55 zichtbaar, 6 appwidgets plus Tower, geen overflow of browserconsolefouten | - -## Scenario coverage - -- Deterministische discovery, Unraid XML/WebUI-portvervanging en NPM-hostmatching. -- Nieuwe containers verborgen; zichtbaarheid, naam, categorie, order en URL-override. -- Categorieën en favorieten aanmaken/bewerken/verwijderen. -- Volledige dynamische zoekindex voor zichtbare/verborgen apps, actuele namen, technische namen, categorieën, images, URL's, favorieten en Tower; expliciete Google-fallback in nieuw tabblad. -- Persistente DockDeck-only appverwijdering met rediscoveryonderdrukking en back-updekking; geen Docker-mutatie. -- Alle drie thema's, overzicht/categorienavigatie en mobiele navigatie. -- Persistente accent-, dichtheid-, contentbreedte-, achtergrond-, hero- en statusvoorkeuren. -- Responsieve grids en navigatie op mobiel, tablet, desktop en ultrawide; lokale fonts zonder externe request. -- Automatische first-party faviconafleiding met lokale merk-/semantische fallback en handmatige override; lettermonogrammen zijn niet meer nodig. -- Per-app widgetkeuze, limiet van acht, legacy-migratie en generieke read-only Dockerstats met degraded fallback. -- Persistente widgetformaten en inhoudslagen voor app- en Unraidpanelen, refreshmomenten en dichte responsieve plaatsing. -- Standaard Unraid-hostwidget via Docker `/info` en provider-aware AdGuard/Plex/Immich/Home Assistant/*arr-presentatie met server-only credentials. -- Gemaskeerde providercredentialinvoer via Settings, allowlist per provider, HTTP(S)-URL-validatie, same-origin write, atomische `0600`-environmentpersistentie en directe hertest zonder secret echo. -- Widget Studio capabilitycatalogus met aanbevolen/alle/actieve filters, acht-panelenlimiet, directe sync en vervangbare connected credentials. -- Volledige 80/80 capabilitycatalogus met app-specifieke metricdefinities, per-metric zichtbaarheid/volgorde en een begrensd custom GET-only JSON-contract. -- Persistente Tower-naam/URL met poort `5000` als standaard en dezelfde actuele waarde in kaart en zoekindex. -- Zero-config Ollama-/Glances-API's, Tautulli-/Paperless-adapters en app-specifieke AI/document/security-Dockerfallbacks. -- Per-servicetype fallbackpresentatie met berekende live netwerkdelta's; JDownloader gebruikt download-/uploadsnelheid en AdGuard DNS-traffic in plaats van generieke cumulatieve netwerkbytes. -- Zijpaneel met klok, deck health, favorieten en snelle servicelinks; gebalanceerde tegelbreedtes op 2560 en 3440 pixels. -- Statuspolling, offline container en integratie-degradatie. -- SQLite-migratie, restartpersistentie, container-ID-wijziging en export/import versie 1. -- Geen Docker-mutatie-endpoint of Docker-beheeractie. - -## Open external checks - -- Live Nginx Proxy Manager: credentials ontbreken; adapter en degradatie zijn wel gemockt/getest. -- Optionele providercredentials: alleen nodig voor de diepste API-metrics van beveiligde diensten; Dockerfallbacks blijven beschikbaar. - -## Latest quality gate - -De volledige lokale Canon v1-gate is groen: `format:check`, lint, typecheck, 42/42 unit- en integratietests, 5/5 afzonderlijke securitytests, 7 uitgevoerde en 9 doelbewust project-gededupliceerde Playwright-checks op desktop/tablet/mobile/ultrawide, production build en 0 kwetsbaarheden. De browserflows eindigen met 0 `console.error`- of ongehanteelde paginafouten. De clientbuild is 312,99 kB JS (93,53 kB gzip) en 94,91 kB CSS (17,51 kB gzip); de serverbundle is 163,9 kB. In-app Chromium verifieerde daarnaast eerste-use, gevuld dashboard, Quick Launch, apps-instellingen, mobiele drawer en dark/light op 390×844 en 1440×900. De vaste testpoortblokkade op 3000 is opgelost met geïsoleerde poorten 3101/5174 en een schone Canon-testdatabase. Docker Compose en imagebuild zijn niet opnieuw uitgevoerd omdat in deze Windows-omgeving geen `docker`-CLI/runtime beschikbaar is; de eerdere Tower-verificatie blijft ongewijzigd. - -## Canon v1 gate — 2026-07-21 - -| Check | Resultaat | -| -------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -| `npm run format:check` | Pass, alle bestanden Prettier-conform | -| `npm run lint` | Pass, 0 waarschuwingen | -| `npm run typecheck` | Pass | -| `npm test` | Pass, 42/42 | -| `npm run test:security` | Pass, 5/5 | -| `npm run test:e2e` | Pass, 7 uitgevoerd / 9 gededupliceerd over vier viewports; 0 console-/paginafouten | -| `npm run build` | Pass, JS 312,99 kB / 93,53 kB gzip; CSS 94,91 kB / 17,51 kB gzip; server 163,9 kB | -| `npm audit --audit-level=moderate` | Pass, 0 kwetsbaarheden | -| In-app browserreview | Pass: 390×844 en 1440×900, dark/light, dashboard, Quick Launch, apps, drawer, geen horizontale overflow | -| Secret-/mutatiescan | Pass; geen toegevoegde secrets of Docker-mutaties | -| `docker compose config --quiet` / imagebuild | Niet uitgevoerd: `docker` is lokaal niet geïnstalleerd | - -## Directe Stitch-pass — 2026-07-21 - -| Check | Resultaat | -| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | -| `npm run format:check` | Pass, alle bestanden Prettier-conform | -| `npm run lint` | Pass, 0 waarschuwingen | -| `npm run typecheck` | Pass | -| `npm test` | Pass, 42/42 | -| `npm run test:security` | Pass, 5/5 | -| `npm run test:e2e` | Pass, 11 uitgevoerd / 9 project-gededupliceerd; 390×844, 768×1024, 1440×900 en 3440×1440; 0 console-/paginafouten | -| `npm run build` | Pass, JS 312,49 kB / 93,60 kB gzip; CSS 125,83 kB / 22,07 kB gzip; server 163,9 kB | -| `npm audit --audit-level=moderate` | Pass, 0 kwetsbaarheden | -| In-app browserreview | Pass: gevuld donker dashboard, Quick Launch, App Beheer, editordrawer en Weergave; muis/toetsenbord en focus bevestigd | -| React-review | Pass: afgeleide state, effect-cleanup, semantische links/buttons, stabiele sleutels en toegankelijke switches | - -De CSS-toename is de expliciete directe Canon-compositielaag. Zij vervangt geen runtime- of backendlogica en bevat geen externe assets. De finale E2E-test schrijft de actuele referentiebeelden naar `docs/design/dockdeck-canon-v1/implementation/`. - -## Stitch launcherrail, iconen en widgets — 2026-07-22 - -| Check | Resultaat | -| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -| `npm run format:check` | Pass, alle bestanden Prettier-conform | -| `npm run lint` | Pass, 0 waarschuwingen | -| `npm run typecheck` | Pass | -| `npm test` | Pass, 42/42 | -| `npm run test:security` | Pass, 5/5 | -| `npm run test:e2e` | Pass, 11 uitgevoerd / 9 project-gededupliceerd op 390×844, 768×1024, 1440×900 en 3440×1440; 0 console-/paginafouten | -| `npm run build` | Pass, JS 353,39 kB / 110,42 kB gzip; CSS 135,18 kB / 23,34 kB gzip; server 163,9 kB | -| `npm audit --audit-level=moderate` | Pass na transitieve `fast-uri`-lockfile-update, 0 kwetsbaarheden | -| In-app browserreview | Pass: desktopfavorietenrail, echte Google/YouTube/GitHub/Plex/Immich/Home Assistant-iconen, zichtbare widgets en mobiele volgorde | -| React-review | Pass: zelfstandig railcomponent, effect-cleanup, afgeleide state, semantische links/buttons en getypeerde instellingenroute | -| Docker Compose/imagebuild | Niet uitgevoerd: in de lokale Windows-omgeving is geen `docker`-CLI/runtime beschikbaar | - -De browsertest valideert aanvullend dat de desktoprail en servicepanelen zichtbaar zijn, dat appkaarten geen `.app-monogram` meer renderen en dat de Paneelstudio via de rail rechtstreeks in `Instellingen → Algemeen` opent. De nieuwe merkiconen zijn lokaal gebundeld; expliciete Unraid-/gebruikersiconen blijven de eerste bron. - -## Widescreen leesbaarheids- en categorieaudit — 2026-07-22 - -| Check | Resultaat | -| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| `npm run format:check` | Pass, alle bestanden Prettier-conform | -| `npm run lint` | Pass, 0 waarschuwingen | -| `npm run typecheck` | Pass | -| `npm test` | Pass, 51/51; inclusief categorie-inferentie, behoud van onbekende toewijzingen en de expliciete organisatie-API | -| `npm run test:security` | Pass, 5/5 | -| `npm run test:e2e` | Pass, 12 uitgevoerd / 12 bewust overgeslagen over desktop, tablet, mobiel en ultrawide; 0 testfouten | -| `npm run build` | Pass, JS 365,35 kB / 113,90 kB gzip; CSS 173,48 kB / 29,03 kB gzip; server 170,8 kB | -| `npm audit --audit-level=moderate` | Pass, 0 kwetsbaarheden | -| React-review | Pass: afgeleide categorieanalyse, expliciete semantische actie, stabiele sleutels, geen extra effecten en ongewijzigde cleanup | -| Chrome audit vóór correctie | 3440×1215, 0 horizontale overflow en 0 consolefouten; wel widgetmetadata van 8 px, Paneelstudio tot 6 px en foutieve bulkcategorieën | - -De pass vergroot de ultrawide widgetdock, maakt widget- en instellingenmicrotypografie leesbaar en begrenst extreem lange beheer-, integratie- en back-uprijen. De categorieactie gebruikt uitsluitend bestaande categorieën en laat onbekende services onaangeroerd. - -### Live verificatie - -| Check | Resultaat | -| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | -| Secretvrij herstelpunt | `.codex-input/dockdeck-before-widescreen-audit-20260722-140834.json`, 80.809 bytes | -| `.env`-behoud | SHA-256 voor en na bronvervanging identiek: `e41e1b63…` | -| `docker compose config` | Pass vóór en na bronvervanging | -| Imagebuild en health | Pass, image `cc3a4b56fdfb…`, container healthy, 0 restarts | -| Read-only heartbeat | Pass, exact 90 bestaande containers en 50 zichtbare apps | -| Slimme categorieorganisatie | Pass, 14 gecorrigeerd, 76 behouden, 0 onbekend; 16 bestaande categorieën | -| Chrome 3440×1215 | Pass, dock 540 px, 0 horizontale overflow, 0 kapotte images, geen applicatieconsolefouten | -| Interacties | Pass: Downloads-filter toont exact Deluge/JDownloader2/slskd; instellingenzoeker, categoriebeheer en Quick Launch-favicon werken live | - -De enige browserlogregel kwam van de WideFrog Chrome-extensie zelf (`keyflow timeout` op een titelupdate), niet van DockDeck. De live DockDeck-tab is als testdashboard open gebleven. - -## Volledige instellingen- en releasepass — 2026-07-22 - -| Check | Resultaat | -| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -| `npm run format:check` | Pass, alle bestanden Prettier-conform | -| `npm run lint` | Pass, 0 waarschuwingen | -| `npm run typecheck` | Pass | -| `npm test` | Pass, 42/42; inclusief legacy-migratie en export/import van `reducedMotion` en `pollingIntervalMs` | -| `npm run test:security` | Pass, 5/5 | -| `npm run test:e2e` | Pass, 11 uitgevoerd / 9 project-gededupliceerd op vier viewports; instellingenzoeker en voorkeurpersistentie; 0 browserfouten | -| `npm run build` | Pass, JS 357,89 kB / 111,86 kB gzip; CSS 139,39 kB / 24,06 kB gzip; server 164,2 kB | -| `npm audit --audit-level=moderate` | Pass, 0 kwetsbaarheden | -| In-app browserreview | Pass: Apps, Categorieën, Weergave en Back-up; zoekfilter, kaartlayout, dropzone, scrollherstel en 0 horizontale overflow op 1280 | -| React-review | Pass: afgeleide zoekstate, correcte hookafhankelijkheden, getypeerde drag-events en semantische buttons/switches | - -De instellingenmatrix dekt iedere persistente voorkeur, app-/favoriet-/categoriebeheer, widgets, integratiecredentials en back-up/export. Fictieve online- of back-uphistoriek is bewust niet toegevoegd; de UI toont alleen werkelijk beschikbare gegevens. - -### Live Unraid-release - -| Check | Resultaat | -| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| Pre-deploybehoud | Secretvrije export gemaakt; `.env`-SHA-256 voor/na bronkopie identiek; named volume `dockdeck_dockdeck_data` behouden | -| `docker compose config` | Pass vóór en na bronvervanging | -| Klassieke Docker-imagebuild | Pass; image `14497e8a384c…`, client-/serverbundels identiek aan lokale build | -| Compose release | Pass; commit `3ffd39b`, één `DockDeck`-container, healthy, 0 restarts op `192.168.10.150:1218` | -| Persistentie na migratie | Pass; 55 zichtbare apps, 1 favoriet, 3 categorieën, 6 appwidgets en dark/30 s behouden; sync voegde 3 nieuwe records standaard verborgen toe | -| Read-only integraties | Pass; 233 templates, 3 array-/poolgroepen, 90 AppOps-containers GET-only en AdGuard connected; NPM bewust disabled | -| Live in-app browser | Pass; favorietenrail, echte iconen, 7 panelen, instellingenzoeker/controls, scrolltop 0 en geen horizontale overflow | - -De enige foutregel in de eerste vijf minuten was een door de releasecheck zelf veroorzaakte lege POST zonder JSON-contenttype (verwachte HTTP 415); de onmiddellijk herhaalde geldige synchronisatie gaf HTTP 200 en alle daaropvolgende health-/dashboardchecks zijn groen. - -## Stitch consistentie- en ultrawidepass - 2026-07-22 - -| Check | Resultaat | -| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `npm run format:check` | Pass, alle bestanden Prettier-conform | -| `npm run lint` | Pass, 0 waarschuwingen | -| `npm run typecheck` | Pass | -| `npm test` | Pass, 42/42 | -| `npm run test:security` | Pass, 5/5 | -| `npm run test:e2e` | Pass, 11 uitgevoerd / 9 project-gededupliceerd op desktop/tablet/mobile/ultrawide; 0 browserfouten | -| `npm run build` | Pass, JS 363,25 kB / 113,46 kB gzip; CSS 155,55 kB / 26,33 kB gzip; server 164,6 kB | -| `npm audit --audit-level=moderate` | Pass, 0 kwetsbaarheden | -| React-review | Pass: lokale state voor widgetdock, media-query cleanup, afgeleide Quick Launch-snelkoppelingen en semantische linkcontrols | -| Secret-/mutatiescan | Pass; geen toegevoegde secrets, externe UI-assets of Docker-mutaties | - -Deze pass maakt de platformlayout consistent tussen dashboard en instellingen, vergroot de instellingen-typografie, gebruikt ultrawide-detectie om widgets als rechterdock te tonen, laat Quick Launch-favorieten met echte iconen renderen en vervangt de lege Quick Launch-uitleg door functionele snelkoppelingskaarten. Loopback-iconen uit discovery worden op een LAN-host naar de actuele DockDeck-host herschreven, zodat appiconen zoals Widefrog niet meer naar client-localhost wijzen. De E2E-suite valideert daarnaast dat de ultrawide-widgetdock automatisch open staat en rechts van de dashboardgroepen ligt zonder horizontale overflow. - -## Edge-anchored Stitch shell en rijkere Dockerkaarten - 2026-07-22 - -| Check | Resultaat | -| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | -| `npm run format:check` | Pass, alle bestanden Prettier-conform | -| `npm run lint` | Pass, 0 waarschuwingen | -| `npm run typecheck` | Pass | -| `npm test` | Pass, 42/42 | -| `npm run test:security` | Pass, 5/5 | -| `npm run test:e2e` | Pass, 11 uitgevoerd / 9 project-gededupliceerd op desktop/tablet/mobile/ultrawide | -| `npm run build` | Pass, JS 363,25 kB / 113,46 kB gzip; CSS 162,12 kB / 27,27 kB gzip; server 164,6 kB | -| `npm audit --audit-level=moderate` | Pass, 0 kwetsbaarheden | -| Chrome visuele audit | Pass: dashboardrail `left=0`, settings-sidebar `left=0`, ultrawide widgetdock `right=3440`, 0 horizontale overflow op 1920 en 3440 px | - -Deze pass verwijdert de zwevende dashboard-shell op desktop: de linkerfavorietenrail staat tegen de viewportzijde en gebruikt dezelfde oppervlakte, padding en typografische taal als de instellingenrail. Op ultrawide staat het widgetpaneel als rechterdock tegen de rechter viewportzijde. Docker-snelkoppelingen zijn visueel zwaardere Stitch-servicekaarten met grotere iconen, statuschips, accentrail, arrow affordance, subtiel verloop en diepere hoverrespons. - -### Live categorie-indeling - 2026-07-22 - -Voor de live DockDeck-config op `http://192.168.10.150:1218` is vooraf een secretvrije export gemaakt naar `.codex-input/dockdeck-categories-before-20260722-052942.json`. Daarna zijn 139 apps via de DockDeck-config-API over 16 categorieën verdeeld; verificatie: 0 apps zonder categorie. Categorieaantallen: Media 17, Downloads 2, Books & Documents 7, Photos 14, Home & Automation 2, Security & Identity 4, Network & Admin 4, Monitoring 7, Development & Projects 45, AI & ML 3, Databases & Storage 12, Infrastructure 16, Games & Emulation 5, Tools & Productivity 1, Experiments & Archived 0, Favorites 0. - -Live codevervanging is daarna alsnog uitgevoerd via de bestaande SSH-alias `unraid-widefrog` (`root`, sleutel `widefrog_unraid_deploy`). De deploymentmap is een uitgepakte source-copy en geen git-worktree; daarom is commit `41942d5` via een checksum-gecontroleerd `git archive` naar Tower gekopieerd en over de source uitgepakt. `.env` bleef byte-identiek (`e41e1b63...` voor en na), `docker compose config --quiet` slaagde, `docker compose up -d --build` bouwde image `af96b8ac7a04...` en de `DockDeck`-container staat healthy met 0 restarts op `192.168.10.150:1218`. Na een geldige read-only sync zijn Unraid templates, Unraid runtime, AppOps inventory en AdGuard Home widget connected; dashboardverificatie: 139 apps, 55 zichtbaar, 16 categorieën, 0 zonder categorie. diff --git a/deploy/dockdeck-unraid-template.xml b/deploy/dockdeck-unraid-template.xml deleted file mode 100644 index a8e27d16..00000000 --- a/deploy/dockdeck-unraid-template.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - DockDeck - dockdeck:local - bridge - sh - false - http://192.168.10.150:3000/NuklearRabbit/DockDeck - http://192.168.10.150:3000/NuklearRabbit/DockDeck - 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. - Tools:Status: - http://[IP]:[PORT:1218] - http://192.168.10.150:1218/dockdeck-icon.png - --read-only --security-opt=no-new-privileges:true - - - 1784060000 - - - Unraid AppOps Gateway on port 1216 - 1218 - /mnt/user/appdata/dockdeck/data - /mnt/user/appdata/dockdeck/unraid-templates - /var/local/emhttp - /sys/class/hwmon - /proc/cpuinfo - /proc/meminfo - http://192.168.10.150:1216 - 192.168.10.150 - http://192.168.10.150:5000 - diff --git a/deploy/refresh-unraid-icon.sh b/deploy/refresh-unraid-icon.sh deleted file mode 100755 index 8931d101..00000000 --- a/deploy/refresh-unraid-icon.sh +++ /dev/null @@ -1,48 +0,0 @@ -#!/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#.*\(.*\).*#\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 " ;; -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" diff --git a/deploy/sanitize-unraid-templates.py b/deploy/sanitize-unraid-templates.py deleted file mode 100644 index d21a9177..00000000 --- a/deploy/sanitize-unraid-templates.py +++ /dev/null @@ -1,61 +0,0 @@ -#!/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() diff --git a/deploy/unraid.env.example b/deploy/unraid.env.example deleted file mode 100644 index 54eed2a6..00000000 --- a/deploy/unraid.env.example +++ /dev/null @@ -1,9 +0,0 @@ -DOCKDECK_BIND_ADDRESS=192.168.10.150 -DOCKDECK_PORT=1218 -DOCKDECK_EGRESS_SUBNET=172.31.240.16/28 -UNRAID_TEMPLATES_DIR=/mnt/user/appdata/dockdeck/unraid-templates -UNRAID_HOST=192.168.10.150 -UNRAID_URL=http://192.168.10.150:5000 -UNRAID_RUNTIME_DIR=/var/local/emhttp -UNRAID_HWMON_DIR=/sys/class/hwmon -APPOPS_URL=http://192.168.10.150:1216 diff --git a/docker-compose.socket-proxy.yml b/docker-compose.socket-proxy.yml deleted file mode 100644 index 359728d0..00000000 --- a/docker-compose.socket-proxy.yml +++ /dev/null @@ -1,56 +0,0 @@ -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:latest - 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} diff --git a/docker-compose.yml b/docker-compose.yml index 440bcb2f..39b4507d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,119 +1,183 @@ services: - dockdeck: - container_name: DockDeck - build: - context: . - 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: http://192.168.10.150:3000/NuklearRabbit/DockDeck - net.unraid.docker.managed: dockerman - net.unraid.docker.webui: "http://${UNRAID_HOST:-tower.local}:${DOCKDECK_PORT:-1218}" - net.unraid.docker.icon: "http://${UNRAID_HOST:-tower.local}:${DOCKDECK_PORT:-1218}/dockdeck-icon.png" + db: + image: postgis/postgis:16-3.4 environment: - HOST: 0.0.0.0 - PORT: 3000 - DATABASE_PATH: /data/dockdeck.db - INTEGRATION_ENV_PATH: /data/integrations.env - 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:-tower.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" + POSTGRES_DB: ${GEOINTEL_POSTGRES_DB:-geointel} + POSTGRES_USER: ${GEOINTEL_POSTGRES_USER:-geointel} + POSTGRES_PASSWORD: ${GEOINTEL_POSTGRES_PASSWORD:-geointel} 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 + - geointel_postgis:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${GEOINTEL_POSTGRES_USER:-geointel} -d ${GEOINTEL_POSTGRES_DB:-geointel}"] + interval: 5s + timeout: 5s + retries: 10 -networks: - dockdeck_egress: - ipam: - config: - - subnet: ${DOCKDECK_EGRESS_SUBNET:-172.31.240.16/28} + backend: + build: + context: ./backend + args: + GEOINTEL_INSTALL_AI: ${GEOINTEL_INSTALL_AI:-false} + environment: + DATABASE_URL: postgresql+psycopg://${GEOINTEL_POSTGRES_USER:-geointel}:${GEOINTEL_POSTGRES_PASSWORD:-geointel}@db:5432/${GEOINTEL_POSTGRES_DB:-geointel} + STORAGE_ROOT: /app/storage + CORS_ORIGINS: ${GEOINTEL_CORS_ORIGINS:-http://localhost:1202,http://127.0.0.1:1202} + MAX_UPLOAD_MB: ${GEOINTEL_MAX_UPLOAD_MB:-500} + ORTHOPHOTO_ENABLED: ${ORTHOPHOTO_ENABLED:-true} + ORTHOPHOTO_WMS_URL: ${ORTHOPHOTO_WMS_URL:-https://geo.api.vlaanderen.be/OMWRGBMRVL/wms} + ORTHOPHOTO_WMS_LAYER: ${ORTHOPHOTO_WMS_LAYER:-Ortho} + ORTHOPHOTO_RESOLUTION_M: ${ORTHOPHOTO_RESOLUTION_M:-1.0} + ORTHOPHOTO_MIN_SIDE_M: ${ORTHOPHOTO_MIN_SIDE_M:-128} + ORTHOPHOTO_MAX_SIDE_M: ${ORTHOPHOTO_MAX_SIDE_M:-1024} + ORTHOPHOTO_CACHE_TTL_HOURS: ${ORTHOPHOTO_CACHE_TTL_HOURS:-24} + SOURCE_CATALOG_PROBE_ENABLED: ${SOURCE_CATALOG_PROBE_ENABLED:-true} + SOURCE_CATALOG_GRB_WFS_URL: ${SOURCE_CATALOG_GRB_WFS_URL:-https://geo.api.vlaanderen.be/GRB/wfs} + GRB_ENABLED: ${GRB_ENABLED:-true} + GRB_OGC_API_URL: ${GRB_OGC_API_URL:-https://geo.api.vlaanderen.be/GRB/ogc/features/v1} + GRB_MIN_SIDE_M: ${GRB_MIN_SIDE_M:-10} + GRB_MAX_SIDE_M: ${GRB_MAX_SIDE_M:-20000} + GRB_PAGE_SIZE: ${GRB_PAGE_SIZE:-1000} + GRB_MAX_PAGES: ${GRB_MAX_PAGES:-200} + GRB_MAX_FEATURES: ${GRB_MAX_FEATURES:-150000} + GRB_TIMEOUT_SECONDS: ${GRB_TIMEOUT_SECONDS:-180} + GRB_MAX_RESPONSE_MB: ${GRB_MAX_RESPONSE_MB:-20} + GRB_MAX_TOTAL_RESPONSE_MB: ${GRB_MAX_TOTAL_RESPONSE_MB:-256} + GRB_CACHE_TTL_HOURS: ${GRB_CACHE_TTL_HOURS:-24} + OFFICIAL_VECTOR_ENABLED: ${OFFICIAL_VECTOR_ENABLED:-true} + BWK_WFS_URL: ${BWK_WFS_URL:-https://geo.api.vlaanderen.be/BWK/wfs} + DOV_SOIL_WFS_URL: ${DOV_SOIL_WFS_URL:-https://www.dov.vlaanderen.be/geoserver/wfs} + SPW_PICC_ENABLED: ${SPW_PICC_ENABLED:-true} + SPW_PICC_MAPSERVER_URL: ${SPW_PICC_MAPSERVER_URL:-https://geoservices.wallonie.be/arcgis/rest/services/TOPOGRAPHIE/PICC_VDIFF/MapServer} + SPW_FLOOD_HAZARD_ENABLED: ${SPW_FLOOD_HAZARD_ENABLED:-true} + SPW_FLOOD_HAZARD_MAPSERVER_URL: ${SPW_FLOOD_HAZARD_MAPSERVER_URL:-https://geoservices.wallonie.be/arcgis/rest/services/EAU/ALEA_INOND/MapServer} + URBIS_ENABLED: ${URBIS_ENABLED:-true} + URBIS_WFS_URL: ${URBIS_WFS_URL:-https://geoservices-vector.irisnet.be/geoserver/urbisvector/ows} + OFFICIAL_VECTOR_MIN_SIDE_M: ${OFFICIAL_VECTOR_MIN_SIDE_M:-10} + OFFICIAL_VECTOR_MAX_SIDE_M: ${OFFICIAL_VECTOR_MAX_SIDE_M:-20000} + OFFICIAL_VECTOR_PAGE_SIZE: ${OFFICIAL_VECTOR_PAGE_SIZE:-1000} + OFFICIAL_VECTOR_MAX_PAGES: ${OFFICIAL_VECTOR_MAX_PAGES:-200} + OFFICIAL_VECTOR_MAX_FEATURES: ${OFFICIAL_VECTOR_MAX_FEATURES:-100000} + OFFICIAL_VECTOR_TIMEOUT_SECONDS: ${OFFICIAL_VECTOR_TIMEOUT_SECONDS:-180} + OFFICIAL_VECTOR_MAX_RESPONSE_MB: ${OFFICIAL_VECTOR_MAX_RESPONSE_MB:-20} + OFFICIAL_VECTOR_MAX_TOTAL_RESPONSE_MB: ${OFFICIAL_VECTOR_MAX_TOTAL_RESPONSE_MB:-256} + OFFICIAL_VECTOR_CACHE_TTL_HOURS: ${OFFICIAL_VECTOR_CACHE_TTL_HOURS:-24} + SOURCE_CATALOG_STATBEL_DCAT_URL: ${SOURCE_CATALOG_STATBEL_DCAT_URL:-https://doc.statbel.be/publications/DCAT/DCAT_opendata_datasets.ttl} + SOURCE_CATALOG_STATBEL_MAX_RESPONSE_MB: ${SOURCE_CATALOG_STATBEL_MAX_RESPONSE_MB:-5} + SOURCE_CATALOG_ALZ_RELEASE_URL: ${SOURCE_CATALOG_ALZ_RELEASE_URL:-https://landbouwcijfers.vlaanderen.be/open-geodata-landbouwgebruikspercelen} + SOURCE_CATALOG_PROBE_TIMEOUT_SECONDS: ${SOURCE_CATALOG_PROBE_TIMEOUT_SECONDS:-10} + SOURCE_CATALOG_PROBE_MAX_RESPONSE_MB: ${SOURCE_CATALOG_PROBE_MAX_RESPONSE_MB:-2} + SOURCE_CATALOG_PROBE_CACHE_TTL_SECONDS: ${SOURCE_CATALOG_PROBE_CACHE_TTL_SECONDS:-900} + DHMV_ENABLED: ${DHMV_ENABLED:-true} + DHMV_WCS_URL: ${DHMV_WCS_URL:-https://geo.api.vlaanderen.be/DHMV/wcs} + DHMV_RESOLUTION_M: ${DHMV_RESOLUTION_M:-5.0} + DHMV_MIN_SIDE_M: ${DHMV_MIN_SIDE_M:-10} + DHMV_MAX_SIDE_M: ${DHMV_MAX_SIDE_M:-20000} + DHMV_MAX_PIXELS: ${DHMV_MAX_PIXELS:-12000000} + DHMV_TIMEOUT_SECONDS: ${DHMV_TIMEOUT_SECONDS:-300} + DHMV_MAX_RESPONSE_MB: ${DHMV_MAX_RESPONSE_MB:-160} + FLOOD_HAZARD_ENABLED: ${FLOOD_HAZARD_ENABLED:-true} + FLOOD_HAZARD_WCS_URL: ${FLOOD_HAZARD_WCS_URL:-https://geoservice.waterinfo.be/OGRK/wcs} + FLOOD_HAZARD_RESOLUTION_M: ${FLOOD_HAZARD_RESOLUTION_M:-5.0} + FLOOD_HAZARD_MIN_SIDE_M: ${FLOOD_HAZARD_MIN_SIDE_M:-10} + FLOOD_HAZARD_MAX_SIDE_M: ${FLOOD_HAZARD_MAX_SIDE_M:-20000} + FLOOD_HAZARD_MAX_PIXELS: ${FLOOD_HAZARD_MAX_PIXELS:-12000000} + FLOOD_HAZARD_TIMEOUT_SECONDS: ${FLOOD_HAZARD_TIMEOUT_SECONDS:-300} + FLOOD_HAZARD_MAX_RESPONSE_MB: ${FLOOD_HAZARD_MAX_RESPONSE_MB:-160} + BATHYMETRY_PROFILES_ENABLED: ${BATHYMETRY_PROFILES_ENABLED:-true} + BATHYMETRY_PROFILES_LAYER_URL: ${BATHYMETRY_PROFILES_LAYER_URL:-https://vha.waterinfo.be/arcgis/rest/services/digitale_atlas/MapServer/0} + BATHYMETRY_WATERCOURSE_LAYER_URL: ${BATHYMETRY_WATERCOURSE_LAYER_URL:-https://vha.waterinfo.be/arcgis/rest/services/digitale_atlas/MapServer/1} + BATHYMETRY_PROFILES_PAGE_SIZE: ${BATHYMETRY_PROFILES_PAGE_SIZE:-1000} + BATHYMETRY_PROFILES_MAX_FEATURES: ${BATHYMETRY_PROFILES_MAX_FEATURES:-50000} + BATHYMETRY_PROFILES_TIMEOUT_SECONDS: ${BATHYMETRY_PROFILES_TIMEOUT_SECONDS:-120} + BATHYMETRY_PROFILES_MAX_RESPONSE_MB: ${BATHYMETRY_PROFILES_MAX_RESPONSE_MB:-32} + MDK_BATHYMETRY_PROBE_ENABLED: ${MDK_BATHYMETRY_PROBE_ENABLED:-true} + MDK_BATHYMETRY_WCS_URL: ${MDK_BATHYMETRY_WCS_URL:-https://bathy.agentschapmdk.be/spatialfusionserver/services/ows/wcs/EL_wcs} + MDK_BATHYMETRY_PROBE_TIMEOUT_SECONDS: ${MDK_BATHYMETRY_PROBE_TIMEOUT_SECONDS:-20} + MDK_BATHYMETRY_PROBE_MAX_RESPONSE_MB: ${MDK_BATHYMETRY_PROBE_MAX_RESPONSE_MB:-4} + MDK_BATHYMETRY_ACQUISITION_ENABLED: ${MDK_BATHYMETRY_ACQUISITION_ENABLED:-false} + MDK_BATHYMETRY_COVERAGE_ID: ${MDK_BATHYMETRY_COVERAGE_ID:-} + MDK_BATHYMETRY_REQUEST_CRS: ${MDK_BATHYMETRY_REQUEST_CRS:-EPSG:4326} + MDK_BATHYMETRY_MAX_BBOX_DEG2: ${MDK_BATHYMETRY_MAX_BBOX_DEG2:-0.25} + MDK_BATHYMETRY_ACQUISITION_TIMEOUT_SECONDS: ${MDK_BATHYMETRY_ACQUISITION_TIMEOUT_SECONDS:-120} + MDK_BATHYMETRY_ACQUISITION_MAX_RESPONSE_MB: ${MDK_BATHYMETRY_ACQUISITION_MAX_RESPONSE_MB:-160} + THEMATIC_RASTER_ENABLED: ${THEMATIC_RASTER_ENABLED:-true} + THEMATIC_RASTER_WCS_URL: ${THEMATIC_RASTER_WCS_URL:-https://www.mercator.vlaanderen.be/raadpleegdienstenmercatorpubliek/wcs} + THEMATIC_RASTER_MIN_SIDE_M: ${THEMATIC_RASTER_MIN_SIDE_M:-100} + THEMATIC_RASTER_MAX_SIDE_M: ${THEMATIC_RASTER_MAX_SIDE_M:-60000} + THEMATIC_RASTER_MAX_PIXELS: ${THEMATIC_RASTER_MAX_PIXELS:-30000000} + THEMATIC_RASTER_TIMEOUT_SECONDS: ${THEMATIC_RASTER_TIMEOUT_SECONDS:-300} + THEMATIC_RASTER_MAX_RESPONSE_MB: ${THEMATIC_RASTER_MAX_RESPONSE_MB:-160} + WALOUS_ENABLED: ${WALOUS_ENABLED:-true} + WALOUS_SOURCE_DIR: ${WALOUS_SOURCE_DIR:-/app/storage/source-cache/walous} + WALOUS_ANALYSIS_RESOLUTION_M: ${WALOUS_ANALYSIS_RESOLUTION_M:-10} + WALOUS_MAX_SIDE_M: ${WALOUS_MAX_SIDE_M:-60000} + WALOUS_MAX_PIXELS: ${WALOUS_MAX_PIXELS:-36000000} + YOLO_ENABLED: ${YOLO_ENABLED:-false} + YOLO_MODELS_DIR: ${YOLO_MODELS_DIR:-/app/models} + YOLO_MODEL_PATH: ${YOLO_MODEL_PATH:-} + YOLO_MODEL_ID: ${YOLO_MODEL_ID:-yolo-configured} + YOLO_MODEL_DISPLAY_NAME: ${YOLO_MODEL_DISPLAY_NAME:-Configured YOLO detector} + YOLO_MODEL_VERSION: ${YOLO_MODEL_VERSION:-} + YOLO_CONFIG_DIR: ${YOLO_CONFIG_DIR:-/app/storage/ultralytics} + YOLO_DEVICE: ${YOLO_DEVICE:-cpu} + YOLO_IMAGE_SIZE: ${YOLO_IMAGE_SIZE:-640} + YOLO_MAX_TILES: ${YOLO_MAX_TILES:-100} + YOLO_MAX_DETECTIONS: ${YOLO_MAX_DETECTIONS:-1000} + YOLO_DUPLICATE_IOU_THRESHOLD: ${YOLO_DUPLICATE_IOU_THRESHOLD:-0.5} + YOLO_BATCH_SIZE: ${YOLO_BATCH_SIZE:-1} + YOLO_SEG_ENABLED: ${YOLO_SEG_ENABLED:-false} + YOLO_SEG_MODEL_PATH: ${YOLO_SEG_MODEL_PATH:-} + YOLO_SEG_MODEL_ID: ${YOLO_SEG_MODEL_ID:-yolo-seg-configured} + YOLO_SEG_MODEL_DISPLAY_NAME: ${YOLO_SEG_MODEL_DISPLAY_NAME:-Configured YOLO segmentation} + YOLO_SEG_MODEL_VERSION: ${YOLO_SEG_MODEL_VERSION:-} + SAM_ENABLED: ${SAM_ENABLED:-false} + SAM_MODEL_PATH: ${SAM_MODEL_PATH:-} + SAM_MODEL_ID: ${SAM_MODEL_ID:-sam-configured} + SAM_MODEL_DISPLAY_NAME: ${SAM_MODEL_DISPLAY_NAME:-Configured SAM segmentation} + SAM_MODEL_VERSION: ${SAM_MODEL_VERSION:-} + SEGMENTATION_MAX_MASKS_PER_TILE: ${SEGMENTATION_MAX_MASKS_PER_TILE:-300} + SEGMENTATION_DUPLICATE_IOU_THRESHOLD: ${SEGMENTATION_DUPLICATE_IOU_THRESHOLD:-0.5} + GEOINTEL_RECONCILE_INTERRUPTED_RUNS_ON_STARTUP: ${GEOINTEL_RECONCILE_INTERRUPTED_RUNS_ON_STARTUP:-true} + OLLAMA_ENABLED: ${OLLAMA_ENABLED:-false} + OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://host.docker.internal:11434} + OLLAMA_DEFAULT_MODEL: ${OLLAMA_DEFAULT_MODEL:-qwen3.5:9b} + OLLAMA_TIMEOUT_SECONDS: ${OLLAMA_TIMEOUT_SECONDS:-120} + OLLAMA_MAX_OUTPUT_TOKENS: ${OLLAMA_MAX_OUTPUT_TOKENS:-1200} + OLLAMA_CONTEXT_TOKENS: ${OLLAMA_CONTEXT_TOKENS:-16384} + ports: + - "${GEOINTEL_BACKEND_PORT:-8000}:8000" + volumes: + - ${GEOINTEL_STORAGE_PATH:-./storage}:/app/storage + - ${GEOINTEL_MODELS_PATH:-./models}:/app/models + - ${GEOINTEL_BACKUPS_PATH:-./backups}:/app/backups:ro + - ./fixtures:/app/fixtures:ro + extra_hosts: + - "host.docker.internal:host-gateway" + command: sh /app/docker_start.sh + depends_on: + db: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/ready', timeout=5).read()\""] + interval: 10s + timeout: 5s + retries: 12 + start_period: 30s + + frontend: + build: + context: ./frontend + ports: + - "${GEOINTEL_FRONTEND_PORT:-1202}:80" + depends_on: + backend: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1/health/ready | grep -q '\"status\":\"ok\"'"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 10s volumes: - dockdeck_data: + geointel_postgis: diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh deleted file mode 100644 index e6563f22..00000000 --- a/docker-entrypoint.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/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 "$@" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d8660e2e..b63a45e4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,45 +1,148 @@ -# DockDeck architecture +# Architecture -## Runtime +## 1. Overzicht -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. +GeoIntel bestaat uit: + +- React/TypeScript frontend +- FastAPI backend +- PostgreSQL/PostGIS database +- background job queue +- file/object storage +- GIS processing services +- AI inference services + +## 2. Hoofdcomponenten ```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) +Frontend + ↓ REST/WebSocket +FastAPI Backend + ↓ +PostgreSQL + PostGIS + ↓ +Storage: uploads, processed rasters, tiles, masks, exports + ↓ +Workers: GIS processing, AI inference, QA/QC, export ``` -## Trust boundaries +## 3. Frontend -- 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. +Aanbevolen stack: -## Modules +- React +- TypeScript +- MapLibre GL +- Deck.gl +- Tailwind +- TanStack Query +- Zustand of vergelijkbare lichte state store +- Recharts voor eenvoudige grafieken -- `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. +Belangrijke principes: -## Layout and offline model +- kaart centraal, maar analysepanelen even belangrijk +- labs per workflow +- duidelijke jobstatus +- outputs altijd exporteerbaar +- geen verborgen mockgedrag -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. +## 4. Backend -De serviceworker cachet alleen statische applicatieshellassets. Requests onder `/api/` worden volledig overgeslagen, zodat offline content nooit als actuele runtime- of providerdata wordt gepresenteerd. +Aanbevolen stack: -## Failure behavior +- FastAPI +- SQLAlchemy 2.x +- GeoAlchemy2 +- Alembic +- Pydantic +- RQ/Celery +- Rasterio +- GeoPandas +- Shapely +- PyProj +- NumPy +- OpenCV +- Ultralytics/PyTorch -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. +## 5. Database + +PostgreSQL met PostGIS is verplicht voor: + +- projectgebieden +- vectorfeatures +- detectiepolygonen +- segmentatiepolygonen +- spatial joins +- intersects +- IoU berekeningen +- bounds queries + +## 6. Storage + +Bewaar grote bestanden niet in de database. + +Opslagcategorieën: + +- originele uploads +- verwerkte rasters +- raster tiles +- masks +- model outputs +- exports +- rapporten + +Database bewaart metadata en paden. + +## 7. Jobs + +Langlopende processen moeten via background jobs: + +- raster metadata extraction +- raster clipping +- raster tiling +- vector import +- AI inference +- segmentation polygonize +- QA/QC +- change detection +- export generation + +## 8. AI Inference + +Inference pipeline: + +```text +Raster dataset +→ clip to area +→ tile raster +→ normalize/preprocess +→ model inference +→ convert pixel coords to geospatial coords +→ merge/filter outputs +→ save detections/segmentations +→ expose as map layer +``` + +## 9. CRS-regels + +- Alle interne geometrieën worden opgeslagen in PostGIS met bekende SRID. +- Voor metrische berekeningen wordt een geschikte projectie gebruikt. +- API-output naar frontend mag in EPSG:4326 of WebMercator-compatible formaat. +- Elke dataset zonder CRS krijgt status `needs_crs_review`. + +## 10. Developmentstrategie + +Bouwvolgorde: + +1. backend foundation +2. database schema +3. project/area API +4. dataset upload en metadata +5. frontend workspace en kaart +6. vector import +7. raster import +8. processing jobs +9. detection lab +10. QA/QC +11. export diff --git a/docs/EXTERNAL_ACCESS.md b/docs/EXTERNAL_ACCESS.md deleted file mode 100644 index 755f8c3f..00000000 --- a/docs/EXTERNAL_ACCESS.md +++ /dev/null @@ -1,29 +0,0 @@ -# Authenticated external access - -DockDeck has no built-in authentication. Keep its Compose bind on `127.0.0.1` or a trusted LAN address unless an authenticated HTTPS reverse proxy protects every route. - -## Recommended topology - -```text -Internet -> HTTPS reverse proxy -> Authentik forward auth -> DockDeck :1218 -Trusted LAN -----------------------------------------------> DockDeck :1218 -``` - -## Minimum requirements - -1. Terminate TLS with a valid certificate. -2. Require authentication before proxying `/`, static assets and every `/api/*` route. -3. Do not create an unauthenticated exception for `/api/diagnostics`, integration settings or exports. -4. Preserve the original host and forwarding headers. -5. Restrict the upstream to `http://192.168.10.150:1218`; never publish the internal Docker socket proxy. -6. Use a separate minimum-scope account/token for each provider and protect `/data/integrations.env` backups. - -## Validation checklist - -- An incognito request is redirected to authentication before DockDeck HTML or API JSON is returned. -- A signed-in user can load the dashboard, search, edit a harmless presentation preference and read diagnostics. -- Signing out invalidates both page and API access. -- The browser reports HTTPS without mixed content. -- The reverse proxy does not cache `/api/*` responses. - -Nginx Proxy Manager discovery in DockDeck remains read-only and does not create or change proxy hosts. Proxy and Authentik configuration therefore stay an explicit deployment responsibility. diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md deleted file mode 100644 index 1a437bda..00000000 --- a/docs/IMPLEMENTATION_STATUS.md +++ /dev/null @@ -1,37 +0,0 @@ -# DockDeck implementation status - -Updated: 2026-07-22 - -| MVP criterion | Status | Evidence | -| ------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------- | -| Read-only Docker/Unraid discovery | Live verified | AppOps GET adapter, optional restricted proxy overlay, mocks and adapter tests. | -| New containers hidden | Built | Repository insert invariant and API/database tests. | -| App visibility/name/category/order/URL management | Built | Settings UI, Zod API and persistence tests. | -| Persistent app removal | Verified | DockDeck-only delete endpoint, `removed_apps` suppression and rediscovery/export coverage. | -| Local WebUI detection | Built | Unraid XML parser, port substitution and unit tests. | -| Status and ~30 second polling | Built | Runtime state mapping and visibility-aware client interval. | -| Categories and both dashboard views | Built | CRUD API, settings and responsive overview/tab UI. | -| Favorites | Built | CRUD API, settings, dashboard tiles and search index. | -| Search and Google fallback | Verified | Dynamic full-payload index, keyboard selection, explicit Google action and browser flow. | -| Links open in new tabs | Built | All tiles/results use `_blank` with `noopener noreferrer`. | -| Three themes | Built | Porcelain, Midnight and Harbor tokens and controls. | -| Persistent appearance customization | Verified | Theme, mode, accent, density, width, background, hero, status, reduced motion and polling with migration/export coverage. | -| Searchable settings | Verified | Global section search, accessible controls and automatic scroll restoration on section changes. | -| Read-only service widgets | Verified | Per-app opt-in, eight-widget limit, domain data, three sizes, four layers and configurable presentation. | -| Configurable Tower identity | Live verified | Persistent server name/URL in Settings, search and host card; live URL is `192.168.10.150:5000`. | -| Automatic favorite favicons | Verified | First-party `/favicon.ico` derivation, explicit override and monogram fallback. | -| SQLite persistence | Built | Named Docker volume and restart persistence test. | -| Export/import | Built | Versioned Zod schema, API and settings flow; secrets excluded. | -| Read-only NPM integration | Built | Optional server-side adapter, unambiguous matching and fallback. | -| Degraded states | Built | Adapter isolation, health states, banner and integration panel. | -| Docker/Unraid packaging | Live verified | Commit `3ffd39b`, one healthy `DockDeck` on `192.168.10.150:1218`; 137 stored/55 visible apps and 90 GET-only containers. | -| Private Gitea repository | Verified | Private `NuklearRabbit/DockDeck`; local `main` tracks `origin/main`. | -| Professional responsive UI | Live verified | 390px through ultrawide plus live 1280×720; adaptive cards, persistent favorites sidebar and no overflow. | -| Independent widget composition | Verified | Order, size, content, labels, metric styles/limits, any-signal warnings, cadence, charts and edit mode. | -| Native Unraid health | Live verified | Array STARTED, parity healthy, three storage groups and temperatures through read-only mounts. | -| App-specific provider breadth | Live verified | 97/97 stored apps have an explicit domain profile; zero generic profiles remain in the live capability audit. | -| Per-app metric composition | Live verified | Labels can be selected, ordered, styled, limited, charted and monitored and survive presets/export/restart. | -| Configurable Unraid trends | Live verified | Container, image, storage, temperature, parity and UPS series with statistic selection and 60-point memory. | -| Unraid Docker-list icon | Live verified | DockerMan visibly renders the 512×512 PNG after both persistent and active caches are refreshed. | -| Layout presets | Verified | Create, apply, delete and independent import/export with database/API/browser coverage. | -| PWA and diagnostics | Live verified | Installable shell excludes `/api`; diagnostics are secret-free and report native/integration health. | diff --git a/docs/PLATFORM_AUDIT.md b/docs/PLATFORM_AUDIT.md deleted file mode 100644 index 85834527..00000000 --- a/docs/PLATFORM_AUDIT.md +++ /dev/null @@ -1,75 +0,0 @@ -# DockDeck platform audit - -Updated: 2026-07-15 - -## Executive assessment - -DockDeck is now a complete daily-use dashboard rather than a launcher with generic telemetry. Navigation, app management, responsive presentation, widget composition, service-specific data, Unraid host depth, freshness, portability and diagnostics form one coherent read-only product. The P0, P1 and P2 roadmap from the previous audit is implemented. - -## Current scorecard - -| Area | Assessment | Evidence | -| --------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------- | -| Navigation and search | Complete | Dynamic index follows renames, removals, URLs, categories, favorites and Tower. | -| Responsive shell | Complete | Mobile, tablet, desktop and ultrawide layouts have no positive horizontal overflow. | -| App management | Complete | Rename, override, categorize, hide and persistent DockDeck-only removal. | -| Widget composition | Complete | Order, three sizes, content layers, per-metric visibility/order/style, limits, labels, warnings and edit mode. | -| Widget data depth | Complete framework | Live audit: 97/97 apps have a specific capability profile, zero generic profiles; 23 native providers. | -| Unraid host depth | Complete | AppOps plus array/parity/storage/temperature/UPS, selectable stats and configurable server trend charts. | -| Freshness and trends | Complete | Fresh/stale/unavailable semantics, cadence, stale cache and selectable line/area/bar charts up to sixty samples. | -| Portability | Complete | Full secret-free backup plus independent layout preset import/export. | -| Operations | Complete for LAN | Secret-free diagnostics, health endpoint, PWA shell and authenticated reverse-proxy guide. | -| Security | Strong for trusted LAN | Single socketless container, GET-only integrations, masked secrets and API-excluding service worker. | - -## Delivered roadmap - -### Personal widget depth - -- Persistent Tower name and URL in Settings with port `5000` as the default Unraid WebUI route. -- Capability coverage expanded from a provider shortlist to every discovered app. -- Native Audiobookshelf, Netdata and PeerTube readers added to the existing provider set. -- Domain profiles for the actual live inventory, including app/API/frontend/worker/database/cache, mail, GPU, download, media, document and custom-project workloads. -- Per-app metric visibility and ordering, including dynamically returned labels and preset/export persistence. -- Per-app value/gauge/progress presentation, statistic limits, line/area/bar charts, legends and selectable 10–60 sample windows. -- Warning rules can target any numeric infrastructure or provider metric and trigger above or below the configured value. -- Capability labels remain app-specific before an endpoint is connected; absent values say `Connect data` instead of showing unrelated generic Docker labels. -- BlockPilot root/runtime/API/web/Minecraft/ViaProxy, LumaOps, Chimera, OpenRGB, Porkfolio and unnamed legacy workloads now have explicit profiles rather than the last generic service profile. -- Tower exposes selectable/ordered server statistics and charts for containers, images, storage, temperature, parity and UPS. -- A constrained custom contract (`summary` plus at most twelve stats) for apps without a native read API; endpoint and optional bearer token are configured in Widget Studio and remain server-side. - -### P0 - -- Independent widget order with keyboard-accessible move controls. -- Native Unraid adapter for array state, parity, disk/pool usage, temperatures and optional UPS data. -- App-specific Gitea adapter and GET-only metrics bridges for Deluge, JDownloader and Tdarr. -- Explicit freshness states with last-successful provider cache. - -### P1 - -- Metric order, primary metric, custom labels and numeric provider/infrastructure warning thresholds. -- Saved, applicable, removable and portable layout presets. -- Safe per-widget refresh cadence between 15 seconds and 5 minutes. -- Direct dashboard edit mode with move, resize and hide controls. -- Provider expansion for Jellyfin, Seerr, Prowlarr, Authentik, NPM, Portainer, Grafana, Prometheus and Nextcloud, plus bridges for qBittorrent, Bazarr and Vaultwarden. - -### P2 - -- Selectable line, area and bar charts with at most sixty in-memory points and no monitoring database. -- Installable PWA application shell; API responses are never cached. -- Independent preset export/import. -- Secret-free diagnostics and an authenticated external-access guide. - -## Remaining environment-dependent opportunities - -- Fill provider credentials or bridge URLs in Settings only for services whose protected API data is desired. Safe Docker fallback remains available without them. -- Configure `UNRAID_UPS_STATUS_PATH` only when a stable read-only APC/UPS status file is available on the host. -- Enable external access only behind authenticated HTTPS; DockDeck deliberately has no built-in user system. -- Persistent long-term history, alerts and notifications remain intentionally outside scope. They would turn DockDeck into a monitoring platform and need separate retention and delivery decisions. - -## Guardrails - -- A widget is app-specific only when its metrics describe the real workload of that app. -- Every external request remains GET-only, time-bounded and server-side. -- Provider failure never blocks navigation and always falls back to safe telemetry. -- Compact panels answer one question, standard panels a few, and wide panels may expose deeper context. -- Layout remains fully usable without drag-and-drop and is tested at both mobile and ultrawide sizes. diff --git a/docs/UX_PREMIUM_ATLAS_PASS_2026-07-26.md b/docs/UX_PREMIUM_ATLAS_PASS_2026-07-26.md new file mode 100644 index 00000000..2f529720 --- /dev/null +++ b/docs/UX_PREMIUM_ATLAS_PASS_2026-07-26.md @@ -0,0 +1,34 @@ +# Premium Atlas workbench pass — 2026-07-26 + +## Outcome + +The authenticated GeoIntel workbench now follows a more deliberate geospatial command-center hierarchy. The pass preserves all existing workflows and data contracts while improving visual hierarchy, scanability, responsive behavior, and operational state presentation. + +## Changes + +- dark Atlas navigation rail with clearer brand and active-workspace states; +- elevated context bar for project, area, platform health, and operator identity; +- stronger workspace titles, controls, shared panels, and form hierarchy; +- composed status overview with a derived readiness ring and richer status cards; +- improved source cards and three-plane source workspace composition; +- elevated map canvas, side panels, theme controls, and floating tools; +- one calm, accessible workspace warning instead of a loose inline error; +- compact, desktop, and ultrawide responsive refinements; +- reduced-motion support retained. + +## Design decision + +The new layer lives in `frontend/src/styles/atlas-premium-v2.css` and loads after the established stylesheets. This makes the direction independently reviewable and limits behavior regression risk. Consolidation should happen only after production acceptance. + +## Verification record + +- frontend unit tests: 36 passed; +- frontend TypeScript check: passed; +- frontend production build: passed; +- frontend dependency audit: 0 vulnerabilities; +- backend suite after removal of the accidental DockDeck overlay: application/runtime contracts recovered; the remaining local failures are Windows Bash invocation incompatibilities and must be rechecked in the Linux release runtime; +- RC9 responsive production audit: pending deployment of this candidate. + +## Repository correction + +Commit `d5ea270` accidentally overlaid DockDeck root/runtime files onto GeoIntel. This pass restores every path touched by that overlay to the last clean GeoIntel revision (`9db6cca`) while retaining the intended GeoIntel frontend work. The authoritative remote remains `git@gitea.itworx.tech:Jens/geointel.git`. diff --git a/docs/design/DESIGN_SYSTEM.md b/docs/design/DESIGN_SYSTEM.md deleted file mode 100644 index e27dd523..00000000 --- a/docs/design/DESIGN_SYSTEM.md +++ /dev/null @@ -1,9 +0,0 @@ -# DockDeck design system - -De visuele basis is **DockDeck Canon v1**. Het onderhoudbare systeem, de bronselectie en de mapping naar echte productflows staan in: - -- [`dockdeck-canon-v1/DESIGN_SYSTEM.md`](./dockdeck-canon-v1/DESIGN_SYSTEM.md) -- [`dockdeck-canon-v1/SOURCE_MANIFEST.md`](./dockdeck-canon-v1/SOURCE_MANIFEST.md) -- [`dockdeck-canon-v1/IMPLEMENTATION_MAPPING.md`](./dockdeck-canon-v1/IMPLEMENTATION_MAPPING.md) - -Gebruik in productcode uitsluitend semantische tokens en lokale assets. Behoud dark en light themes, Nederlandse terminologie, echte API-data en de read-only productgrenzen. diff --git a/docs/design/STITCH_CANON_UI_UPGRADE_PLAN.md b/docs/design/STITCH_CANON_UI_UPGRADE_PLAN.md deleted file mode 100644 index 75ca60a3..00000000 --- a/docs/design/STITCH_CANON_UI_UPGRADE_PLAN.md +++ /dev/null @@ -1,68 +0,0 @@ -# Stitch Canon UI-upgradeplan - -Datum: 2026-07-21 -Branch: `codex/stitch-canon-ui-upgrade` - -## Nulmeting - -DockDeck is een React/Vite-single-page-app met Fastify REST, SQLite en gedeelde Zod-contracten. `App.tsx` beheert dashboard/settings, polling, themadata-attributen en toasts. `Dashboard.tsx` groepeert echte apps en favorieten. `Settings.tsx` bevat de bestaande beheerflows; widgetconfiguratie blijft in afzonderlijke componenten. Er is geen router: schermwisseling is lokale React-state. - -De bestaande functionaliteit is operationeel, maar de interface wijkt zichtbaar van Canon v1 af: Engelse copy, een permanente brede dashboardrail, een prominente monitoringlaag vóór de launcherinhoud en volledig uitgeklapte appformulieren. De baseline is opgeslagen onder `.codex-input/baseline/`. - -Baselinechecks: - -- format, lint en typecheck: geslaagd; -- unit/integratie: 42/42 geslaagd; -- security: 5/5 geslaagd; -- build en audit: geslaagd, 0 kwetsbaarheden; -- E2E-start: omgevingsblokkade doordat een WSL-relay poort 3000 bezet; de upgrade maakt de testpoort configureerbaar. - -## Routes en workflows - -| Oppervlak | Werkelijke workflow | Canonvertaling | -| ----------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | -| Dashboard | apps/favorieten openen, categorieën, sync, widgets | compacte topbar, Quick Launch eerst, favorieten en apps als launcher; widgets lager en optioneel | -| Quick Launch | apps/favorieten/Tower/Google, toetsenbord | gegroepeerde command-palette met Apps, Favorieten en Google | -| Apps | zoeken, zichtbaarheid, widget, naam, categorie, URL's, icoon, sortering, verwijderen | compacte beheerregels met filters en een app-editor drawer | -| Favorieten | toevoegen, wijzigen, verwijderen | Canon-lijst plus rustig formulier/drawerpatroon | -| Categorieën | toevoegen, wijzigen, icoon, sortering, verwijderen | Canon-lijst met duidelijke aantallen en inline-acties | -| Weergave | thema, accent, dichtheid, breedte, achtergrond, hero/status, modus | visuele keuzevelden en previews binnen dezelfde settings-shell | -| Widgets/operaties | bestaande read-only panelen, presets en diagnostics | behouden onder Algemeen als secundaire geavanceerde functies | -| Integraties | status en gemaskeerde credentials | doelgerichte read-only statuskaarten zonder secret-echo | -| Back-up | export/import versie 1 | Canon back-up- en hersteloppervlak, zonder fictieve historie | - -## Componentstrategie - -Hergebruikt: API-client, contracten, URL-resolutie, iconpipeline, widgetconfigurators, server- en servicewidgets, persistence en alle backendvalidatie. - -Herbouwd of toegevoegd: semantische tokenlaag, lokale merkassets, `DashboardTopBar`, dashboardcompositie, categorie-navigatie, app-/favoriettegels, Quick Launch-groepering, settings-shell, compacte appbeheerregels, app-editor drawer, banners, empty/loading/degraded states en mobiele navigatie. - -## Tokens en responsiviteit - -Canon v1 gebruikt een 8px-basis, diepe navy, tonale oppervlakken, lavendel voor actie/focus en groen uitsluitend voor positieve status. Dark, light en het bestaande aanvullende thema blijven functioneel via semantische CSS-variabelen. Bestaande accenten blijven beschikbaar maar worden op de Canon-tokenlaag geprojecteerd. - -- 1920×1080 en 1440×900: max. 1280px launcherinhoud, compacte topbar, vier appkolommen waar passend. -- 768–1024px: twee kolommen, compacte settingsnavigatie. -- 390×844: echte mobiele compositie, horizontaal scrollbare categorieën, één/twee kolommen naar inhoud, vaste ondernavigatie. -- Geen horizontale overflow; minimum klikdoel 44px; `prefers-reduced-motion` blijft leidend. - -## Risico's en mitigatie - -- De huidige widgetdiepgang is groter dan de statische Canon-scope. De functies blijven bestaan maar verdwijnen uit de primaire launcherhiërarchie. -- `Settings.tsx` is groot. De wijziging beperkt backend/API-impact en splitst nieuwe UI-primitives en drawerlogica waar dat reviewbaarheid verbetert. -- E2E gebruikt een vaste poort. De configuratie krijgt een geïsoleerde testpoort zonder productieruntime te wijzigen. -- Externe appiconen kunnen falen. De bestaande veilige monogramfallback blijft behouden. - -## Validatie - -Twee visuele rondes vergelijken dark/light dashboard, Quick Launch, settings/apps, editor en mobiel met de referenties. Daarna volgen alle verplichte scripts, browserconsole/netwerkcontrole, overflowchecks op 390×844, 768×1024, 1440×900 en 1920×1080, Compose-config en een Docker-imagebuild wanneer de lokale Docker-runtime beschikbaar is. - -## Niet-doelen - -Geen Docker-mutaties, containerbeheer, accounts, terminal/logviewer, nieuwe backendintegraties, fictieve data, automatische back-uphistorie of externe tijdelijke assets. - -## Resultaat - -Afgerond op 2026-07-21. De Canon-tokenlaag, lokale Geist-fonts, merkassets, topbar, launcherhiërarchie, Quick Launch, compacte settings-shell, app-editor drawer en responsive mobiele compositie zijn in de bestaande React/Fastify-app geïntegreerd. De E2E-runtime gebruikt geïsoleerde poorten 3101/5174 zodat een lokale WSL-relay op 3000 de suite niet meer blokkeert. De finale gates en eventuele resterende omgevingsbeperkingen staan in `TEST_LOG.md`, `RISKS.md` en `HANDOFF.md`. - -Een aanvullende fidelity-pass heeft de eerder nog eigen DockDeck-interpretatie vervangen door de daadwerkelijke `canon_*`-compositie. Stitch bepaalt nu vrijwel volledig layout, density, kaartfamilies, navigatie, overlay, settings-tabel, drawer en mobiele hiërarchie; alleen echte data, bestaande validatie en operationele workflows wijken bewust af van de statische voorbeelden. diff --git a/docs/design/dockdeck-canon-v1/DESIGN_SYSTEM.md b/docs/design/dockdeck-canon-v1/DESIGN_SYSTEM.md deleted file mode 100644 index dcb196ca..00000000 --- a/docs/design/dockdeck-canon-v1/DESIGN_SYSTEM.md +++ /dev/null @@ -1,30 +0,0 @@ -# DockDeck Canon v1 - -DockDeck is een rustige, premium digitale thuisbasis. De interface is een launcher, geen beheer- of monitoringconsole. - -## Fundament - -- 8px spacingbasis; 16px mobiel zijmarge, 24px desktopgutter, 1280px launchermaximum. -- Geist-achtige lokaal gebundelde sans-serif, sentence case en volledig Nederlandse copy met `je/jouw`. -- Dark-first diepe navy `#0b1326`; tonale oppervlakken en subtiele 1px-randen. -- Lavendel `#818cf8` voor merk, focus, selectie en primaire acties. -- Groen `#10b981` uitsluitend voor positieve status/bevestiging; rood voor offline/fout, slate voor onbekend. -- 8px radius voor controls, 12–16px voor grotere oppervlakken, pills alleen waar de vorm betekenis heeft. -- Geen zware schaduwen of permanente pulserende statusanimaties. - -## Semantische tokens - -Productcode gebruikt tokens voor `background`, `surface`, `surface-elevated`, `border`, `text`, `text-muted`, `primary`, `focus`, `success`, `warning`, `error`, `unknown`, `hover`, `active` en `disabled`. Thema's wijzigen de tokenwaarden, niet de componentstructuur. - -## Componentregels - -- Apptegels tonen icoon, naam en `Online`, `Offline` of `Status onbekend`; geen poort, image, CPU, RAM of uptime. -- Favoriettegels delen dezelfde visuele familie zonder runtimestatus. -- Quick Launch groepeert Apps, Favorieten en Google en ondersteunt muis en toetsenbord. -- Settings gebruikt één shell met Algemeen, Apps, Favorieten, Categorieën, Weergave, Integraties en Back-up en herstel. -- Overlays hebben Escape, focusherstel en een zichtbaar sluitdoel; drawer op desktop, schermvullend op mobiel. -- Status wordt nooit uitsluitend met kleur gecommuniceerd. - -## Assets - -Alle merkassets, fonts en productie-iconen zijn lokaal. Stitch-screenshots zijn alleen ontwerpdocumentatie; HTML/CDN/Google-hosted exportassets worden niet uitgevoerd of gebundeld. diff --git a/docs/design/dockdeck-canon-v1/IMPLEMENTATION_MAPPING.md b/docs/design/dockdeck-canon-v1/IMPLEMENTATION_MAPPING.md deleted file mode 100644 index 27e1ea5c..00000000 --- a/docs/design/dockdeck-canon-v1/IMPLEMENTATION_MAPPING.md +++ /dev/null @@ -1,37 +0,0 @@ -# Implementatiemapping — DockDeck Canon v1 - -| Canonreferentie | DockDeck-oppervlak | Implementatiekeuze | -| --------------------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -| Dashboard 1440/1920 donker | primaire startpagina | compacte topbar, begroeting, categorieën, desktopfavorietenrail, apptegels en uitklapbaar live inzicht; echte data | -| Dashboard licht | light theme | dezelfde hiërarchie en componentvormen op lichte semantische tokens | -| Dashboard categorie-modus | bestaande `viewMode=categories` | categoriechips sturen de bestaande filterlogica | -| Quick Launch resultaten/geen lokaal resultaat | `SearchOverlay` | viewportbrede portal met Apps/Favorieten/Google, pijlen, Enter, Escape en herstelde toetsenbordfocus | -| Instellingen Apps | `Settings` / apps | compacte rijen, echte ontdekstatus, filters, paginering en status/zichtbaarheid afzonderlijk | -| App-editor drawer | app PATCH-contract | naam, zichtbaarheid/categorie, lokale/externe URL, icoon en sortering in toegankelijke drawer | -| Instellingen Favorieten | favoriete CRUD | Canon-lijst en formulier met bestaande validatie | -| Instellingen Categorieën | categorie CRUD | bestaande naam/icoon/sortering/verwijderen in rustige lijst | -| Instellingen Weergave | preferences PATCH | thema, modus en dichtheid eerst; accent, breedte, achtergrond en details progressief ontsloten | -| Instellingen Integraties | status/credential-API | uitsluitend bestaande read-only integraties; secrets gemaskeerd | -| Back-up en herstel | `/api/export` en `/api/import` | bestaande versie 1-flow, geen fictieve historie | -| Mobiel 390×844 | responsive dashboard/settings | mobiele topbar, touchcategorieën, compacte tegels en ondernavigatie | - -## Bewuste afwijkingen - -- Bestaande read-only widgets, presets en diagnostics blijven beschikbaar onder Algemeen omdat dit operationele productfunctionaliteit is; ze krijgen geen voorrang boven de launcher. -- De statische voorbeelden gebruiken andere apps en aantallen; DockDeck toont uitsluitend API-data. -- Canon-copy over Docker Proxy wordt niet letterlijk gebruikt omdat productie via AppOps kan ontdekken; integratietekst volgt de echte runtime. -- Het logo wordt vereenvoudigd voor 16–48px en als lokaal SVG/PNG geleverd; de bronafbeelding wordt niet uitgesneden of gehotlinkt. - -## Implementatiestatus - -Alle bovenstaande kernoppervlakken zijn op 2026-07-21 geïmplementeerd. Het dashboard gebruikt de echte API-payload, Quick Launch groepeert server/apps/favorieten/Google, appbeheer gebruikt compacte regels plus een focus-trapped drawer en de bestaande geavanceerde widget- en diagnosticaflows staan onder **Algemeen**. De statische Canon-voorbeelden zijn dus niet als los prototype of hardcoded datalaag overgenomen. - -Na de eerste review is een tweede, directere Stitch-pass uitgevoerd. `stitch-direct.css` is de finale compositielaag en neemt nu de daadwerkelijke Canon-hiërarchie over: 64px topbar, begroeting met compacte ontdekbanner, pilnavigatie, vaste favorietkaart, vierkoloms servicekaarten, tweekoloms featurekaarten, de volledige Quick Launch-sheet, App Beheer als vijfkolomstabel en de 570px app-editor. Op mobiel worden de ontdekkaart, zoekbalk, ronde categorieknoppen, favorietegels, compacte servicerijen en vaste ondernavigatie rechtstreeks uit `canon_mobiel_dashboard_390_844` gevolgd. - -Het operationele widgetblok blijft in de normale launcher zichtbaar als compacte rij **Live inzicht** en klapt op verzoek open. De volledige Paneelstudio, presets en diagnostics blijven onder **Algemeen** beschikbaar, zodat de dagelijkse launcher rustig blijft zonder functionaliteit te verbergen. - -De visuele eindcontrole staat in [`implementation/README.md`](./implementation/README.md). Daarbij is ook de eerste-use toestand met alle nieuw ontdekte apps verborgen gecontroleerd; de gevulde screenshots gebruiken uitsluitend de geïsoleerde mockdatabase onder `.codex-input/`. - -## Consistentiepass 2026-07-22 - -Dashboard en instellingen delen nu dezelfde platformgeometrie tot 3040 px, met een schaalbare linkerrail en een rechter widgetdock dat vanaf 1900 px automatisch opent. De instellingenkopie en form controls zijn vergroot voor leesbaarheid, apptegels hebben sterkere iconografie en actieaffordances, Quick Launch gebruikt echte favorieticonen en de lege zoekstaat bevat directe snelkoppelingen in plaats van alleen uitlegtekst. diff --git a/docs/design/dockdeck-canon-v1/SOURCE_MANIFEST.md b/docs/design/dockdeck-canon-v1/SOURCE_MANIFEST.md deleted file mode 100644 index adeb0855..00000000 --- a/docs/design/dockdeck-canon-v1/SOURCE_MANIFEST.md +++ /dev/null @@ -1,23 +0,0 @@ -# Source manifest — DockDeck Canon v1 - -## ZIP - -- Bron: `C:\Users\Jens\Downloads\stitch_dockdeck_premium_interface_system (2).zip` -- SHA-256: `C5A76D95680550A74024F967F86553A008E5657C05833EE13C042C4F493BD441` -- Tijdelijke extractie: `.codex-input/stitch-dockdeck-canon-v1/` (genegeerd door Git) -- Primair: `dockdeck_canon_v1/DESIGN.md`, `dockdeck_canon_logo/screen.png` en alle `canon_*`-schermen. -- Aanvullende controle: `design.md_dockdeck_canon_v1.md`. -- Bewust genegeerd: `core_modernist/`, `obsidian_control/`, niet-`canon_*` schermen, `*_definitief` varianten en conflicterende oudere designsystemen. - -## Stitch MCP - -- Beschikbaar op 2026-07-21. -- Project: `projects/11417268779858336248` — **DockDeck Premium Interface System**. -- Project bijgewerkt: 2026-07-21T19:14:24.395101Z. -- Gebruikt design system: `assets/d3776abdbc9040bcac889ef9b20c3eb8` — **DockDeck Canon v1**, versie 1. -- Gecontroleerde metadata: Geist, dark-first, 8px afronding, `#818cf8` primair, `#10b981` positief, `#0b1326` basis en 1280px contentmaximum. -- De actuele `CANON — ...` schermen zijn vergeleken; niet-canonieke oudere schermen en de systemen Obsidian Control/Core Modernist zijn niet als productbron gebruikt. - -## Opgenomen referenties - -De map `screens/` bevat alleen dashboard dark/light, Quick Launch-resultaten, instellingen Apps, mobiel dashboard en de logo-referentie. HTML-export en externe Google-assets zijn niet opgenomen of gebruikt als productieafhankelijkheid. diff --git a/docs/design/dockdeck-canon-v1/implementation/README.md b/docs/design/dockdeck-canon-v1/implementation/README.md deleted file mode 100644 index ced43d93..00000000 --- a/docs/design/dockdeck-canon-v1/implementation/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# Canon v1 implementatiebeelden - -Deze beelden zijn op 2026-07-22 rechtstreeks uit de lokale applicatie vastgelegd met deterministische mockdiscovery. Ze bevatten geen credentials of productiegegevens. De actuele dashboards tonen de Stitch-launcherworkspace met desktopfavorietenrail, herkenbare appiconen en zichtbare read-only servicepanelen; de E2E-suite legt ze opnieuw vast. - -| Oppervlak | Beeld | -| ----------------------------------------- | ---------------------------------------------------------------------------------------- | -| Dashboard licht, desktop 1440×900 | [`dashboard-light-desktop-1440x900.png`](./dashboard-light-desktop-1440x900.png) | -| Dashboard donker, desktop 1440×900 | [`dashboard-dark-desktop-1440x900.png`](./dashboard-dark-desktop-1440x900.png) | -| Quick Launch licht, desktop 1440×900 | [`quick-launch-light-desktop-1440x900.png`](./quick-launch-light-desktop-1440x900.png) | -| Apps-instellingen licht, desktop 1440×900 | [`settings-apps-light-desktop-1440x900.png`](./settings-apps-light-desktop-1440x900.png) | -| Dashboard licht, mobiel 390×844 | [`dashboard-light-mobile-390x844.png`](./dashboard-light-mobile-390x844.png) | -| App-editor drawer, mobiel 390×844 | [`app-drawer-mobile-390x844.png`](./app-drawer-mobile-390x844.png) | -| Dashboard donker, mobiel 390×844 | [`dashboard-dark-mobile-390x844.png`](./dashboard-dark-mobile-390x844.png) | -| Dashboard donker, tablet 768×1024 | [`dashboard-dark-tablet-768x1024.png`](./dashboard-dark-tablet-768x1024.png) | -| Dashboard donker, ultrawide 3440×1440 | [`dashboard-dark-ultrawide-3440x1440.png`](./dashboard-dark-ultrawide-3440x1440.png) | -| Quick Launch donker, desktop 1440×900 | [`quick-launch-dark-desktop-1440x900.png`](./quick-launch-dark-desktop-1440x900.png) | -| Apps-instellingen donker, desktop | [`settings-apps-dark-desktop-1440x900.png`](./settings-apps-dark-desktop-1440x900.png) | -| App-editor donker, desktop | [`app-drawer-dark-desktop-1440x900.png`](./app-drawer-dark-desktop-1440x900.png) | - -De geïsoleerde E2E-database wordt alleen voor visuele verificatie aangepast om drie apps en drie favorieten zichtbaar te maken. De productregel blijft ongewijzigd: nieuw ontdekte apps zijn standaard verborgen. De finale audit controleert bovendien alle instellingensecties, de horizontale tablet-/mobiele navigatie, echte appiconen, de uitklapbare widgetlaag en een Quick Launch die ook op ultrawide het volledige viewport afdekt. diff --git a/docs/design/dockdeck-canon-v1/implementation/app-drawer-dark-desktop-1440x900.png b/docs/design/dockdeck-canon-v1/implementation/app-drawer-dark-desktop-1440x900.png deleted file mode 100644 index 732a38a6..00000000 Binary files a/docs/design/dockdeck-canon-v1/implementation/app-drawer-dark-desktop-1440x900.png and /dev/null differ diff --git a/docs/design/dockdeck-canon-v1/implementation/app-drawer-mobile-390x844.png b/docs/design/dockdeck-canon-v1/implementation/app-drawer-mobile-390x844.png deleted file mode 100644 index 8a005593..00000000 Binary files a/docs/design/dockdeck-canon-v1/implementation/app-drawer-mobile-390x844.png and /dev/null differ diff --git a/docs/design/dockdeck-canon-v1/implementation/dashboard-dark-desktop-1440x900.png b/docs/design/dockdeck-canon-v1/implementation/dashboard-dark-desktop-1440x900.png deleted file mode 100644 index 56f7e39e..00000000 Binary files a/docs/design/dockdeck-canon-v1/implementation/dashboard-dark-desktop-1440x900.png and /dev/null differ diff --git a/docs/design/dockdeck-canon-v1/implementation/dashboard-dark-mobile-390x844.png b/docs/design/dockdeck-canon-v1/implementation/dashboard-dark-mobile-390x844.png deleted file mode 100644 index 7f30601f..00000000 Binary files a/docs/design/dockdeck-canon-v1/implementation/dashboard-dark-mobile-390x844.png and /dev/null differ diff --git a/docs/design/dockdeck-canon-v1/implementation/dashboard-dark-tablet-768x1024.png b/docs/design/dockdeck-canon-v1/implementation/dashboard-dark-tablet-768x1024.png deleted file mode 100644 index d37fc03b..00000000 Binary files a/docs/design/dockdeck-canon-v1/implementation/dashboard-dark-tablet-768x1024.png and /dev/null differ diff --git a/docs/design/dockdeck-canon-v1/implementation/dashboard-dark-ultrawide-3440x1440.png b/docs/design/dockdeck-canon-v1/implementation/dashboard-dark-ultrawide-3440x1440.png deleted file mode 100644 index a8681450..00000000 Binary files a/docs/design/dockdeck-canon-v1/implementation/dashboard-dark-ultrawide-3440x1440.png and /dev/null differ diff --git a/docs/design/dockdeck-canon-v1/implementation/dashboard-light-desktop-1440x900.png b/docs/design/dockdeck-canon-v1/implementation/dashboard-light-desktop-1440x900.png deleted file mode 100644 index 6547702d..00000000 Binary files a/docs/design/dockdeck-canon-v1/implementation/dashboard-light-desktop-1440x900.png and /dev/null differ diff --git a/docs/design/dockdeck-canon-v1/implementation/dashboard-light-mobile-390x844.png b/docs/design/dockdeck-canon-v1/implementation/dashboard-light-mobile-390x844.png deleted file mode 100644 index c889ae75..00000000 Binary files a/docs/design/dockdeck-canon-v1/implementation/dashboard-light-mobile-390x844.png and /dev/null differ diff --git a/docs/design/dockdeck-canon-v1/implementation/quick-launch-dark-desktop-1440x900.png b/docs/design/dockdeck-canon-v1/implementation/quick-launch-dark-desktop-1440x900.png deleted file mode 100644 index 94d3031f..00000000 Binary files a/docs/design/dockdeck-canon-v1/implementation/quick-launch-dark-desktop-1440x900.png and /dev/null differ diff --git a/docs/design/dockdeck-canon-v1/implementation/quick-launch-light-desktop-1440x900.png b/docs/design/dockdeck-canon-v1/implementation/quick-launch-light-desktop-1440x900.png deleted file mode 100644 index 5ddfb1dc..00000000 Binary files a/docs/design/dockdeck-canon-v1/implementation/quick-launch-light-desktop-1440x900.png and /dev/null differ diff --git a/docs/design/dockdeck-canon-v1/implementation/settings-apps-dark-desktop-1440x900.png b/docs/design/dockdeck-canon-v1/implementation/settings-apps-dark-desktop-1440x900.png deleted file mode 100644 index adebd51b..00000000 Binary files a/docs/design/dockdeck-canon-v1/implementation/settings-apps-dark-desktop-1440x900.png and /dev/null differ diff --git a/docs/design/dockdeck-canon-v1/implementation/settings-apps-light-desktop-1440x900.png b/docs/design/dockdeck-canon-v1/implementation/settings-apps-light-desktop-1440x900.png deleted file mode 100644 index e5d2f921..00000000 Binary files a/docs/design/dockdeck-canon-v1/implementation/settings-apps-light-desktop-1440x900.png and /dev/null differ diff --git a/docs/design/dockdeck-canon-v1/screens/dashboard-dark-1440x900.png b/docs/design/dockdeck-canon-v1/screens/dashboard-dark-1440x900.png deleted file mode 100644 index 164054c6..00000000 Binary files a/docs/design/dockdeck-canon-v1/screens/dashboard-dark-1440x900.png and /dev/null differ diff --git a/docs/design/dockdeck-canon-v1/screens/dashboard-light-1440x900.png b/docs/design/dockdeck-canon-v1/screens/dashboard-light-1440x900.png deleted file mode 100644 index 512c113b..00000000 Binary files a/docs/design/dockdeck-canon-v1/screens/dashboard-light-1440x900.png and /dev/null differ diff --git a/docs/design/dockdeck-canon-v1/screens/dashboard-mobile-390x844.png b/docs/design/dockdeck-canon-v1/screens/dashboard-mobile-390x844.png deleted file mode 100644 index b98be4bb..00000000 Binary files a/docs/design/dockdeck-canon-v1/screens/dashboard-mobile-390x844.png and /dev/null differ diff --git a/docs/design/dockdeck-canon-v1/screens/logo-reference.png b/docs/design/dockdeck-canon-v1/screens/logo-reference.png deleted file mode 100644 index 817aa59c..00000000 Binary files a/docs/design/dockdeck-canon-v1/screens/logo-reference.png and /dev/null differ diff --git a/docs/design/dockdeck-canon-v1/screens/quick-launch-results.png b/docs/design/dockdeck-canon-v1/screens/quick-launch-results.png deleted file mode 100644 index f1b2163c..00000000 Binary files a/docs/design/dockdeck-canon-v1/screens/quick-launch-results.png and /dev/null differ diff --git a/docs/design/dockdeck-canon-v1/screens/settings-apps-1440x900.png b/docs/design/dockdeck-canon-v1/screens/settings-apps-1440x900.png deleted file mode 100644 index 118c393a..00000000 Binary files a/docs/design/dockdeck-canon-v1/screens/settings-apps-1440x900.png and /dev/null differ diff --git a/eslint.config.js b/eslint.config.js deleted file mode 100644 index 6bfc4bb4..00000000 --- a/eslint.config.js +++ /dev/null @@ -1,34 +0,0 @@ -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", - }, - }, -); diff --git a/fixtures/unraid-templates/README.md b/fixtures/unraid-templates/README.md deleted file mode 100644 index 9b65edbd..00000000 --- a/fixtures/unraid-templates/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# 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. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4cd4273a..05d2ae3e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -2530,9 +2530,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "dev": true, "funding": [ { @@ -2676,9 +2676,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "dev": true, "funding": [ { @@ -2696,7 +2696,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e9d35892..c2ce8e1b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import { LogOut, UserRound } from 'lucide-react' +import { CircleAlert, LogOut, UserRound } from 'lucide-react' import '@fontsource/manrope/latin-500.css' import '@fontsource/manrope/latin-600.css' import '@fontsource/manrope/latin-700.css' @@ -9,6 +9,7 @@ import '@fontsource/public-sans/latin-600.css' import './styles/app.css' import './styles/premium.css' import './styles/atlas-workbench.css' +import './styles/atlas-premium-v2.css' import { LandingPage } from './components/auth/LandingPage' import { ChangeDetectionPanel } from './components/analysis/ChangeDetectionPanel' import { GeoAssistantPanel } from './components/assistant/GeoAssistantPanel' @@ -789,7 +790,15 @@ function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JS Ga naar de werkruimte

{workspaceStatusMessage}

- {errorMessage ?

{errorMessage}

: null} + {errorMessage ? ( +
+
+ ) : null}
{ expect(screen.getByText('Kaartwerkruimte is gebruiksklaar')).toBeTruthy() expect(screen.getByText("1 analysethema")).toBeTruthy() + expect(screen.getByLabelText('4 van 6 onderdelen gereed').getAttribute('data-ready')).toBe('4') }) it('does not call an unrelated stored context file an analysis theme', () => { @@ -60,5 +61,6 @@ describe('WorkbenchStatusStrip analytical readiness', () => { expect(screen.getByText('Kaartwerkruimte vraagt aandacht')).toBeTruthy() expect(screen.getByText("0 analysethema's")).toBeTruthy() + expect(screen.getByLabelText('3 van 6 onderdelen gereed').getAttribute('data-ready')).toBe('3') }) }) diff --git a/frontend/src/components/WorkbenchStatusStrip.tsx b/frontend/src/components/WorkbenchStatusStrip.tsx index eb61f6f5..ec8f416d 100644 --- a/frontend/src/components/WorkbenchStatusStrip.tsx +++ b/frontend/src/components/WorkbenchStatusStrip.tsx @@ -195,9 +195,12 @@ export function WorkbenchStatusStrip({

{nextAction(items)}

-
- {readyCount}/{items.length} - onderdelen gereed +
+
diff --git a/frontend/src/styles/atlas-premium-v2.css b/frontend/src/styles/atlas-premium-v2.css new file mode 100644 index 00000000..375f933d --- /dev/null +++ b/frontend/src/styles/atlas-premium-v2.css @@ -0,0 +1,966 @@ +/* GeoIntel Atlas premium command-center layer. + This is intentionally loaded last: it unifies the preserved workflows + without changing their data or interaction contracts. */ + +:root { + --atlas-950: #082a26; + --atlas-900: #0a3731; + --atlas-800: #0d4a42; + --atlas-700: #0d665b; + --atlas-600: #0b7f71; + --atlas-500: #159889; + --atlas-100: #dff3ef; + --atlas-50: #eef8f6; + --ink-950: #10211d; + --ink-700: #334c46; + --ink-500: #647b75; + --canvas: #f2f6f4; + --paper: #ffffff; + --paper-muted: #f8faf9; + --atlas-line: #d9e4e0; + --atlas-line-strong: #c2d2cd; + --atlas-shadow-xs: 0 1px 2px rgba(8, 42, 38, 0.05); + --atlas-shadow-sm: 0 8px 24px rgba(8, 42, 38, 0.07); + --atlas-shadow-md: 0 18px 44px rgba(8, 42, 38, 0.1); + --atlas-shadow-floating: 0 24px 70px rgba(8, 42, 38, 0.17); + --atlas-radius-sm: 0.625rem; + --atlas-radius-md: 0.875rem; + --atlas-radius-lg: 1.125rem; + --atlas-radius-xl: 1.5rem; + --bg: var(--canvas); + --panel: var(--paper); + --panel-soft: var(--paper-muted); + --surface-raised: var(--paper); + --surface-sunken: #edf3f0; + --text: var(--ink-950); + --muted: var(--ink-500); + --line: var(--atlas-line); + --line-strong: var(--atlas-line-strong); + --accent: var(--atlas-600); + --accent-strong: var(--atlas-800); + --accent-soft: var(--atlas-50); + --shadow: var(--atlas-shadow-md); + --shadow-soft: var(--atlas-shadow-sm); +} + +body { + color: var(--ink-950); + background: var(--canvas); +} + +.workbench-shell { + font-family: "Public Sans", "Segoe UI", sans-serif; + background: + radial-gradient( + circle at 78% -12%, + rgba(21, 152, 137, 0.09), + transparent 28rem + ), + linear-gradient(rgba(13, 102, 91, 0.022) 1px, transparent 1px), + linear-gradient(90deg, rgba(13, 102, 91, 0.022) 1px, transparent 1px), + var(--canvas); + background-size: + auto, + 32px 32px, + 32px 32px, + auto; +} + +.workbench-layout { + grid-template-columns: 15.5rem minmax(0, 1fr); + min-height: 100dvh; +} + +/* Brand rail */ + +.workbench-sidebar { + border: 0; + padding: 1rem 0.85rem 1.25rem; + color: rgba(255, 255, 255, 0.76); + background: + radial-gradient( + circle at 20% 4%, + rgba(85, 205, 187, 0.16), + transparent 15rem + ), + linear-gradient(180deg, var(--atlas-900), var(--atlas-950)); + box-shadow: 12px 0 36px rgba(8, 42, 38, 0.12); +} + +.sidebar-brand { + min-height: 4.75rem; + gap: 0.75rem; + margin: 0 0 1.15rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.11); + padding: 0.35rem 0.45rem 1.15rem; +} + +.sidebar-brand .brand-mark { + width: 2.65rem; + height: 2.65rem; + border: 1px solid rgba(173, 238, 227, 0.44); + border-radius: 0.8rem; + color: #dffaf5; + background: rgba(255, 255, 255, 0.09); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.14), + 0 10px 24px rgba(0, 0, 0, 0.16); +} + +.sidebar-brand-copy { + display: grid; + gap: 0.12rem; +} + +.sidebar-brand-copy strong { + color: #ffffff; + font-family: "Manrope", sans-serif; + font-size: 1.02rem; + letter-spacing: -0.025em; +} + +.sidebar-brand-copy small { + color: rgba(223, 250, 245, 0.58); + font-size: 0.72rem; +} + +.nav-group { + margin-top: 1.15rem; +} + +.workbench-shell .nav-section-label { + margin: 0 0 0.42rem; + padding: 0 0.7rem; + color: rgba(220, 246, 240, 0.46); + font-size: 0.68rem; + letter-spacing: 0.08em; +} + +.workbench-sidebar .nav-item { + position: relative; + min-height: 3rem; + gap: 0.75rem; + margin: 0.2rem 0; + border: 1px solid transparent; + border-radius: 0.75rem; + padding: 0.68rem 0.72rem; + color: rgba(239, 252, 249, 0.7); + background: transparent; + font-size: 0.82rem; + font-weight: 650; + box-shadow: none; +} + +.workbench-sidebar .nav-item:hover:not(:disabled) { + border-color: rgba(255, 255, 255, 0.1); + color: #ffffff; + background: rgba(255, 255, 255, 0.075); + box-shadow: none; +} + +.workbench-sidebar .nav-item-active, +.workbench-sidebar .nav-item-active:hover:not(:disabled) { + border-color: rgba(134, 224, 208, 0.22); + color: #ffffff; + background: linear-gradient( + 135deg, + rgba(39, 169, 151, 0.3), + rgba(255, 255, 255, 0.09) + ); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.1), + 0 8px 22px rgba(0, 0, 0, 0.14); +} + +.workbench-sidebar .nav-item-active::before { + position: absolute; + top: 0.68rem; + bottom: 0.68rem; + left: -0.86rem; + width: 0.22rem; + border-radius: 0 999px 999px 0; + background: #69d5c2; + box-shadow: 0 0 14px rgba(105, 213, 194, 0.72); + content: ""; +} + +.workbench-sidebar .nav-item-icon { + width: 1.15rem; + height: 1.15rem; + flex: 0 0 auto; + color: rgba(211, 244, 237, 0.72); +} + +.workbench-sidebar .nav-item-active .nav-item-icon { + color: #9be7da; +} + +/* Context command bar */ + +.workbench-topbar { + min-height: 5rem; + gap: 1rem; + border-bottom: 1px solid rgba(194, 210, 205, 0.72); + padding: 0.72rem 1.35rem; + background: rgba(250, 252, 251, 0.9); + box-shadow: 0 8px 24px rgba(8, 42, 38, 0.055); + backdrop-filter: blur(18px) saturate(135%); +} + +.context-bar { + gap: 0.48rem; +} + +.context-bar > div { + position: relative; + min-width: 0; + border: 1px solid transparent; + border-radius: 0.72rem; + padding: 0.48rem 0.72rem 0.52rem; + background: rgba(238, 248, 246, 0.62); + transition: + border-color 140ms ease, + background-color 140ms ease, + transform 140ms ease; +} + +.context-bar > div:first-child { + border-color: #c7ded8; + background: #eaf6f3; +} + +.workbench-shell .context-bar span { + margin-bottom: 0.16rem; + color: #6d827c; + font-size: 0.66rem; + letter-spacing: 0.055em; +} + +.context-bar strong { + color: var(--ink-950); + font-size: 0.78rem; + font-weight: 650; +} + +.context-health { + min-height: 2.45rem; + border: 1px solid #cfe0dc; + border-radius: 999px; + padding: 0.45rem 0.78rem; + color: var(--atlas-800); + background: #f4faf8; + font-size: 0.75rem; +} + +.context-health > span { + width: 0.5rem; + height: 0.5rem; + background: #26a68f; + box-shadow: 0 0 0 4px rgba(38, 166, 143, 0.12); +} + +.context-account { + min-height: 2.55rem; + border: 1px solid var(--atlas-line); + border-radius: 999px; + padding: 0.22rem 0.28rem 0.22rem 0.68rem; + background: var(--paper); + box-shadow: var(--atlas-shadow-xs); +} + +.context-account button { + border-radius: 999px; +} + +/* Calm, singular operational notice */ + +.workspace-notice { + position: fixed; + top: 5.75rem; + right: 1.25rem; + z-index: 80; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + width: min(26rem, calc(100vw - 2rem)); + gap: 0.7rem; + align-items: start; + border: 1px solid #e6c9b2; + border-radius: 0.9rem; + padding: 0.82rem 0.95rem; + color: #6f3b1c; + background: rgba(255, 250, 244, 0.96); + box-shadow: var(--atlas-shadow-floating); + backdrop-filter: blur(18px); +} + +.workspace-notice > svg { + width: 1.08rem; + margin-top: 0.08rem; + color: #b26530; +} + +.workspace-notice > div { + display: grid; + gap: 0.18rem; +} + +.workspace-notice strong { + font-size: 0.78rem; +} + +.workspace-notice span { + color: #865337; + font-size: 0.74rem; + line-height: 1.45; +} + +/* Page composition */ + +.workbench-main { + padding: 1.6rem 1.75rem 2.75rem; +} + +.workspace-heading { + min-height: 5.6rem; + margin: -0.15rem 0 1.2rem; + border: 0; + padding: 0.5rem 0 1rem; +} + +.workspace-heading-copy { + gap: 0.12rem; +} + +.workbench-shell .workspace-heading .eyebrow { + color: var(--atlas-700); + font-size: 0.7rem; + letter-spacing: 0.075em; +} + +.workspace-heading h2 { + color: var(--ink-950); + font-family: "Manrope", sans-serif; + font-size: clamp(1.65rem, 2vw, 2.25rem); + font-weight: 700; + letter-spacing: -0.045em; +} + +.workspace-heading-actions > p { + max-width: 31rem; + color: var(--ink-500); + font-size: 0.84rem; + line-height: 1.5; +} + +.inspector-toggle, +.secondary-action, +.primary-action, +.quick-action-button { + min-height: 2.65rem; + border-radius: 0.68rem; + padding-inline: 1rem; + font-size: 0.78rem; + font-weight: 700; +} + +.workbench-shell .primary-action { + border-color: var(--atlas-700); + background: linear-gradient(135deg, var(--atlas-700), var(--atlas-600)); + box-shadow: 0 8px 20px rgba(13, 102, 91, 0.16); +} + +.workbench-shell .primary-action:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 12px 26px rgba(13, 102, 91, 0.22); +} + +/* Shared premium surfaces */ + +.workspace-panel, +.panel, +.workbench-status-strip, +.source-freshness-panel, +.workspace-grid-data > section, +.source-catalog-panel, +.secondary-analysis-disclosure, +.status-details-disclosure, +.export-history-disclosure { + border: 1px solid rgba(194, 210, 205, 0.78); + border-radius: var(--atlas-radius-lg); + background: rgba(255, 255, 255, 0.94); + box-shadow: var(--atlas-shadow-sm); +} + +.panel-title-row h2, +.workspace-panel h2, +.source-catalog-panel h2 { + font-family: "Manrope", sans-serif; + letter-spacing: -0.025em; +} + +.workbench-shell .eyebrow, +.workbench-shell .section-kicker { + color: var(--atlas-700); + font-size: 0.69rem; + letter-spacing: 0.06em; +} + +.muted, +.panel-title-row .muted, +.result-state p, +.empty-state p { + color: var(--ink-500); + font-size: 0.78rem; + line-height: 1.5; +} + +.status-badge, +.count-pill, +.status-pill { + border-radius: 999px; + padding: 0.28rem 0.52rem; + font-size: 0.66rem; + font-weight: 750; + letter-spacing: 0.015em; +} + +.result-state, +.empty-state { + min-height: 9rem; + border: 1px dashed #bfd0cb; + border-radius: var(--atlas-radius-md); + padding: 1.2rem; + background: + radial-gradient( + circle at 50% 0%, + rgba(21, 152, 137, 0.08), + transparent 8rem + ), + #fbfdfc; +} + +.result-state-error { + border-style: solid; + border-color: #ebc7bf; + background: #fff8f6; +} + +/* Status becomes an executive health overview */ + +.workbench-status-strip { + overflow: hidden; +} + +.status-strip-header { + min-height: 10rem; + border-bottom: 1px solid var(--atlas-line); + padding: 1.55rem 1.65rem; + background: + radial-gradient( + circle at 92% 12%, + rgba(91, 204, 184, 0.22), + transparent 14rem + ), + linear-gradient(135deg, #f8fcfb, #eef7f4); +} + +.status-strip-header h2 { + margin-top: 0.15rem; + color: var(--atlas-950); + font-family: "Manrope", sans-serif; + font-size: clamp(1.3rem, 1.55vw, 1.8rem); + letter-spacing: -0.035em; +} + +.status-next-action { + max-width: 42rem; + margin-top: 0.45rem; + color: var(--ink-500); + font-size: 0.84rem; +} + +.status-readiness { + position: relative; + display: grid; + width: 7.2rem; + height: 7.2rem; + place-items: center; + border: 0; + border-radius: 50%; + padding: 0; + background: var(--paper); + box-shadow: 0 12px 30px rgba(8, 42, 38, 0.13); +} + +.status-readiness-orbit { + position: absolute; + inset: 0.42rem; + border-radius: inherit; + background: conic-gradient(var(--atlas-500) 60deg, #dce8e4 0); + mask: radial-gradient(circle, transparent 57%, #000 58%); +} + +.status-readiness[data-ready="0"] .status-readiness-orbit { + background: conic-gradient(var(--atlas-500) 0deg, #dce8e4 0); +} +.status-readiness[data-ready="1"] .status-readiness-orbit { + background: conic-gradient(var(--atlas-500) 60deg, #dce8e4 0); +} +.status-readiness[data-ready="2"] .status-readiness-orbit { + background: conic-gradient(var(--atlas-500) 120deg, #dce8e4 0); +} +.status-readiness[data-ready="3"] .status-readiness-orbit { + background: conic-gradient(var(--atlas-500) 180deg, #dce8e4 0); +} +.status-readiness[data-ready="4"] .status-readiness-orbit { + background: conic-gradient(var(--atlas-500) 240deg, #dce8e4 0); +} +.status-readiness[data-ready="5"] .status-readiness-orbit { + background: conic-gradient(var(--atlas-500) 300deg, #dce8e4 0); +} +.status-readiness[data-ready="6"] .status-readiness-orbit { + background: conic-gradient(var(--atlas-500) 360deg, #dce8e4 0); +} + +.status-readiness-copy { + position: relative; + z-index: 1; + display: grid; + max-width: 5rem; + gap: 0.08rem; + text-align: center; +} + +.status-readiness-copy strong { + color: var(--atlas-900); + font-family: "Manrope", sans-serif; + font-size: 1.5rem; + letter-spacing: -0.05em; +} + +.status-readiness-copy > span { + color: var(--ink-500); + font-size: 0.62rem; + line-height: 1.25; +} + +.status-strip-grid { + gap: 0.65rem; + padding: 0.75rem; + background: #f7faf9; +} + +.status-tile, +.status-tile:nth-child(n) { + position: relative; + min-height: 8rem; + overflow: hidden; + border: 1px solid var(--atlas-line); + border-radius: 0.8rem; + padding: 0.9rem; + background: var(--paper); +} + +.status-tile::after { + position: absolute; + right: -1.3rem; + bottom: -1.7rem; + width: 4.4rem; + height: 4.4rem; + border-radius: 50%; + background: rgba(13, 102, 91, 0.055); + content: ""; +} + +.status-tile-ready { + border-color: #c6dfd8; + box-shadow: inset 0 3px 0 #45a996; +} + +.status-tile-warning { + box-shadow: inset 0 3px 0 #d69b48; +} + +.status-tile > strong { + margin-top: 0.7rem; + color: var(--ink-950); + font-family: "Manrope", sans-serif; + font-size: 0.88rem; +} + +.status-tile p { + margin-top: 0.28rem; + color: var(--ink-500); + font-size: 0.7rem; + line-height: 1.45; +} + +/* Source workspace: distinct choose / understand / act planes */ + +.workspace-grid-data { + gap: 1rem; +} + +.workbench-shell .workspace-grid-data > section { + border-radius: var(--atlas-radius-lg); + box-shadow: var(--atlas-shadow-sm); +} + +.workbench-shell .workspace-grid-data > section.source-catalog-panel { + border-color: #c5dcd6; + background: + linear-gradient( + 135deg, + rgba(238, 248, 246, 0.72), + rgba(255, 255, 255, 0.95) 28% + ), + var(--paper); +} + +.source-catalog-panel > .panel-title-row { + padding: 1.25rem 1.35rem; +} + +.source-domain-card, +.source-catalog-domain, +.entity-card, +.dataset-card, +.data-selection-summary { + border-radius: var(--atlas-radius-md); +} + +.workspace-grid-data .entity-card, +.workspace-grid-data .dataset-card { + border-color: var(--atlas-line); + padding: 0.9rem; + background: #fcfdfd; + transition: + transform 140ms ease, + border-color 140ms ease, + box-shadow 140ms ease; +} + +.workspace-grid-data .entity-card:hover, +.workspace-grid-data .dataset-card:hover { + transform: translateY(-1px); + border-color: #9fc8be; + box-shadow: var(--atlas-shadow-sm); +} + +/* Map command center */ + +.geo-explorer { + overflow: hidden; + border: 1px solid rgba(170, 195, 188, 0.84); + border-radius: var(--atlas-radius-xl); + background: var(--paper); + box-shadow: var(--atlas-shadow-md); +} + +.geo-explorer-header { + min-height: 6rem; + border-bottom: 1px solid var(--atlas-line); + padding: 1rem 1.25rem; + background: + radial-gradient( + circle at 90% 0%, + rgba(84, 196, 176, 0.14), + transparent 16rem + ), + linear-gradient(135deg, #fbfdfc, #f3f9f7); +} + +.geo-explorer-header h2 { + font-family: "Manrope", sans-serif; + font-size: 1.45rem; + letter-spacing: -0.04em; +} + +.geo-explorer-header p:last-child { + color: var(--ink-500); + font-size: 0.8rem; +} + +.geo-explorer-layout { + gap: 0; + background: #eef3f1; +} + +.geo-theme-panel, +.geo-insight-panel { + border: 0; + background: rgba(255, 255, 255, 0.97); +} + +.geo-theme-panel { + border-right: 1px solid var(--atlas-line); +} + +.geo-insight-panel { + border-left: 1px solid var(--atlas-line); +} + +.geo-theme-panel > .geo-panel-heading, +.geo-insight-panel > .geo-panel-heading { + min-height: 5rem; + padding: 1rem; + background: #fbfdfc; +} + +.geo-theme-list { + gap: 0.45rem; + padding: 0.7rem; +} + +.geo-theme-option { + min-height: 3.65rem; + border: 1px solid transparent; + border-radius: 0.75rem; + padding: 0.65rem 0.72rem; + background: transparent; +} + +.geo-theme-option:hover:not(:disabled) { + border-color: #c4d9d4; + background: #f5faf8; +} + +.geo-theme-option-active, +.geo-theme-option-active:hover:not(:disabled) { + border-color: #9bc9be; + background: linear-gradient(135deg, #e7f5f1, #f4faf8); + box-shadow: + inset 3px 0 0 var(--atlas-600), + 0 6px 16px rgba(8, 42, 38, 0.07); +} + +.geo-theme-option strong { + font-size: 0.82rem; +} + +.geo-theme-option small, +.geo-theme-option i { + font-size: 0.69rem; +} + +.geo-map-panel, +.geo-map-stage, +.map-frame-surface { + background: #dce7e3; +} + +.geo-explorer .map-container, +.map-frame-surface .map-container { + border-radius: 0; +} + +.geo-explorer .maplibregl-ctrl-group, +.map-frame-surface .maplibregl-ctrl-group { + overflow: hidden; + border: 1px solid rgba(171, 192, 186, 0.78); + border-radius: 0.65rem; + box-shadow: var(--atlas-shadow-md); +} + +.geo-explorer .maplibregl-ctrl button, +.map-frame-surface .maplibregl-ctrl button { + width: 2.4rem; + height: 2.4rem; +} + +.geo-map-toolbar, +.map-toolbar { + border: 1px solid rgba(172, 194, 188, 0.82); + border-radius: 0.78rem; + background: rgba(255, 255, 255, 0.94); + box-shadow: var(--atlas-shadow-floating); + backdrop-filter: blur(16px); +} + +.geo-explorer-footer { + min-height: 3rem; + border-top: 1px solid var(--atlas-line); + background: #fbfdfc; + color: var(--ink-500); + font-size: 0.7rem; +} + +/* Disclosures and forms */ + +.technical-run-list, +.area-catalog-disclosure, +.dataset-history-disclosure, +.dataset-technical-details, +.quality-advanced-disclosure, +.provider-technical-details, +.secondary-analysis-disclosure, +.status-details-disclosure, +.export-history-disclosure { + border-color: var(--atlas-line); + border-radius: var(--atlas-radius-md); + background: rgba(255, 255, 255, 0.9); +} + +.technical-run-list > summary, +.area-catalog-disclosure > summary, +.dataset-history-disclosure > summary, +.dataset-technical-details > summary, +.quality-advanced-disclosure > summary, +.provider-technical-details > summary, +.secondary-analysis-disclosure > summary, +.status-details-disclosure > summary, +.export-history-disclosure > summary { + min-height: 3.15rem; + padding: 0.75rem 0.95rem; + color: var(--ink-700); + font-size: 0.78rem; +} + +.workbench-shell input, +.workbench-shell select, +.workbench-shell textarea { + min-height: 2.65rem; + border-color: var(--atlas-line-strong); + border-radius: 0.62rem; + background: #fcfdfd; +} + +.workbench-shell input:focus, +.workbench-shell select:focus, +.workbench-shell textarea:focus { + border-color: var(--atlas-600); + background: #ffffff; + box-shadow: 0 0 0 4px rgba(21, 152, 137, 0.12); +} + +@media (min-width: 1800px) { + .workbench-layout { + grid-template-columns: 16rem minmax(0, 1fr); + } + + .workbench-main { + padding: 1.9rem 2.25rem 3rem; + } + + .workspace-grid-data { + grid-template-columns: minmax(18rem, 0.78fr) minmax(28rem, 1.5fr) minmax( + 20rem, + 0.9fr + ); + } +} + +@media (max-width: 1360px) and (min-width: 921px) { + .workbench-layout { + grid-template-columns: 5.75rem minmax(0, 1fr); + } + + .workbench-sidebar { + padding-inline: 0.55rem; + } + + .sidebar-brand { + justify-content: center; + padding-inline: 0; + } + + .sidebar-brand-copy, + .nav-section-label, + .workbench-sidebar .nav-item > span { + display: none; + } + + .workbench-sidebar .nav-item { + justify-content: center; + padding-inline: 0; + } + + .workbench-sidebar .nav-item-icon { + width: 1.25rem; + height: 1.25rem; + } +} + +@media (max-width: 920px) { + .workbench-sidebar { + color: var(--ink-700); + background: rgba(255, 255, 255, 0.97); + box-shadow: 0 8px 24px rgba(8, 42, 38, 0.08); + } + + .workbench-sidebar .nav-item { + color: var(--ink-500); + background: transparent; + } + + .workbench-sidebar .nav-item-active, + .workbench-sidebar .nav-item-active:hover:not(:disabled) { + border-color: #acd2c9; + color: var(--atlas-800); + background: var(--atlas-50); + box-shadow: none; + } + + .workbench-sidebar .nav-item-active::before { + display: none; + } + + .workbench-sidebar .nav-item-icon, + .workbench-sidebar .nav-item-active .nav-item-icon { + color: currentColor; + } + + .workspace-notice { + top: 0.75rem; + right: 0.75rem; + } + + .status-strip-header { + min-height: auto; + } + + .status-readiness { + width: 5.8rem; + height: 5.8rem; + } +} + +@media (max-width: 620px) { + .workbench-main { + padding: 1rem 0.8rem 1.6rem; + } + + .workspace-heading h2 { + font-size: 1.55rem; + } + + .status-strip-header { + padding: 1.15rem; + } + + .status-readiness { + width: 5.2rem; + height: 5.2rem; + } + + .status-readiness-copy strong { + font-size: 1.15rem; + } + + .geo-explorer { + border-radius: var(--atlas-radius-lg); + } + + .geo-explorer-header { + padding: 0.9rem; + } +} + +@media (prefers-reduced-motion: reduce) { + .workbench-shell *, + .workbench-shell *::before, + .workbench-shell *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + } +} diff --git a/index.html b/index.html deleted file mode 100644 index 3506ccda..00000000 --- a/index.html +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - DockDeck - - -
- - - diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 002ddcf1..00000000 --- a/package-lock.json +++ /dev/null @@ -1,7340 +0,0 @@ -{ - "name": "dockdeck", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "dockdeck", - "version": "0.1.0", - "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", - "fastify": "^5.6.1", - "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": "^3.2.4" - }, - "engines": { - "node": ">=22" - } - }, - "node_modules/@adobe/css-tools": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", - "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@asamuzakjp/css-color": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", - "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@csstools/css-calc": "^2.1.3", - "@csstools/css-color-parser": "^3.0.9", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", - "lru-cache": "^10.4.3" - } - }, - "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/@babel/code-frame": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", - "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.29.7", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", - "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", - "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-compilation-targets": "^7.29.7", - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helpers": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", - "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", - "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.29.7", - "@babel/helper-validator-option": "^7.29.7", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", - "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", - "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", - "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", - "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", - "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", - "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.7" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", - "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", - "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/runtime": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", - "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/template": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", - "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", - "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@csstools/color-helpers": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", - "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=18" - } - }, - "node_modules/@csstools/css-calc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", - "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-color-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", - "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "dependencies": { - "@csstools/color-helpers": "^5.1.0", - "@csstools/css-calc": "^2.1.4" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-parser-algorithms": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", - "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-tokenizer": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", - "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.21.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", - "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.5" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", - "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.14.0", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.3.0", - "minimatch": "^3.1.5", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@eslint/js": { - "version": "9.39.5", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", - "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0", - "levn": "^0.4.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@fastify/accept-negotiator": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz", - "integrity": "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/@fastify/ajv-compiler": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", - "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "ajv": "^8.12.0", - "ajv-formats": "^3.0.1", - "fast-uri": "^3.0.0" - } - }, - "node_modules/@fastify/ajv-compiler/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@fastify/ajv-compiler/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, - "node_modules/@fastify/error": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", - "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/@fastify/fast-json-stringify-compiler": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.1.0.tgz", - "integrity": "sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "fast-json-stringify": "^7.0.0" - } - }, - "node_modules/@fastify/forwarded": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", - "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/@fastify/merge-json-schemas": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", - "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/@fastify/proxy-addr": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", - "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@fastify/forwarded": "^3.0.0", - "ipaddr.js": "^2.1.0" - } - }, - "node_modules/@fastify/send": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz", - "integrity": "sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@lukeed/ms": "^2.0.2", - "escape-html": "~1.0.3", - "fast-decode-uri-component": "^1.0.1", - "http-errors": "^2.0.0", - "mime": "^3" - } - }, - "node_modules/@fastify/static": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/@fastify/static/-/static-10.1.0.tgz", - "integrity": "sha512-iK/8TvRM/EgNOyQL+EpWu+x3aR6o4GWt+UI+27zmE7w6t/6d80mXqOtWLdEKQ13vL/g1Jry0ae2icj6GP7tGzA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@fastify/accept-negotiator": "^2.0.0", - "@fastify/error": "^4.0.0", - "@fastify/send": "^4.0.0", - "content-disposition": "^2.0.1", - "fastify-plugin": "^6.0.0", - "fastq": "^1.17.1", - "glob": "^13.0.0" - } - }, - "node_modules/@fontsource-variable/geist": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.3.0.tgz", - "integrity": "sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==", - "license": "OFL-1.1", - "funding": { - "url": "https://github.com/sponsors/ayuhito" - } - }, - "node_modules/@fontsource/newsreader": { - "version": "5.2.10", - "resolved": "https://registry.npmjs.org/@fontsource/newsreader/-/newsreader-5.2.10.tgz", - "integrity": "sha512-TFaYzoFhDqarUyV2yYjgZZEwT4bpaj6sGBnXSnFknQ/QB8/9LzfY6IO9+inHOX4zzPp87Z7/KuG1OI5gr91Q3A==", - "license": "OFL-1.1", - "funding": { - "url": "https://github.com/sponsors/ayuhito" - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", - "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/types": "^0.15.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.8", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", - "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.2", - "@humanfs/types": "^0.15.0", - "@humanwhocodes/retry": "^0.4.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/types": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", - "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@icons-pack/react-simple-icons": { - "version": "13.8.0", - "resolved": "https://registry.npmjs.org/@icons-pack/react-simple-icons/-/react-simple-icons-13.8.0.tgz", - "integrity": "sha512-iZrhL1fSklfCCVn68IYHaAoKfcby3RakUTn2tRPyHBkhr2tkYqeQbjJWf+NizIYBzKBn2IarDJXmTdXd6CuEfw==", - "license": "MIT", - "peerDependencies": { - "react": "^16.13 || ^17 || ^18 || ^19" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@lukeed/ms": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", - "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@pinojs/redact": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", - "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", - "license": "MIT" - }, - "node_modules/@playwright/test": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", - "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright": "1.61.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.3", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", - "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", - "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", - "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", - "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", - "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", - "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", - "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", - "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", - "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", - "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", - "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", - "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", - "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", - "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", - "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", - "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", - "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", - "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", - "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", - "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", - "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", - "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", - "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", - "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", - "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", - "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@testing-library/dom": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", - "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "picocolors": "1.1.1", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@testing-library/jest-dom": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", - "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@adobe/css-tools": "^4.4.0", - "aria-query": "^5.0.0", - "css.escape": "^1.5.1", - "dom-accessibility-api": "^0.6.3", - "picocolors": "^1.1.1", - "redent": "^3.0.0" - }, - "engines": { - "node": ">=14", - "npm": ">=6", - "yarn": ">=1" - } - }, - "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", - "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@testing-library/react": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", - "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.5" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@testing-library/dom": "^10.0.0", - "@types/react": "^18.0.0 || ^19.0.0", - "@types/react-dom": "^18.0.0 || ^19.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@types/aria-query": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" - } - }, - "node_modules/@types/better-sqlite3": { - "version": "7.6.13", - "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", - "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "24.13.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", - "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.18.0" - } - }, - "node_modules/@types/react": { - "version": "19.2.17", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", - "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", - "dev": true, - "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^19.2.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.64.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.64.0.tgz", - "integrity": "sha512-CGvQPBxN3wZLu6Rz2kFUpZeoCm78xUic92ck39KPePkO1NPOwjCqdQnm5Q87tpWw9vcBvW8XLrDXjH9PWYtJ3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.64.0", - "@typescript-eslint/type-utils": "8.64.0", - "@typescript-eslint/utils": "8.64.0", - "@typescript-eslint/visitor-keys": "8.64.0", - "ignore": "^7.0.5", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.64.0", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", - "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "8.64.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.64.0.tgz", - "integrity": "sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.64.0", - "@typescript-eslint/types": "8.64.0", - "@typescript-eslint/typescript-estree": "8.64.0", - "@typescript-eslint/visitor-keys": "8.64.0", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.64.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.64.0.tgz", - "integrity": "sha512-tk4WpOJ6IEbGrVHaNmM0YRrwAD3exZlIK3iadQNAxh4YKk6jvUQ4ecq18n+v7+meh+cJ3j+D8nbk8sRKhlwLQg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.64.0", - "@typescript-eslint/types": "^8.64.0", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.64.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.64.0.tgz", - "integrity": "sha512-CXEaFdYXjSTgKhisNkwCcJwTP8Pl+fmRrEQrri4nm3vU743bALrxzLmq7fHG/7e6a5xO0lDYeURpZmBuhHk54w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.64.0", - "@typescript-eslint/visitor-keys": "8.64.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.64.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.64.0.tgz", - "integrity": "sha512-2yo8rRNKuzbVWQp5kslhANqZ2uDAeROQHBRZNPu8JDsHmeFNj/XJJhX/FhNUWmkHHvoNsKa6+tHJiig87EzsQw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.64.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.64.0.tgz", - "integrity": "sha512-XWG4Fmmv/6SvyS9nH8jWrKs6terwJvE8cyRt1CzYYqzp9OrPhCT4cMc/f7C6RZCwG+qMmiffJS1/qJP8G1URtg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.64.0", - "@typescript-eslint/typescript-estree": "8.64.0", - "@typescript-eslint/utils": "8.64.0", - "debug": "^4.4.3", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/types": { - "version": "8.64.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.64.0.tgz", - "integrity": "sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.64.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.64.0.tgz", - "integrity": "sha512-Pztpsn1aCE1oWDvDEfUk31nngvvF7vUB5SwHFEaZIFpvw7WJtqUHHL4plBZDA9HfWJJjL13BdG0YrJInTUvoVA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.64.0", - "@typescript-eslint/tsconfig-utils": "8.64.0", - "@typescript-eslint/types": "8.64.0", - "@typescript-eslint/visitor-keys": "8.64.0", - "debug": "^4.4.3", - "minimatch": "^10.2.2", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "8.64.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.64.0.tgz", - "integrity": "sha512-aJUGVB3+U0htrrCjoA8qukw8cm8fNCGAxK/tVoS70k8aeb7DETKeFozRiVFIwEeN9WJLsjaP3ph8I60tY2XZoQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.64.0", - "@typescript-eslint/types": "8.64.0", - "@typescript-eslint/typescript-estree": "8.64.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.64.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.64.0.tgz", - "integrity": "sha512-mrtuL8Nsn6gi2H4mo5KMTp823M+3Q19Ew/i+Zlikq20tIMm99C3Ez0dCmkWWnxut20esQvTg8aUSEhMcAOXhEw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.64.0", - "eslint-visitor-keys": "^5.0.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@vitejs/plugin-react": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", - "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.29.0", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-rc.3", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.18.0" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/@vitest/expect": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", - "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.7", - "@vitest/utils": "3.2.7", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", - "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "3.2.7", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", - "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", - "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "3.2.7", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", - "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.7", - "magic-string": "^0.30.17", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", - "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", - "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.7", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/abstract-logging": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", - "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", - "license": "MIT" - }, - "node_modules/acorn": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", - "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/atomic-sleep": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", - "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", - "license": "MIT", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/avvio": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz", - "integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@fastify/error": "^4.0.0", - "fastq": "^1.17.1" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/baseline-browser-mapping": { - "version": "2.10.43", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", - "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/better-sqlite3": { - "version": "12.11.1", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz", - "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "bindings": "^1.5.0", - "prebuild-install": "^7.1.1" - }, - "engines": { - "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" - } - }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "license": "MIT", - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/browserslist": { - "version": "4.28.6", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", - "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.10.42", - "caniuse-lite": "^1.0.30001803", - "electron-to-chromium": "^1.5.389", - "node-releases": "^2.0.51", - "update-browserslist-db": "^1.2.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001805", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001805.tgz", - "integrity": "sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chalk/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, - "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC" - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/concurrently": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.4.tgz", - "integrity": "sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "4.1.2", - "rxjs": "7.8.2", - "shell-quote": "1.9.0", - "supports-color": "8.1.1", - "tree-kill": "1.2.2", - "yargs": "17.7.2" - }, - "bin": { - "conc": "dist/bin/concurrently.js", - "concurrently": "dist/bin/concurrently.js" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" - } - }, - "node_modules/content-disposition": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz", - "integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/css.escape": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cssstyle": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", - "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/css-color": "^3.2.0", - "rrweb-cssom": "^0.8.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/data-urls": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", - "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decimal.js": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true, - "license": "MIT" - }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "license": "MIT", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/drizzle-orm": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.2.tgz", - "integrity": "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==", - "license": "Apache-2.0", - "peerDependencies": { - "@aws-sdk/client-rds-data": ">=3", - "@cloudflare/workers-types": ">=4", - "@electric-sql/pglite": ">=0.2.0", - "@libsql/client": ">=0.10.0", - "@libsql/client-wasm": ">=0.10.0", - "@neondatabase/serverless": ">=0.10.0", - "@op-engineering/op-sqlite": ">=2", - "@opentelemetry/api": "^1.4.1", - "@planetscale/database": ">=1.13", - "@prisma/client": "*", - "@tidbcloud/serverless": "*", - "@types/better-sqlite3": "*", - "@types/pg": "*", - "@types/sql.js": "*", - "@upstash/redis": ">=1.34.7", - "@vercel/postgres": ">=0.8.0", - "@xata.io/client": "*", - "better-sqlite3": ">=7", - "bun-types": "*", - "expo-sqlite": ">=14.0.0", - "gel": ">=2", - "knex": "*", - "kysely": "*", - "mysql2": ">=2", - "pg": ">=8", - "postgres": ">=3", - "sql.js": ">=1", - "sqlite3": ">=5" - }, - "peerDependenciesMeta": { - "@aws-sdk/client-rds-data": { - "optional": true - }, - "@cloudflare/workers-types": { - "optional": true - }, - "@electric-sql/pglite": { - "optional": true - }, - "@libsql/client": { - "optional": true - }, - "@libsql/client-wasm": { - "optional": true - }, - "@neondatabase/serverless": { - "optional": true - }, - "@op-engineering/op-sqlite": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@planetscale/database": { - "optional": true - }, - "@prisma/client": { - "optional": true - }, - "@tidbcloud/serverless": { - "optional": true - }, - "@types/better-sqlite3": { - "optional": true - }, - "@types/pg": { - "optional": true - }, - "@types/sql.js": { - "optional": true - }, - "@upstash/redis": { - "optional": true - }, - "@vercel/postgres": { - "optional": true - }, - "@xata.io/client": { - "optional": true - }, - "better-sqlite3": { - "optional": true - }, - "bun-types": { - "optional": true - }, - "expo-sqlite": { - "optional": true - }, - "gel": { - "optional": true - }, - "knex": { - "optional": true - }, - "kysely": { - "optional": true - }, - "mysql2": { - "optional": true - }, - "pg": { - "optional": true - }, - "postgres": { - "optional": true - }, - "prisma": { - "optional": true - }, - "sql.js": { - "optional": true - }, - "sqlite3": { - "optional": true - } - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.389", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz", - "integrity": "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==", - "dev": true, - "license": "ISC" - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "9.39.5", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", - "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.2", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.6", - "@eslint/js": "9.39.5", - "@eslint/plugin-kit": "^0.4.1", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.14.0", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.5", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/eslint-plugin-react-hooks": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", - "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" - } - }, - "node_modules/eslint-plugin-react-refresh": { - "version": "0.4.26", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", - "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "eslint": ">=8.40" - } - }, - "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "license": "(MIT OR WTFPL)", - "engines": { - "node": ">=6" - } - }, - "node_modules/expect-type": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", - "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/fast-decode-uri-component": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", - "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", - "license": "MIT" - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-json-stringify": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.0.1.tgz", - "integrity": "sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@fastify/merge-json-schemas": "^0.2.0", - "ajv": "^8.12.0", - "ajv-formats": "^3.0.1", - "fast-uri": "^4.0.0", - "json-schema-ref-resolver": "^3.0.0", - "rfdc": "^1.2.0" - } - }, - "node_modules/fast-json-stringify/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/fast-json-stringify/node_modules/ajv/node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fast-json-stringify/node_modules/fast-uri": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.1.tgz", - "integrity": "sha512-YPOs1zD5TG2+EZt+r88LwF6mclA7TPkpwMP7ZN3TO2HiHS8TXvq7QA/17iJsV9dubcLo/f8eEYqMBruyQV21hQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fast-json-stringify/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-querystring": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", - "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", - "license": "MIT", - "dependencies": { - "fast-decode-uri-component": "^1.0.1" - } - }, - "node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fastify": { - "version": "5.10.0", - "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.10.0.tgz", - "integrity": "sha512-A9L0ziuWGQHgEEVgF3davQ9vbD93IuX+lo2IsxapQmu5b/Y/ynn9m9K5JHt9dvyJXOFc5iN0Zk5GHEOqnzhWjg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@fastify/ajv-compiler": "^4.0.5", - "@fastify/error": "^4.0.0", - "@fastify/fast-json-stringify-compiler": "^5.0.0", - "@fastify/proxy-addr": "^5.0.0", - "abstract-logging": "^2.0.1", - "avvio": "^9.0.0", - "fast-json-stringify": "^7.0.0", - "find-my-way": "^9.6.0", - "light-my-request": "^6.0.0", - "pino": "^9.14.0 || ^10.1.0", - "process-warning": "^5.0.0", - "rfdc": "^1.3.1", - "secure-json-parse": "^4.0.0", - "semver": "^7.6.0", - "toad-cache": "^3.7.0" - } - }, - "node_modules/fastify-plugin": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz", - "integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/fastify/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "license": "MIT" - }, - "node_modules/find-my-way": { - "version": "9.6.0", - "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz", - "integrity": "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-querystring": "^1.0.0", - "safe-regex2": "^5.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", - "dev": true, - "license": "ISC" - }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "license": "MIT" - }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "license": "MIT" - }, - "node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/glob/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/globals": { - "version": "16.5.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", - "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/html-encoding-sniffer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", - "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-encoding": "^3.1.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" - }, - "node_modules/ipaddr.js": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", - "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/jsdom": { - "version": "26.1.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", - "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssstyle": "^4.2.1", - "data-urls": "^5.0.0", - "decimal.js": "^10.5.0", - "html-encoding-sniffer": "^4.0.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", - "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.16", - "parse5": "^7.2.1", - "rrweb-cssom": "^0.8.0", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^5.1.1", - "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^7.0.0", - "whatwg-encoding": "^3.1.1", - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.1.1", - "ws": "^8.18.0", - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "canvas": "^3.0.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-ref-resolver": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", - "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/light-my-request": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", - "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause", - "dependencies": { - "cookie": "^1.0.1", - "process-warning": "^4.0.0", - "set-cookie-parser": "^2.6.0" - } - }, - "node_modules/light-my-request/node_modules/process-warning": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", - "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/lucide-react": { - "version": "0.468.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz", - "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==", - "license": "ISC", - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" - } - }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "dev": true, - "license": "MIT", - "peer": true, - "bin": { - "lz-string": "bin/bin.js" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/mime": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", - "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/min-indent": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "license": "MIT" - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/napi-build-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "license": "MIT" - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-abi": { - "version": "3.94.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", - "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", - "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/node-abi/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/node-releases": { - "version": "2.0.51", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", - "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/nwsapi": { - "version": "2.2.24", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", - "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", - "dev": true, - "license": "MIT" - }, - "node_modules/on-exit-leak-free": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", - "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.5.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", - "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pino": { - "version": "10.3.1", - "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", - "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", - "license": "MIT", - "dependencies": { - "@pinojs/redact": "^0.4.0", - "atomic-sleep": "^1.0.0", - "on-exit-leak-free": "^2.1.0", - "pino-abstract-transport": "^3.0.0", - "pino-std-serializers": "^7.0.0", - "process-warning": "^5.0.0", - "quick-format-unescaped": "^4.0.3", - "real-require": "^0.2.0", - "safe-stable-stringify": "^2.3.1", - "sonic-boom": "^4.0.1", - "thread-stream": "^4.0.0" - }, - "bin": { - "pino": "bin.js" - } - }, - "node_modules/pino-abstract-transport": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", - "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", - "license": "MIT", - "dependencies": { - "split2": "^4.0.0" - } - }, - "node_modules/pino-std-serializers": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", - "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", - "license": "MIT" - }, - "node_modules/playwright": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", - "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.61.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", - "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/postcss": { - "version": "8.5.19", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", - "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.12", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/prebuild-install": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", - "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/prettier": { - "version": "3.9.5", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz", - "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/pretty-format": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/process-warning": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", - "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/pump": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/quick-format-unescaped": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", - "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", - "license": "MIT" - }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/rc/node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", - "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", - "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", - "license": "MIT", - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.7" - } - }, - "node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/react-refresh": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", - "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/real-require": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", - "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", - "license": "MIT", - "engines": { - "node": ">= 12.13.0" - } - }, - "node_modules/redent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", - "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "indent-string": "^4.0.0", - "strip-indent": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/ret": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", - "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "license": "MIT" - }, - "node_modules/rollup": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", - "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.9" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.62.2", - "@rollup/rollup-android-arm64": "4.62.2", - "@rollup/rollup-darwin-arm64": "4.62.2", - "@rollup/rollup-darwin-x64": "4.62.2", - "@rollup/rollup-freebsd-arm64": "4.62.2", - "@rollup/rollup-freebsd-x64": "4.62.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", - "@rollup/rollup-linux-arm-musleabihf": "4.62.2", - "@rollup/rollup-linux-arm64-gnu": "4.62.2", - "@rollup/rollup-linux-arm64-musl": "4.62.2", - "@rollup/rollup-linux-loong64-gnu": "4.62.2", - "@rollup/rollup-linux-loong64-musl": "4.62.2", - "@rollup/rollup-linux-ppc64-gnu": "4.62.2", - "@rollup/rollup-linux-ppc64-musl": "4.62.2", - "@rollup/rollup-linux-riscv64-gnu": "4.62.2", - "@rollup/rollup-linux-riscv64-musl": "4.62.2", - "@rollup/rollup-linux-s390x-gnu": "4.62.2", - "@rollup/rollup-linux-x64-gnu": "4.62.2", - "@rollup/rollup-linux-x64-musl": "4.62.2", - "@rollup/rollup-openbsd-x64": "4.62.2", - "@rollup/rollup-openharmony-arm64": "4.62.2", - "@rollup/rollup-win32-arm64-msvc": "4.62.2", - "@rollup/rollup-win32-ia32-msvc": "4.62.2", - "@rollup/rollup-win32-x64-gnu": "4.62.2", - "@rollup/rollup-win32-x64-msvc": "4.62.2", - "fsevents": "~2.3.2" - } - }, - "node_modules/rrweb-cssom": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", - "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", - "dev": true, - "license": "MIT" - }, - "node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safe-regex2": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", - "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "ret": "~0.5.0" - }, - "bin": { - "safe-regex2": "bin/safe-regex2.js" - } - }, - "node_modules/safe-stable-stringify": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", - "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "license": "MIT" - }, - "node_modules/saxes": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, - "license": "ISC", - "dependencies": { - "xmlchars": "^2.2.0" - }, - "engines": { - "node": ">=v12.22.7" - } - }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" - }, - "node_modules/secure-json-parse": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", - "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/set-cookie-parser": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", - "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", - "license": "MIT" - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/shell-quote": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", - "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, - "node_modules/sonic-boom": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", - "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", - "license": "MIT", - "dependencies": { - "atomic-sleep": "^1.0.0" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "license": "ISC", - "engines": { - "node": ">= 10.x" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-indent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", - "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "min-indent": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/strip-literal": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/strip-literal/node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, - "license": "MIT" - }, - "node_modules/tar-fs": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", - "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", - "license": "MIT", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "license": "MIT", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/thread-stream": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", - "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", - "license": "MIT", - "dependencies": { - "real-require": "^1.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/thread-stream/node_modules/real-require": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", - "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", - "license": "MIT" - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, - "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", - "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tldts": { - "version": "6.1.86", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", - "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tldts-core": "^6.1.86" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/tldts-core": { - "version": "6.1.86", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", - "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", - "dev": true, - "license": "MIT" - }, - "node_modules/toad-cache": { - "version": "3.7.4", - "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz", - "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==", - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/tough-cookie": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", - "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tldts": "^6.1.32" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/tr46": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", - "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/tree-kill": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", - "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", - "dev": true, - "license": "MIT", - "bin": { - "tree-kill": "cli.js" - } - }, - "node_modules/ts-api-utils": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", - "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD" - }, - "node_modules/tsx": { - "version": "4.23.1", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", - "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.28.0" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" - } - }, - "node_modules/tsx/node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/typescript-eslint": { - "version": "8.64.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.64.0.tgz", - "integrity": "sha512-0qg+pDNMnqYzqH9AnNK+39tejHvsShUOUUoRUgtnTGE7QuMZhiFDnozq8nHJVq+Wae6NMLKNWLg5WmkcC/ndyQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "8.64.0", - "@typescript-eslint/parser": "8.64.0", - "@typescript-eslint/typescript-estree": "8.64.0", - "@typescript-eslint/utils": "8.64.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, - "node_modules/vite": { - "version": "7.3.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", - "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.27.0 || ^0.28.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vite/node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" - } - }, - "node_modules/vite/node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/vitest": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", - "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.7", - "@vitest/mocker": "3.2.7", - "@vitest/pretty-format": "^3.2.7", - "@vitest/runner": "3.2.7", - "@vitest/snapshot": "3.2.7", - "@vitest/spy": "3.2.7", - "@vitest/utils": "3.2.7", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", - "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.7", - "@vitest/ui": "3.2.7", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@types/debug": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, - "node_modules/w3c-xmlserializer": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", - "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/whatwg-encoding": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", - "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", - "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", - "dev": true, - "license": "MIT", - "dependencies": { - "iconv-lite": "0.6.3" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/whatwg-mimetype": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", - "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/whatwg-url": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", - "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tr46": "^5.1.0", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, - "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/xml-name-validator": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", - "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/xmlchars": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, - "license": "MIT" - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index 1371b0f7..00000000 --- a/package.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "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": "tsc -b && vite build && esbuild src/server/index.ts --bundle --platform=node --format=esm --packages=external --outfile=dist/server/index.js", - "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" - }, - "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", - "fastify": "^5.6.1", - "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": "^3.2.4" - }, - "engines": { - "node": ">=22" - } -} diff --git a/playwright.config.ts b/playwright.config.ts deleted file mode 100644 index 60ca4ca8..00000000 --- a/playwright.config.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { defineConfig, devices } from "@playwright/test"; - -export default defineConfig({ - testDir: "./tests", - fullyParallel: false, - workers: 1, - retries: 0, - reporter: "list", - use: { - baseURL: "http://127.0.0.1:5174", - trace: "retain-on-failure", - }, - webServer: { - command: "npm run dev", - url: "http://127.0.0.1:3101/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: "3101", - VITE_API_PORT: "3101", - VITE_PORT: "5174", - }, - }, - 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 }, - }, - }, - ], -}); diff --git a/project-launchpad.json b/project-launchpad.json deleted file mode 100644 index 3bfd9804..00000000 --- a/project-launchpad.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "schemaVersion": 1, - "generatedAt": "2026-07-13T18:54:16.667Z", - "rootFolderName": "dockdeck", - "exportProfile": { - "id": "codex-full", - "label": "Codex volledig" - }, - "codexStartMode": { - "id": "build-mvp", - "label": "MVP bouwen" - }, - "input": { - "projectName": "DockDeck", - "shortDescription": "Een premium ogend persoonlijk startdashboard voor Unraid dat Docker-apps automatisch ontdekt, stijlvol groepeert en snelle navigatie biedt naar lokale diensten en eigen favorieten.", - "projectIdea": "Bouw een persoonlijke webapp die als standaard startpagina van de browser dient en een prachtig, professioneel en strak afgewerkt overzicht geeft van de diensten op de Unraid-server. De app ontdekt Docker-containers automatisch via Unraid en een beperkte read-only Docker-socketproxy, maar toont nieuwe containers niet automatisch op het hoofddashboard. In het instellingenmenu kan de gebruiker per container zichtbaarheid, categorie, sortering, icoon, naam en URL beheren. Voor Docker-apps wordt standaard de lokale WebUI-URL uit de Unraid-configuratie gebruikt. Daarnaast kunnen handmatig favoriete externe websites worden toegevoegd met naam, URL, icoon, categorie en sorteerpositie. Het dashboard bevat een centrale zoekbalk die zowel zichtbare Docker-apps als favorieten doorzoekt en, wanneer geen lokaal resultaat wordt gekozen, de invoer als Google-zoekopdracht in een nieuw tabblad opent. De gebruiker kan kiezen tussen één overzicht met categorieën onder elkaar en een weergave met afzonderlijke categoriepagina's of tabbladen. Thema's, weergavevoorkeuren en openingsgedrag worden via instellingen beheerd. Externe toegang kan optioneel worden ingeschakeld; wanneer die actief is, haalt een read-only koppeling met Nginx Proxy Manager beschikbare externe URL's op en koppelt die aan de juiste apps, met steeds een handmatige override als terugval. De UI moet aanvoelen als een hoogwaardige, moderne productinterface met sterke visuele hiërarchie, nette details, subtiele animaties en een verzorgde dashboardervaring.", - "targetUsers": "Eén primaire gebruiker: de eigenaar en beheerder van de Unraid-server. Deze gebruiker verwacht zonder technische omwegen snel naar lokale Docker-diensten en favoriete websites te kunnen navigeren, zelf te bepalen welke apps zichtbaar zijn, categorieën en sortering te beheren, de startpagina visueel aan te passen en optioneel lokale of externe app-URL's te gebruiken. Er zijn in de MVP geen afzonderlijke gebruikersrollen, accounts of gedeelde profielen.", - "problemToSolve": "De gebruiker heeft meerdere Docker-diensten op een Unraid-server en moet momenteel afzonderlijke URL's onthouden, bladwijzers beheren of via verschillende beheerinterfaces navigeren. Bestaande startpagina's sluiten niet volledig aan op de gewenste combinatie van automatische Unraid-detectie, configureerbare zichtbaarheid, categorie-indeling, statusweergave, lokale en externe URL's, favoriete websites, geïntegreerde zoekfunctie en een echt professioneel ogende premium UI.", - "desiredOutcome": "De eerste bruikbare versie draait als Docker-container op Unraid en kan als browserstartpagina worden ingesteld. Na de eerste start ontdekt de app de beschikbare Docker-containers, toont ze in een instellingenoverzicht en laat de gebruiker selecteren welke apps op het dashboard verschijnen. Zichtbare apps staan netjes gegroepeerd in configureerbare categorieën, tonen naam, icoon en actuele online- of offline-status en openen hun lokale WebUI in een nieuw tabblad. Favoriete websites kunnen handmatig worden toegevoegd. De zoekbalk vindt apps en favorieten en kan rechtstreeks een Google-zoekopdracht starten. Alle instellingen blijven na een herstart behouden en kunnen worden geëxporteerd en opnieuw geïmporteerd. De totale ervaring moet eruitzien als een afgewerkt premium product dat de gebruiker met plezier dagelijks als startpagina gebruikt.", - "successCriteria": "De MVP is klaar wanneer: de applicatie via Docker Compose op Unraid kan worden gestart; beschikbare Docker-containers automatisch worden ontdekt zonder schrijf- of beheerrechten op Docker; nieuw ontdekte containers standaard verborgen blijven; de gebruiker containers zichtbaar of verborgen kan maken; naam, icoon, categorie, sortering en lokale URL per app kunnen worden aangepast; de lokale WebUI-URL waar mogelijk automatisch uit Unraid-templategegevens wordt afgeleid; zichtbare apps naam, icoon en correcte online- of offline-status tonen; status bij het openen en daarna ongeveer elke 30 seconden wordt vernieuwd; categorieën kunnen worden aangemaakt, hernoemd, gesorteerd en toegewezen; zowel een volledig overzicht als categoriegebaseerde navigatie configureerbaar is; favorieten met naam, URL, icoon, categorie en sortering kunnen worden beheerd; de zoekbalk apps en favorieten doorzoekt en overige invoer naar Google stuurt; alle links in een nieuw tabblad openen; licht, donker en minstens één aanvullend thema selecteerbaar zijn; configuratie persistent blijft na container- en serverherstart; back-up en herstel via export en import werken; de app uitsluitend voor lokaal gebruik is ingericht en geen container start-, stop-, restart- of wijzigingsacties bevat; een private Gitea-repository automatisch wordt aangemaakt, lokaal wordt geïnitialiseerd en een eerste bruikbare commit bevat; en de UI een consistent, professioneel en visueel hoogwaardig geheel vormt met nette kaartlay-outs, sterke typografie, duidelijke statusindicatoren, vloeiende maar subtiele interacties en een verzorgde instellingenervaring.", - "mustHaves": "Automatische read-only ontdekking van Unraid Docker-containers; nieuwe containers standaard verborgen; instellingenpagina voor zichtbaarheid, naam, icoon, categorie, sortering en URL-overrides; automatische afleiding van lokale WebUI-URL's uit Unraid-templategegevens; read-only containerstatus met verversing bij laden en ongeveer elke 30 seconden; configureerbare categorieën; configureerbare keuze tussen één totaaloverzicht en categoriepagina's of tabbladen; handmatige favorieten voor externe websites; centrale zoekbalk voor apps, favorieten en Google; openen van alle doelen in een nieuw tabblad; licht, donker en aanvullende ingebouwde thema's; persistent bewaren van instellingen in SQLite; export en import van configuratie; Docker- en Docker Compose-configuratie voor Unraid; beperkte Docker-socketproxy zonder beheerrechten; optionele read-only Nginx Proxy Manager-koppeling voor externe URL's met handmatige fallback; duidelijke foutstatus wanneer Unraid, Docker-proxy of Nginx Proxy Manager tijdelijk onbereikbaar is; automatische initialisatie van een private Gitea-repository via environmentvariabelen; en een uitzonderlijk verzorgde professionele UI met premium dashboardlook, consistente componenten, strak kaartdesign, goede witruimte, duidelijke visuele hiërarchie, subtiele hover- en transition-effecten, nette iconografie en een afgewerkte instellingenomgeving.", - "niceToHaves": "Drag-and-drop sortering van apps en categorieën; automatisch herkennen van bekende applicatie-iconen; favicon-import voor favorieten; meerdere aanvullende kleurthema's; compacte en ruime tegelweergave; achtergrondafbeeldingen of subtiele dashboard-achtergronden; sneltoets om de zoekbalk te focussen; recente of meest gebruikte apps; optionele statuslatentie en responstijd; import van bestaande browserbladwijzers; meerdere dashboards of profielen; PWA-installatie; mobiele optimalisaties buiten de basisresponsiviteit; geavanceerde automatische matching tussen Nginx Proxy Manager-hosts en Docker-containers; handmatige statuscontrole voor favoriete websites; extra micro-interacties en visuele personalisatieopties zolang die de strakke premium uitstraling ondersteunen.", - "outOfScope": "Containers starten, stoppen, herstarten, verwijderen of wijzigen; Docker Compose-stacks beheren; Unraid-systeembeheer; terminal- of shelltoegang; logviewer; resourcegrafieken en uitgebreide monitoring; notificaties en incidentbeheer; publieke internettoegang configureren; Authentik- of andere SSO-integratie; eigen gebruikersaccounts, rollen of multi-userondersteuning; opslag van echte API-tokens in de repository of browser; automatische wijzigingen aan Nginx Proxy Manager; cloudhosting; mobiele native apps; AI-functionaliteit; synchronisatie tussen meerdere Unraid-servers; automatische publicatie van diensten naar het internet.", - "constraints": "De MVP moet als Docker-container op de bestaande Unraid-server draaien en alleen vanaf het lokale netwerk bereikbaar zijn. De app moet zonder eigen login bruikbaar zijn, omdat netwerktoegang de primaire beveiligingsgrens vormt. Alle Docker-toegang moet read-only verlopen via een beperkte socketproxy; de hoofdcontainer krijgt geen directe onbeperkte Docker-socket. Unraid-templategegevens mogen alleen worden gelezen. Nginx Proxy Manager wordt uitsluitend read-only benaderd. De applicatie moet bruikbaar blijven wanneer Nginx Proxy Manager niet is geconfigureerd of tijdelijk niet bereikbaar is. Er mogen geen betaalde externe diensten nodig zijn. Alle configuratie moet persistent blijven bij herstart en eenvoudig te back-uppen zijn. Codex moet een nieuwe private repository in de persoonlijke Gitea-omgeving aanmaken en mag geen secrets hardcoderen. Visuele kwaliteit is belangrijk, maar de UI mag niet onnodig zwaar of traag worden; professionele polish moet samengaan met vlotte prestaties als browserstartpagina.", - "projectType": "webapp", - "runtimeModel": "interactive-app", - "updateStrategy": "polling", - "persistenceModel": "sqlite", - "deploymentTarget": "docker-unraid", - "repositoryState": "repo-needed", - "preferredStack": "TypeScript als hoofdtaal; React met Vite voor de frontend; Node.js met Fastify voor de backend; SQLite met Drizzle ORM voor persistentie en migraties; gedeelde types en validaties met Zod; een eenvoudige REST-API tussen frontend en backend; een zorgvuldig opgebouwd design system met design tokens, CSS-variabelen en herbruikbare UI-componenten; een moderne componentbibliotheek of headless aanpak voor toegankelijke basiscomponenten; iconenset van hoge kwaliteit; subtiele motion via lichte animatielogica; Docker multi-stage build en Docker Compose voor Unraid; Vitest voor unit- en integratietests; Playwright voor browsertests. Gebruik een overzichtelijke repository met duidelijk gescheiden frontend-, backend- en shared-mappen. Richt de frontend expliciet in op een premium dashboardervaring met consistente spacing, kaartsystemen, typografie, kleurgebruik, statusbadges en instellingenpanelen.", - "dataInvolved": "Docker-apps met container-ID, technische naam, weergavenaam, afbeelding, ontdekt icoon, online- of offline-status, lokale WebUI-URL, optionele externe URL, zichtbaarheid, categorie, sorteerpositie, detectiebron en laatste statuscontrole; categorieën met naam, icoon, sorteerpositie en weergave-instellingen; favorieten met naam, URL, icoon, categorie en sorteerpositie; dashboardinstellingen met gekozen thema, weergavemodus, openingsgedrag, zoekprovider, pollinginterval en externe-toegangsschakelaar; integratie-instellingen zonder geheime waarden; import- en exportbestand met versieerbaar configuratieschema; synchronisatiestatus en beperkte foutinformatie voor Unraid, Docker-proxy en Nginx Proxy Manager.", - "integrations": "Unraid Docker-templatebestanden of beschikbare Unraid-metadata voor containernaam, WebUI en iconen; een beperkte read-only Docker Socket Proxy voor containerinventaris en runtime-status; Nginx Proxy Manager API in read-only modus voor optionele externe hostnamen en URL's; Google Search via een gewone zoek-URL zonder API-key; Gitea API voor het aanmaken van een private repository en het configureren van de Git-remote.", - "credentialsExpected": true, - "securityPrivacyNotes": "Gebruik uitsluitend environmentvariabelen voor gevoelige configuratie. Voorzie minimaal variabelen zoals GITEA_URL, GITEA_TOKEN en GITEA_OWNER voor repository-initialisatie en optionele NPM_URL, NPM_USERNAME en NPM_PASSWORD of een ondersteund read-only token voor Nginx Proxy Manager. Gebruik voor de Docker-koppeling geen onbeperkte socketmount in de applicatiecontainer, maar een afzonderlijke proxy met alleen de minimaal noodzakelijke read-endpoints. Sla secrets nooit op in SQLite, exports, logs, frontendbundels, testfixtures of Git. Toon gevoelige integratiewaarden gemaskeerd in de UI. De app mag geen Docker-mutaties uitvoeren en mag nooit containers starten, stoppen, herstarten, verwijderen of aanpassen. De MVP heeft geen eigen authenticatie en moet daarom standaard alleen op een lokaal bindadres of afgeschermd Docker-netwerk worden gepubliceerd. Externe toegang via Nginx Proxy Manager wordt alleen voorbereid; correcte authenticatie en toegangscontrole via bijvoorbeeld Authentik blijven de verantwoordelijkheid van een latere deploymentstap.", - "qualityBar": "De interface moet eruitzien als een professioneel premium product en niet als een simpel hobbydashboard. Verwacht een moderne, luxueuze en verzorgde UI met uitgebalanceerde witruimte, consistente kaartcomponenten, hoogwaardige iconografie, duidelijke typografie, nette schaduwen of diepte waar passend, subtiele animaties, verzorgde empty states, sterke visuele hiërarchie en een kalme maar rijke look-and-feel. De belangrijkste startpagina moet snel laden en direct bruikbaar zijn wanneer integraties traag of tijdelijk offline zijn. Fouten moeten begrijpelijk worden getoond zonder technische stacktraces. Ontbrekende iconen of URL's moeten nette fallbacks hebben. Instellingen moeten veilig opgeslagen worden en bij foutieve invoer gevalideerde feedback geven. De interface moet responsief zijn voor desktop, tablet en basisgebruik op mobiel. Toetsenbordnavigatie, zichtbare focus, semantische HTML en voldoende kleurcontrast zijn vereist. Statuspolling mag geen zichtbare flikkering veroorzaken en moet stoppen wanneer de pagina niet actief is of verstandig worden vertraagd. De code moet modulair, getypeerd, gedocumenteerd en zonder onnodige complexiteit zijn. Esthetische kwaliteit is voor deze MVP een kernvereiste en niet louter een extra.", - "testingExpectations": "Unit tests voor URL-normalisatie, categorie- en sorteervoorwaarden, zoekresultaten, configuratievalidatie, import- en exportversies, container-naar-Nginx-hostmatching en permissiegrenzen; backend-integratietests met gemockte Unraid-templategegevens, Docker Socket Proxy en Nginx Proxy Manager; SQLite-migratie- en persistentietests; tests die aantonen dat geen Docker-mutatie-endpoints of beheeracties worden gebruikt; browsertests voor eerste ingebruikname, containers zichtbaar maken, categorieën beheren, favorieten toevoegen, zoeken, Google-fallback, thema wisselen, weergavemodus wijzigen, URL openen en configuratie exporteren en importeren; visuele UI-smoketests voor kernschermen zoals dashboard, categorie-overzicht en instellingen; Docker-buildtest en Compose-smoketest; een live read-only validatie op Unraid waarbij discovery, status en lokale WebUI-URL's worden gecontroleerd zonder containers te wijzigen.", - "knownRisks": "Unraid-templatebestanden en WebUI-velden kunnen per container of template verschillen, waardoor URL-detectie fallbacks en handmatige overrides nodig zijn. Niet iedere container heeft een WebUI, correct icoon of eenvoudig herkenbare poort. Het automatisch koppelen van Nginx Proxy Manager-hosts aan containers kan dubbelzinnig zijn en moet daarom transparant, overschrijfbaar en niet-destructief blijven. Een lokale app zonder login is alleen veilig zolang netwerktoegang correct is afgeschermd. Toegang tot Docker-informatie blijft gevoelig, zelfs in read-only modus, waardoor de socketproxy strikt moet worden beperkt. Gitea-repositorycreatie kan mislukken door ontbrekende rechten, een bestaande naam of onbereikbaarheid; de lokale repository moet dan intact blijven en een duidelijke herstelstap tonen. Hoge UI-ambities kunnen de scope doen groeien door eindeloos finetunen van styling en details; daarom moet de premium uitstraling via een consistent design system en duidelijke acceptatiecriteria bewaakt worden zonder de kernfunctionaliteit te vertragen." - } -} diff --git a/public/dockdeck-icon.png b/public/dockdeck-icon.png deleted file mode 100644 index 75ef90ce..00000000 Binary files a/public/dockdeck-icon.png and /dev/null differ diff --git a/public/dockdeck-icon.svg b/public/dockdeck-icon.svg deleted file mode 100644 index f18f72ca..00000000 --- a/public/dockdeck-icon.svg +++ /dev/null @@ -1,21 +0,0 @@ - - DockDeck - - - - - - - - - - - - - - - - - - - diff --git a/public/dockdeck-mark-dark.svg b/public/dockdeck-mark-dark.svg deleted file mode 100644 index 91f46a1b..00000000 --- a/public/dockdeck-mark-dark.svg +++ /dev/null @@ -1,6 +0,0 @@ - - DockDeck beeldmerk voor donkere achtergrond - - - - diff --git a/public/dockdeck-mark-light.svg b/public/dockdeck-mark-light.svg deleted file mode 100644 index 715b57a6..00000000 --- a/public/dockdeck-mark-light.svg +++ /dev/null @@ -1,6 +0,0 @@ - - DockDeck beeldmerk voor lichte achtergrond - - - - diff --git a/public/dockdeck-mark-monochrome.svg b/public/dockdeck-mark-monochrome.svg deleted file mode 100644 index da802407..00000000 --- a/public/dockdeck-mark-monochrome.svg +++ /dev/null @@ -1,6 +0,0 @@ - - DockDeck monochroom beeldmerk - - - - diff --git a/public/dockdeck-mark.svg b/public/dockdeck-mark.svg deleted file mode 100644 index 09526d8e..00000000 --- a/public/dockdeck-mark.svg +++ /dev/null @@ -1,7 +0,0 @@ - - DockDeck - - - - - diff --git a/public/dockdeck-wordmark.svg b/public/dockdeck-wordmark.svg deleted file mode 100644 index bede9662..00000000 --- a/public/dockdeck-wordmark.svg +++ /dev/null @@ -1,9 +0,0 @@ - - DockDeck woordmerk - - - - - - DockDeck - diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest deleted file mode 100644 index 8beb741a..00000000 --- a/public/manifest.webmanifest +++ /dev/null @@ -1,17 +0,0 @@ -{ - "name": "DockDeck", - "short_name": "DockDeck", - "description": "Je persoonlijke startpunt voor apps en favorieten.", - "start_url": "/", - "display": "standalone", - "background_color": "#0b1326", - "theme_color": "#818cf8", - "icons": [ - { - "src": "/dockdeck-mark.svg", - "sizes": "any", - "type": "image/svg+xml", - "purpose": "any maskable" - } - ] -} diff --git a/public/sw.js b/public/sw.js deleted file mode 100644 index 85f45cf3..00000000 --- a/public/sw.js +++ /dev/null @@ -1,58 +0,0 @@ -/* global self, caches, fetch, URL */ - -const CACHE = "dockdeck-shell-v1"; -const SHELL = ["/", "/manifest.webmanifest", "/dockdeck-icon.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; - }), - ), - ); -}); diff --git a/scripts/deploy_tower.ps1 b/scripts/deploy_tower.ps1 index 1f3e5179..0d496062 100644 --- a/scripts/deploy_tower.ps1 +++ b/scripts/deploy_tower.ps1 @@ -2,8 +2,8 @@ param( [string]$RemoteHost = "root@192.168.10.150", [string]$RemotePath = "/mnt/user/appdata/geointel", [string]$RemoteBranch = "main", - [string]$RemoteRepo = "gitea-widefrog:NuklearRabbit/geointel.git", - [string]$SshKey = "$HOME/.ssh/widefrog_unraid_deploy", + [string]$RemoteRepo = "git@gitea.itworx.tech:Jens/geointel.git", + [string]$SshKey = "$HOME/.ssh/itworx_unraid_deploy", [string]$FrontendUrl = "http://192.168.10.150:1202", [string]$InstallAi = $(if ($env:GEOINTEL_INSTALL_AI) { $env:GEOINTEL_INSTALL_AI } else { "" }), [switch]$Bootstrap diff --git a/scripts/init-gitea.mjs b/scripts/init-gitea.mjs deleted file mode 100644 index 5c7f039b..00000000 --- a/scripts/init-gitea.mjs +++ /dev/null @@ -1,86 +0,0 @@ -import { execFileSync } from "node:child_process"; - -const required = ["GITEA_URL", "GITEA_OWNER", "GITEA_TOKEN"]; -const missing = required.filter((name) => !process.env[name]); -if (missing.length) { - console.error( - `Missing required environment variables: ${missing.join(", ")}`, - ); - process.exit(2); -} - -const base = process.env.GITEA_URL.replace(/\/$/, ""); -const owner = process.env.GITEA_OWNER; -const token = process.env.GITEA_TOKEN; -const name = "DockDeck"; -const headers = { - Authorization: `token ${token}`, - "content-type": "application/json", -}; - -let response = await fetch( - `${base}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`, - { headers }, -); -if (response.status === 404) { - response = await fetch(`${base}/api/v1/user/repos`, { - method: "POST", - headers, - body: JSON.stringify({ - name, - private: true, - auto_init: false, - description: "A premium personal launchpad for Unraid services.", - }), - }); -} -if (!response.ok) { - console.error( - `Gitea repository initialization failed with HTTP ${response.status}.`, - ); - process.exit(1); -} - -const repository = await response.json(); -if (!repository.private || repository.owner?.login !== owner) { - console.error( - "Gitea returned a repository with an unexpected owner or visibility. Aborting.", - ); - process.exit(1); -} - -const remoteUrl = `${base}/${encodeURIComponent(owner)}/${name}.git`; -let currentRemote = ""; -try { - currentRemote = execFileSync("git", ["remote", "get-url", "origin"], { - encoding: "utf8", - }).trim(); -} catch { - /* no origin yet */ -} -if (currentRemote && currentRemote !== remoteUrl) { - console.error( - "An origin remote already exists with a different URL. No changes were made.", - ); - process.exit(1); -} -if (!currentRemote) - execFileSync("git", ["remote", "add", "origin", remoteUrl], { - stdio: "inherit", - }); - -execFileSync( - "git", - [ - "-c", - `http.extraHeader=Authorization: token ${token}`, - "push", - "-u", - "origin", - "main", - ], - { - stdio: ["ignore", "inherit", "inherit"], - }, -); -console.log(`Private repository ready at ${base}/${owner}/${name}`); diff --git a/src/client/App.tsx b/src/client/App.tsx deleted file mode 100644 index bb4b2924..00000000 --- a/src/client/App.tsx +++ /dev/null @@ -1,210 +0,0 @@ -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 { 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("dashboard"); - const [settingsSection, setSettingsSection] = - useState("apps"); - const [data, setData] = useState(null); - const [error, setError] = useState(null); - const [syncing, setSyncing] = useState(false); - const [toast, setToast] = useState(null); - const toastTimerRef = useRef(undefined); - const syncInFlightRef = useRef | 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); - }; - - useEffect( - () => () => { - window.clearTimeout(toastTimerRef.current); - }, - [], - ); - - if (!data && !error) return ; - if (!data && error) - return ( -
- -

VERBINDING VERBROKEN

-

DockDeck is even niet bereikbaar.

-

{error}

- -
- ); - if (!data) return null; - - return ( -
- - Naar inhoud - - {page === "dashboard" ? ( - { - setSettingsSection(section); - setPage("settings"); - }} - /> - ) : ( - setPage("dashboard")} - syncing={syncing} - onSync={sync} - onChanged={load} - notify={notify} - /> - )} - {toast && ( -
- - {toast} -
- )} - -
- ); -} diff --git a/src/client/api.ts b/src/client/api.ts deleted file mode 100644 index 388c240f..00000000 --- a/src/client/api.ts +++ /dev/null @@ -1,126 +0,0 @@ -import type { - AppPatch, - Category, - CategoryOrganizationResult, - CategoryInput, - DashboardPayload, - Favorite, - FavoriteInput, - LayoutPreset, - LayoutPresetImport, - PreferencesPatch, - SystemDiagnostics, - WallpaperDescriptor, - WeatherMetric, - WidgetLayoutInput, -} from "../shared/contracts"; - -async function request(path: string, init?: RequestInit): Promise { - 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("/api/dashboard"), - weather: () => request("/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("/api/wallpaper", { method: "DELETE" }), - sync: () => request("/api/discovery/sync", { 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("/api/categories", { - method: "POST", - body: JSON.stringify(input), - }), - updateCategory: (id: string, input: Partial) => - request(`/api/categories/${id}`, { - method: "PATCH", - body: JSON.stringify(input), - }), - deleteCategory: (id: string) => - request(`/api/categories/${id}`, { method: "DELETE" }), - organizeCategories: () => - request("/api/categories/organize", { - method: "POST", - }), - createFavorite: (input: FavoriteInput) => - request("/api/favorites", { - method: "POST", - body: JSON.stringify(input), - }), - updateFavorite: (id: string, input: Partial) => - request(`/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("/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("/api/layout-presets/import", { - method: "POST", - body: JSON.stringify(payload), - }), - diagnostics: () => request("/api/diagnostics"), - updateIntegrationCredentials: (id: string, values: Record) => - 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) }), -}; diff --git a/src/client/canon.css b/src/client/canon.css deleted file mode 100644 index a7a03a53..00000000 --- a/src/client/canon.css +++ /dev/null @@ -1,1373 +0,0 @@ -/* Canon v1 presentation layer. Component logic continues to use the existing - product contracts; this file only maps those surfaces to semantic tokens. */ - -html { - scroll-behavior: smooth; - background: var(--color-background); -} - -body { - font-family: "Geist Variable", Geist, ui-sans-serif, system-ui, sans-serif; - background: var(--color-background); - color: var(--color-text); -} - -button, -input, -select, -textarea { - font-family: inherit; -} - -button:focus-visible, -a:focus-visible, -input:focus-visible, -select:focus-visible, -summary:focus-visible { - outline: 2px solid var(--color-focus); - outline-offset: 3px; -} - -.brand-lockup { - display: inline-flex; - align-items: center; - gap: var(--space-3); - text-align: left; -} - -.brand-lockup img { - width: 40px; - height: 40px; - flex: 0 0 40px; -} - -.brand-lockup > span { - min-width: 0; - display: grid; -} - -.brand-lockup strong { - color: var(--color-text); - font-size: 18px; - letter-spacing: -0.035em; -} - -.brand-lockup small { - margin-top: 2px; - color: var(--color-text-faint); - font-size: 10px; -} - -.brand-lockup.compact img { - width: 34px; - height: 34px; - flex-basis: 34px; -} - -.brand-lockup.compact strong { - font-size: 20px; -} - -.dashboard, -.settings-page { - width: 100%; - min-height: 100vh; - margin: 0; - padding: 0; -} - -.dashboard-topbar, -.settings-topbar { - position: sticky; - z-index: 50; - top: 0; - width: 100%; - height: 72px; - display: grid; - grid-template-columns: auto minmax(280px, 560px) 1fr auto; - align-items: center; - gap: var(--space-6); - border-bottom: 1px solid var(--color-border); - padding: 0 max(var(--space-6), calc((100vw - 1440px) / 2)); - background: color-mix(in srgb, var(--color-background) 92%, transparent); - backdrop-filter: blur(18px); -} - -.brand-button, -.settings-brand { - display: inline-flex; - border: 0; - padding: 0; - background: transparent; - cursor: pointer; -} - -.dashboard-topbar nav, -.settings-topbar nav { - justify-self: end; - display: flex; - align-items: center; - gap: var(--space-2); -} - -.dashboard-topbar nav button, -.settings-topbar nav button, -.settings-topbar nav span { - min-height: 38px; - display: inline-flex; - align-items: center; - border: 0; - border-bottom: 2px solid transparent; - padding: 0 10px; - color: var(--color-text-muted); - background: transparent; - font-size: 13px; - font-weight: 600; -} - -.dashboard-topbar nav button { - cursor: pointer; -} - -.dashboard-topbar nav button:hover, -.dashboard-topbar nav button.active, -.settings-topbar nav span { - border-bottom-color: var(--color-primary); - color: var(--color-text); -} - -.topbar-actions { - display: flex; - align-items: center; - gap: var(--space-2); -} - -.icon-button { - width: 42px; - height: 42px; - border: 1px solid transparent; - border-radius: var(--radius-control); - color: var(--color-text-muted); - background: transparent; - box-shadow: none; -} - -.icon-button:hover { - border-color: var(--color-border); - color: var(--color-text); - background: var(--color-hover); - transform: none; -} - -.dashboard-canvas { - width: min(calc(100% - 48px), var(--content-canon)); - margin: 0 auto; - padding: 42px 0 48px; -} - -.dashboard-hero, -.dashboard-toolbar, -.degraded-banner, -.widgets-section, -.category-tabs, -.dashboard-groups, -.empty-state, -.dashboard-footer, -.favorites-section { - max-width: none; -} - -.dashboard-hero { - grid-template-columns: minmax(0, 1fr) minmax(320px, 430px); - align-items: center; - gap: var(--space-12); - margin: 0 0 28px; -} - -.dashboard-hero::after { - display: none; -} - -.hero-copy h1 { - max-width: none; - color: var(--color-text); - font-size: clamp(34px, 4vw, 48px); - font-weight: 650; - line-height: 1.08; - letter-spacing: -0.045em; -} - -.hero-intro { - margin-top: var(--space-3); - color: var(--color-text-muted); - font-size: 16px; - line-height: 1.5; -} - -.eyebrow, -.section-kicker { - color: var(--color-primary); - font-size: 11px; - font-weight: 650; - letter-spacing: 0.12em; -} - -.hero-overview { - max-width: none; - border-color: var(--color-border); - border-radius: var(--radius-panel); - padding: var(--space-4); - background: var(--color-surface); - box-shadow: none; - backdrop-filter: none; -} - -.hero-metrics > span { - border-color: var(--color-border); - border-radius: var(--radius-control); - background: var(--color-surface-elevated); -} - -.discovery-banner { - width: fit-content; - min-width: 310px; - min-height: 52px; - display: flex; - align-items: center; - gap: var(--space-3); - margin: -8px 0 28px auto; - border: 1px solid - color-mix(in srgb, var(--color-success) 50%, var(--color-border)); - border-radius: var(--radius-surface); - padding: 10px 14px; - color: var(--color-success); - background: var(--color-success-soft); - cursor: pointer; -} - -.discovery-banner > span { - flex: 1; - display: grid; - text-align: left; -} - -.discovery-banner small { - font-size: 9px; - font-weight: 700; - letter-spacing: 0.11em; -} - -.discovery-banner strong { - margin-top: 2px; - color: var(--color-text); - font-size: 13px; -} - -.degraded-banner { - margin: 0 0 var(--space-6); - border-color: color-mix( - in srgb, - var(--color-warning) 35%, - var(--color-border) - ); - border-radius: var(--radius-control); - background: var(--color-warning-soft); -} - -.topbar-search, -.topbar-search .search-shell { - min-width: 0; - width: 100%; - margin: 0; -} - -.search-shell { - z-index: 80; -} - -.search-bar { - position: relative; - z-index: 2; - height: 44px; - gap: var(--space-3); - border-color: var(--color-border); - border-radius: var(--radius-control); - padding: 0 12px 0 15px; - background: var(--color-surface-elevated); - box-shadow: none; - backdrop-filter: none; -} - -.search-bar input { - color: var(--color-text); - font-size: 14px; -} - -.search-bar:focus-within { - border-color: var(--color-primary); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-focus) 18%, transparent); -} - -.search-shell.expanded { - position: fixed; - inset: 0; - z-index: 100; - display: flex; - flex-direction: column; - align-items: center; - padding: min(14vh, 130px) 20px 40px; -} - -/* A backdrop-filter creates a containing block for fixed descendants. Disable - it while Quick Launch is open so the dialog can cover the full viewport. */ -.dashboard-topbar:has(.search-shell.expanded) { - backdrop-filter: none; -} - -.search-shell.expanded .search-backdrop { - position: absolute; - inset: 0; - z-index: 0; - border: 0; - background: color-mix(in srgb, var(--color-background) 76%, transparent); - backdrop-filter: blur(12px); - cursor: default; -} - -.search-shell.expanded .search-bar, -.search-results { - width: min(100%, 720px); -} - -.search-shell.expanded .search-bar { - height: 66px; - border-radius: var(--radius-surface) var(--radius-surface) 0 0; - border-color: var(--color-border-strong); - background: var(--color-surface); - box-shadow: var(--shadow-overlay); -} - -.search-results { - position: relative; - inset: auto; - top: auto; - z-index: 2; - max-height: min(66vh, 640px); - overflow-y: auto; - border-color: var(--color-border-strong); - border-top: 0; - border-radius: 0 0 var(--radius-surface) var(--radius-surface); - padding: var(--space-2) var(--space-2) 0; - background: var(--color-surface); - box-shadow: var(--shadow-overlay); -} - -.result-group { - padding: var(--space-2) 0; -} - -.result-group h2 { - margin: 0; - padding: 8px 12px 6px; - color: var(--color-text-faint); - font-size: 11px; - font-weight: 650; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.search-results a, -.search-results button.google-result { - min-height: 62px; - border: 0 !important; - border-radius: var(--radius-control) !important; - padding: 8px 12px; - color: var(--color-text); -} - -.search-results a:hover, -.search-results button:hover, -.search-results a.selected, -.search-results button.selected { - background: var(--color-active); -} - -.search-hint { - display: flex; - align-items: center; - gap: var(--space-3); - padding: 22px 14px; - color: var(--color-primary); -} - -.search-hint > span { - display: grid; -} - -.search-hint small { - margin-top: 3px; - color: var(--color-text-muted); -} - -.search-shortcuts { - position: sticky; - bottom: 0; - display: flex; - gap: var(--space-5); - margin: var(--space-2) calc(var(--space-2) * -1) 0; - border-top: 1px solid var(--color-border); - padding: 12px 16px; - color: var(--color-text-muted); - background: var(--color-surface-elevated); - font-size: 11px; -} - -.search-shortcuts span { - display: inline-flex; - align-items: center; - gap: 6px; -} - -kbd { - border-color: var(--color-border-strong); - color: var(--color-text-muted); - background: var(--color-surface-muted); - font-family: inherit; -} - -.category-tabs { - margin: 0 0 38px; - gap: var(--space-2); - padding: 2px; -} - -.category-tabs button { - min-height: 40px; - border-color: var(--color-border); - border-radius: 999px; - padding: 0 16px; - color: var(--color-text-muted); - background: var(--color-surface-elevated); - font-size: 13px; -} - -.category-tabs button.active { - border-color: var(--color-primary); - color: var(--color-background); - background: var(--color-primary); - box-shadow: none; -} - -.favorites-section, -.app-section { - margin: 0 0 42px; -} - -.favorites-section > header, -.app-section > header { - display: flex; - align-items: end; - justify-content: space-between; - padding: 0 2px 14px; -} - -.favorites-section h2, -.app-section h2 { - display: flex; - align-items: center; - gap: var(--space-2); - margin: 0; - font-size: 22px; - font-weight: 600; - letter-spacing: -0.025em; -} - -.favorites-section h2 svg, -.app-section h2 svg { - color: var(--color-primary); -} - -.favorite-grid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: var(--space-4); -} - -.app-grid { - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: var(--space-4); -} - -.app-tile { - min-width: 0; - min-height: 148px; - display: grid; - grid-template-columns: auto 1fr auto; - grid-template-rows: auto 1fr; - align-items: start; - gap: var(--space-3); - border-color: var(--color-border); - border-radius: var(--radius-surface); - padding: var(--space-5); - background: var(--color-surface); - box-shadow: none; - backdrop-filter: none; -} - -.app-tile:hover { - border-color: color-mix( - in srgb, - var(--color-primary) 55%, - var(--color-border) - ); - background: var(--color-hover); - box-shadow: var(--shadow-interactive); - transform: translateY(-2px); -} - -.app-tile .tile-copy { - grid-column: 1 / 3; - grid-row: 2; - align-self: end; -} - -.tile-copy strong { - font-size: 16px; - font-weight: 600; -} - -.tile-copy small { - margin-top: 4px; - color: var(--color-text-muted); - font-size: 12px; -} - -.app-tile .status-dot { - grid-column: 3; - grid-row: 1; - width: 9px; - height: 9px; - margin: 6px; - border: 0; - box-shadow: none; -} - -.status-dot.online, -.inline-status.online > span { - background: var(--color-success); -} - -.status-dot.offline, -.inline-status.offline > span { - background: var(--color-error); -} - -.status-dot.unknown, -.inline-status.unknown > span { - background: var(--color-unknown); -} - -.app-tile .tile-arrow { - grid-column: 3; - grid-row: 2; - align-self: end; -} - -.favorite-grid .app-tile { - min-height: 88px; - display: flex; - align-items: center; - padding: var(--space-4); -} - -.favorite-grid .app-tile .tile-copy { - display: grid; -} - -.favorite-grid .app-tile .tile-arrow { - margin-left: auto; -} - -.dashboard-insights { - margin: 8px 0 36px; - border: 1px solid var(--color-border); - border-radius: var(--radius-panel); - background: var(--color-surface); -} - -.dashboard-insights > summary { - min-height: 74px; - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-6); - padding: 16px 20px; - color: var(--color-text); - cursor: pointer; -} - -.dashboard-insights > summary > span { - display: flex; - align-items: center; - gap: var(--space-2); - font-weight: 600; -} - -.dashboard-insights > summary svg { - color: var(--color-primary); -} - -.dashboard-insights > summary small { - color: var(--color-text-muted); -} - -.dashboard-insights .widgets-section { - margin: 0; - border-top: 1px solid var(--color-border); - padding: 20px; -} - -.dashboard-footer { - color: var(--color-text-muted); -} - -.primary-button { - min-height: 42px; - border-color: var(--color-primary); - border-radius: var(--radius-control); - color: #0b1326; - background: var(--color-primary); - box-shadow: none; -} - -.secondary-button { - min-height: 42px; - border-color: var(--color-border); - border-radius: var(--radius-control); - background: var(--color-surface); -} - -.settings-topbar { - grid-template-columns: auto 1fr auto auto; - padding-inline: max(var(--space-6), calc((100vw - 1440px) / 2)); -} - -.settings-topbar nav { - justify-self: start; -} - -.settings-topbar .back-button { - min-height: 40px; - border: 1px solid var(--color-border); - border-radius: var(--radius-control); - padding: 0 12px; -} - -.settings-layout { - width: min(calc(100% - 48px), 1440px); - max-width: 1440px; - grid-template-columns: 260px minmax(0, 1fr); - min-height: calc(100vh - 72px); -} - -.settings-sidebar { - border-color: var(--color-border); - padding: 42px 24px 36px 0; -} - -.settings-sidebar h1 { - margin-bottom: 28px; - color: var(--color-text-muted); - font-size: 14px; - font-weight: 450; - letter-spacing: 0; -} - -.settings-sidebar nav button { - min-height: 46px; - border-radius: var(--radius-control); - color: var(--color-text-muted); - font-size: 13px; -} - -.settings-sidebar nav button.active { - color: #0b1326; - background: var(--color-primary); - box-shadow: none; -} - -.settings-sidebar nav button.active svg { - color: #0b1326; -} - -.settings-content { - padding: 44px 0 80px 48px; -} - -.settings-heading { - max-width: 720px; - margin-bottom: 24px; -} - -.settings-heading h2 { - margin-bottom: 8px; - font-size: 36px; - font-weight: 650; - letter-spacing: -0.04em; -} - -.settings-heading > p:last-child { - color: var(--color-text-muted); - font-size: 15px; -} - -.settings-subnav { - display: inline-flex; - gap: 4px; - margin: 0 0 28px; - border: 1px solid var(--color-border); - border-radius: var(--radius-control); - padding: 4px; - background: var(--color-surface); -} - -.settings-subnav button { - min-height: 38px; - display: inline-flex; - align-items: center; - gap: 7px; - border: 0; - border-radius: 6px; - padding: 0 12px; - color: var(--color-text-muted); - background: transparent; - cursor: pointer; -} - -.settings-subnav button.active { - color: var(--color-text); - background: var(--color-active); -} - -.settings-summary span { - min-height: 42px; - border-color: var(--color-border); - border-radius: var(--radius-control); - color: var(--color-text-muted); - background: var(--color-surface); - font-size: 11px; -} - -.management-toolbar { - margin-bottom: 10px; -} - -input, -select { - min-height: 42px; - border-color: var(--color-border); - border-radius: var(--radius-control); - color: var(--color-text); - background: var(--color-surface); - font-size: 12px; -} - -input:focus, -select:focus { - border-color: var(--color-focus); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-focus) 16%, transparent); -} - -label > span:first-child, -.setting-label { - color: var(--color-text-muted); - font-size: 10px; - letter-spacing: 0.06em; -} - -.filter-pills { - border-color: var(--color-border); - border-radius: var(--radius-control); - background: var(--color-surface); -} - -.filter-pills button { - min-height: 34px; - font-size: 10px; -} - -.filter-pills button.active { - color: var(--color-background); - background: var(--color-primary); - box-shadow: none; -} - -.app-management-row { - min-height: 78px; - display: grid; - grid-template-columns: - minmax(220px, 1.5fr) minmax(120px, 0.65fr) minmax(130px, 0.65fr) - auto; - align-items: center; - gap: var(--space-4); - border: 1px solid var(--color-border); - border-radius: var(--radius-surface); - padding: 12px 14px; - background: var(--color-surface); -} - -.app-management-row .management-title { - min-width: 0; -} - -.app-management-row .management-title > span:nth-child(2) { - flex: 1; -} - -.inline-status, -.category-badge { - display: inline-flex; - align-items: center; - gap: 7px; - width: fit-content; - min-height: 30px; - border: 1px solid var(--color-border); - border-radius: 999px; - padding: 0 10px; - color: var(--color-text-muted); - font-size: 11px; -} - -.inline-status > span { - width: 7px; - height: 7px; - border-radius: 50%; -} - -.app-row-actions { - display: flex; - justify-content: end; - align-items: center; - gap: 7px; -} - -.visibility-toggle, -.widget-toggle, -.edit-app-button { - min-height: 36px; - border-color: var(--color-border); - border-radius: var(--radius-control); - padding: 0 10px; - color: var(--color-text-muted); - background: var(--color-surface-elevated); - font-size: 10px; -} - -.edit-app-button { - display: inline-flex; - align-items: center; - gap: 5px; - border: 1px solid var(--color-border); - cursor: pointer; -} - -.visibility-toggle.on { - border-color: color-mix( - in srgb, - var(--color-success) 45%, - var(--color-border) - ); - color: var(--color-success); - background: var(--color-success-soft); -} - -.drawer-layer { - position: fixed; - inset: 0; - z-index: 120; - display: flex; - justify-content: flex-end; -} - -.drawer-backdrop { - position: absolute; - inset: 0; - border: 0; - background: rgb(3 8 22 / 0.68); - backdrop-filter: blur(8px); -} - -.app-editor-drawer { - position: relative; - z-index: 1; - width: min(100%, 620px); - height: 100%; - display: grid; - grid-template-rows: auto minmax(0, 1fr) auto; - border-left: 1px solid var(--color-border-strong); - background: var(--color-background); - box-shadow: var(--shadow-overlay); - animation: drawer-in 180ms ease-out both; -} - -.app-editor-drawer > header, -.app-editor-drawer > footer { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-4); - border-bottom: 1px solid var(--color-border); - padding: 18px 22px; - background: var(--color-surface); -} - -.app-editor-drawer > footer { - justify-content: flex-end; - border-top: 1px solid var(--color-border); - border-bottom: 0; -} - -.drawer-app-title { - display: flex; - align-items: center; - gap: var(--space-3); -} - -.drawer-app-title > span { - display: grid; -} - -.drawer-app-title small { - color: var(--color-primary); - font-size: 9px; - font-weight: 700; - letter-spacing: 0.1em; -} - -.drawer-app-title h2 { - margin: 2px 0 0; - font-size: 20px; -} - -.drawer-content { - overflow-y: auto; - padding: 22px; -} - -.drawer-section { - display: grid; - gap: var(--space-4); - border-bottom: 1px solid var(--color-border); - padding: 0 0 24px; - margin-bottom: 24px; -} - -.drawer-section:last-child { - border-bottom: 0; -} - -.drawer-section > div:first-child h3 { - margin: 0; - font-size: 15px; -} - -.drawer-section > div:first-child p { - margin: 4px 0 0; - color: var(--color-text-muted); - font-size: 12px; - line-height: 1.5; -} - -.drawer-section.two-column { - grid-template-columns: 1fr 1fr; -} - -.drawer-section.two-column > div:first-child { - grid-column: 1 / -1; -} - -.discovered-value { - display: grid; - gap: 6px; - margin: 0; - border: 1px solid var(--color-border); - border-radius: var(--radius-control); - padding: 12px; - background: var(--color-surface-elevated); -} - -.discovered-value span { - color: var(--color-text-muted); - font-size: 10px; -} - -.discovered-value code { - overflow-wrap: anywhere; - color: var(--color-text); - font-size: 12px; -} - -.danger-button { - min-height: 42px; - display: inline-flex; - align-items: center; - gap: 7px; - border: 1px solid - color-mix(in srgb, var(--color-error) 45%, var(--color-border)); - border-radius: var(--radius-control); - padding: 0 13px; - color: var(--color-error); - background: var(--color-error-soft); - cursor: pointer; -} - -.toast { - border-color: color-mix( - in srgb, - var(--color-success) 45%, - var(--color-border) - ); - border-radius: var(--radius-control); - color: var(--color-text); - background: var(--color-surface); -} - -.mobile-nav { - display: none; -} - -@keyframes drawer-in { - from { - transform: translateX(32px); - opacity: 0; - } - to { - transform: translateX(0); - opacity: 1; - } -} - -@media (max-width: 1120px) { - .dashboard-topbar { - grid-template-columns: auto minmax(260px, 1fr) auto; - } - - .dashboard-topbar nav { - display: none; - } - - .app-grid { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } - - .app-management-row { - grid-template-columns: minmax(220px, 1fr) auto auto; - } - - .app-management-row .category-badge { - display: none; - } -} - -@media (max-width: 800px) { - .dashboard, - .settings-page { - padding-bottom: 84px; - } - - .dashboard-topbar { - height: auto; - grid-template-columns: 1fr auto; - gap: 10px; - padding: 12px 20px; - } - - .topbar-search { - grid-column: 1 / -1; - grid-row: 2; - } - - .dashboard-canvas { - width: min(calc(100% - 40px), var(--content-canon)); - padding-top: 30px; - } - - .dashboard-hero { - grid-template-columns: 1fr; - gap: 18px; - } - - .hero-overview { - max-width: none; - } - - .app-grid, - .favorite-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .settings-topbar { - grid-template-columns: 1fr auto auto; - padding-inline: 20px; - } - - .settings-topbar nav { - display: none; - } - - .settings-layout { - width: min(calc(100% - 40px), 760px); - display: block; - padding: 0; - } - - .settings-sidebar { - border: 0; - padding: 28px 0 0; - } - - .settings-sidebar nav { - display: flex; - overflow-x: auto; - padding-bottom: 8px; - } - - .settings-sidebar nav button { - flex: 0 0 auto; - } - - .settings-content { - padding: 28px 0 50px; - } - - .app-management-row { - grid-template-columns: minmax(190px, 1fr) auto; - } - - .app-management-row > .inline-status { - display: none; - } - - .mobile-nav { - position: fixed; - inset: auto 0 0; - z-index: 70; - height: 72px; - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 0; - border: 0; - border-top: 1px solid var(--color-border); - border-radius: 0; - padding: 6px 10px 8px; - background: color-mix(in srgb, var(--color-background) 95%, transparent); - box-shadow: none; - backdrop-filter: blur(18px); - transform: none; - left: 0; - } - - .mobile-nav button { - width: auto; - height: 56px; - flex-direction: column; - gap: 3px; - border-radius: var(--radius-control); - color: var(--color-text-muted); - font-size: 10px; - } - - .mobile-nav button.active { - color: var(--color-text); - background: var(--color-active); - } -} - -@media (max-width: 560px) { - .dashboard-topbar { - padding-inline: 16px; - } - - .brand-lockup.compact strong { - font-size: 18px; - } - - .dashboard-canvas { - width: calc(100% - 32px); - padding-top: 22px; - } - - .dashboard-hero { - display: none; - } - - .discovery-banner { - width: 100%; - min-width: 0; - min-height: 82px; - margin: 0 0 22px; - padding: 14px 16px; - } - - .discovery-banner strong { - font-size: 16px; - } - - .category-tabs { - margin-inline: -16px; - padding-inline: 16px; - } - - .category-tabs button { - min-width: 76px; - min-height: 54px; - justify-content: center; - border-radius: 14px; - padding-inline: 12px; - } - - .category-tabs button span { - display: none; - } - - .favorite-grid, - .app-grid { - grid-template-columns: 1fr; - } - - .favorite-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .favorite-grid .app-tile { - min-height: 132px; - display: grid; - align-content: space-between; - } - - .favorite-grid .app-tile .tile-copy { - grid-column: 1 / -1; - } - - .app-tile { - min-height: 82px; - grid-template-columns: auto 1fr auto; - grid-template-rows: auto; - align-items: center; - padding: 14px 16px; - } - - .app-tile .tile-copy, - .app-tile .status-dot, - .app-tile .tile-arrow { - grid-row: 1; - } - - .app-tile .tile-copy { - grid-column: 2; - align-self: center; - } - - .app-tile .status-dot { - grid-column: 3; - } - - .app-tile .tile-arrow { - display: none; - } - - .dashboard-insights > summary { - align-items: flex-start; - flex-direction: column; - gap: 4px; - } - - .search-shell.expanded { - padding: 16px; - justify-content: center; - } - - .search-shell.expanded .search-bar { - height: 58px; - } - - .search-results { - max-height: 70vh; - } - - .search-shortcuts { - gap: 8px; - justify-content: space-between; - font-size: 9px; - } - - .settings-topbar { - padding-inline: 16px; - } - - .settings-topbar .back-button { - width: 42px; - overflow: hidden; - padding: 0 11px; - white-space: nowrap; - } - - .settings-layout { - width: calc(100% - 32px); - } - - .settings-heading h2 { - font-size: 30px; - } - - .settings-summary { - display: grid; - grid-template-columns: repeat(3, 1fr); - } - - .settings-summary span { - min-width: 0; - justify-content: center; - overflow: hidden; - padding-inline: 8px; - text-overflow: ellipsis; - white-space: nowrap; - } - - .app-management-row { - display: block; - } - - .app-row-actions { - justify-content: flex-start; - margin-top: 12px; - overflow-x: auto; - } - - .drawer-section.two-column { - grid-template-columns: 1fr; - } - - .drawer-section.two-column > div:first-child { - grid-column: auto; - } - - .app-editor-drawer > footer { - align-items: stretch; - flex-direction: column-reverse; - } - - .app-editor-drawer > footer button { - justify-content: center; - width: 100%; - } -} - -@media (prefers-reduced-motion: reduce) { - html { - scroll-behavior: auto; - } - .app-editor-drawer { - animation: none; - } -} diff --git a/src/client/categoryIcons.ts b/src/client/categoryIcons.ts deleted file mode 100644 index adce5da4..00000000 --- a/src/client/categoryIcons.ts +++ /dev/null @@ -1,79 +0,0 @@ -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) { - const used = new Set(usedIcons); - return ( - categoryIconOptions.find((option) => !used.has(option.id))?.id ?? "Layers3" - ); -} diff --git a/src/client/components/AppTile.tsx b/src/client/components/AppTile.tsx deleted file mode 100644 index a84a41ce..00000000 --- a/src/client/components/AppTile.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { ArrowUpRight, Box, Globe2 } from "lucide-react"; -import type { DockApp, Favorite } from "../../shared/contracts"; -import { effectiveAppUrl, favoriteIconUrl } from "../../shared/domain"; -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", -}: { - app: DockApp; - externalAccess: boolean; - showStatus: boolean; - index: number; - categoryName: string; - variant?: "standard" | "feature"; -}) { - const url = effectiveAppUrl(app, externalAccess); - const content = ( - <> - - - {app.displayName} - {describeService(app, categoryName)} - - {showStatus && ( - - {statusLabel(app.status)} - - )} -
- ); - - return active ? createPortal(searchShell, document.body) : searchShell; -} diff --git a/src/client/components/ServerWidget.tsx b/src/client/components/ServerWidget.tsx deleted file mode 100644 index 08371ba9..00000000 --- a/src/client/components/ServerWidget.tsx +++ /dev/null @@ -1,252 +0,0 @@ -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 ( -
-
- - - - - UNRAID SERVER - {metric ? ( - - {metric.hostName} - - - ) : ( - Wachten op Unraid - )} - - - {metric ? metric.freshness : "Offline"} - -
- {showDetails && ( -
- {metric?.containersRunning ?? 0} - - containers actief - - {metric?.operatingSystem ?? - "Docker-hostinformatie niet beschikbaar"} - - -
- )} - {showStats && ( -
- {isShown("CPU cores") && ( - - - CPU-kernen - {metric?.cpuCount ?? "—"} - - )} - {isShown("Memory capacity") && ( - - - Geheugencapaciteit - {formatMemory(metric?.totalMemoryBytes ?? 0)} - - )} - {isShown("Stopped containers") && ( - - - Gestopte containers - {metric?.containersStopped ?? "—"} - - )} -
- )} - {showNative && metric?.native && ( -
- {isShown("Array state") && ( - - - Arraystatus - {metric.native.arrayState} - - {metric.native.parityErrors - ? `${metric.native.parityErrors} pariteitsfouten` - : metric.native.parityStatus} - - - )} - {isShown("Storage used") && ( - - - Opslag gebruikt - - {metric.native.storage.length - ? formatMemory( - metric.native.storage.reduce( - (total, group) => total + group.usedBytes, - 0, - ), - ) - : "—"} - - {metric.native.storage.length} array-/poolgroepen - - )} - {isShown("Hottest temperature") && ( - - - Hoogste temperatuur - - {metric.native.hottestTemperatureC === null - ? "—" - : `${Math.round(metric.native.hottestTemperatureC)}°C`} - - Schijf- en hostsensoren - - )} - {isShown("UPS battery") && ( - - - UPS-batterij - {metric.native.upsStatus ?? "Niet beschikbaar"} - - {metric.native.upsBatteryPercent === null - ? "Optionele statusbron" - : `${Math.round(metric.native.upsBatteryPercent)}% batterij`} - - - )} -
- )} - {showChart && chartHistory.length > 1 && ( - - )} - {showDetails && ( -
- - {metric?.images ?? 0} images - - - Docker {metric?.dockerVersion ?? "—"} - - - - {metric - ? `Bijgewerkt ${new Date(metric.updatedAt).toLocaleTimeString( - [], - { - hour: "2-digit", - minute: "2-digit", - }, - )}` - : "Wachten op vernieuwing"} - -
- )} - {showProgress && ( -
- -
- )} -
- ); -} diff --git a/src/client/components/ServerWidgetAdvancedConfigurator.tsx b/src/client/components/ServerWidgetAdvancedConfigurator.tsx deleted file mode 100644 index 85e035c9..00000000 --- a/src/client/components/ServerWidgetAdvancedConfigurator.tsx +++ /dev/null @@ -1,306 +0,0 @@ -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 ( -
- - Volledige Unraid-paneelconfiguratie - -
-
- - - Servertrendgrafiek - - Toon geschiedenis van Docker-inventaris, opslag, temperatuur, - pariteit of UPS. - - -
-
- - - - - - -
-
-
- Serverstatistieken - - Kies welke eigen Unraid- en Docker-hostsignalen de kaart mag tonen. - -
- {statMetrics.map((metric) => { - const visible = !preferences.serverWidgetHiddenMetrics.includes( - metric.label, - ); - return ( - - ); - })} -
-
- {ordered.length > 1 && ( -
- {ordered.map((metric, index) => ( - - - {statMetrics.find((item) => item.label === metric)?.display ?? - metric} - - - - - ))} -
- )} -
- ); -} diff --git a/src/client/components/ServiceIcon.tsx b/src/client/components/ServiceIcon.tsx deleted file mode 100644 index 685453d9..00000000 --- a/src/client/components/ServiceIcon.tsx +++ /dev/null @@ -1,173 +0,0 @@ -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 & { 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, - 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(null); - const resolvedIcon = icon ? resolveIconUrl(icon) : null; - if (resolvedIcon && failedIcon !== resolvedIcon) - return ( - setFailedIcon(resolvedIcon)} - /> - ); - const { Icon, brand } = inferredIcon(name, hint); - return ( - - ); -} diff --git a/src/client/components/ServiceWidget.tsx b/src/client/components/ServiceWidget.tsx deleted file mode 100644 index 0a9d7ae3..00000000 --- a/src/client/components/ServiceWidget.tsx +++ /dev/null @@ -1,569 +0,0 @@ -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 "Opstarten"; - 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, - providerState: IntegrationState | undefined, -) { - const waiting = - providerState?.state === "degraded" ? "Niet beschikbaar" : "Niet gekoppeld"; - const stats: TailoredStat[] = capability.metrics.map(({ label }) => { - if (/^status$/i.test(label)) - return { - label, - value: app.status, - tone: app.status === "online" ? "positive" : "warning", - }; - if (/^cpu$|cpu load/i.test(label)) - return { - label, - value: - metric?.cpuPercent == null ? "—" : `${metric.cpuPercent.toFixed(1)}%`, - numericValue: metric?.cpuPercent ?? undefined, - icon: Cpu, - }; - if (/^memory$|working memory/i.test(label)) - return { - label, - value: formatBytes(metric?.memoryUsageBytes ?? null), - numericValue: metric?.memoryUsageBytes ?? undefined, - icon: MemoryStick, - }; - if (/ingress|download speed/i.test(label)) - return { - label, - value: formatRate(metric?.networkRxBytesPerSecond), - numericValue: metric?.networkRxBytesPerSecond ?? undefined, - icon: ArrowDown, - }; - if (/egress|upload speed/i.test(label)) - return { - label, - value: formatRate(metric?.networkTxBytesPerSecond), - numericValue: metric?.networkTxBytesPerSecond ?? undefined, - icon: ArrowUp, - }; - return { label, value: waiting, icon: Activity }; - }); - return { - summary: - app.status === "online" - ? `${capability.label} is beschikbaar` - : `${capability.label} vraagt aandacht`, - detail: - providerState?.state === "degraded" - ? "Het ingestelde alleen-lezen endpoint voor metingen is momenteel onbereikbaar" - : "Koppel het alleen-lezen endpoint van de app om gespecialiseerde statistieken te vullen", - 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") { - const apiLocked = providerState?.state === "disabled"; - return { - summary: apiLocked - ? "DNS-inzichten vereisen API-toegang" - : "DNS-bescherming is online", - detail: apiLocked - ? "Statistieken zijn vergrendeld · stel in bij Integraties" - : (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, providerState) - : 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 = ( - <> -
- - - - {app.widgetCustomLabel || metric?.provider?.label || details.label} - - {app.displayName} - - {freshness} - -
- {app.widgetShowSummary && ( -
- - {metric?.provider?.summary ?? fallback.summary} - - {metric?.provider - ? "Provider-API · alleen-lezen live data" - : fallback.detail} - -
- )} - {app.widgetShowMetrics && ( -
- {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 ( - - {StatIcon && } - {stat.label} - {stat.value} - {presentation !== "value" && presentationValue !== null && ( - - - - )} - - ); - })} -
- )} - {app.widgetShowSparkline && chartHistory.length > 1 && ( - - )} - {app.widgetShowSummary && - providerState?.state === "disabled" && - !metric?.provider && ( -
- App-API vergrendeld · Docker-telemetrie - blijft live -
- )} - {app.widgetShowInfrastructure && ( -
- - {" "} - {metric?.cpuPercent == null - ? "—" - : `${metric.cpuPercent.toFixed(1)}%`} - - - {" "} - {formatBytes(metric?.memoryUsageBytes ?? null)} - - - {formatRate(metric?.networkRxBytesPerSecond)} - - - {metric?.pids ?? "—"} PIDs - - - - {formatUpdatedAt(metric?.provider?.updatedAt ?? metric?.updatedAt)} - -
- )} - {app.widgetShowProgress && ( -
- -
- )} - {url && } - - ); - return url ? ( - - {body} - - ) : ( -
- {body} -
- ); -} diff --git a/src/client/components/ServiceWidgets.tsx b/src/client/components/ServiceWidgets.tsx deleted file mode 100644 index 7bd03205..00000000 --- a/src/client/components/ServiceWidgets.tsx +++ /dev/null @@ -1,215 +0,0 @@ -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; -} - -export function ServiceWidgets({ data, syncing, onSync }: ServiceWidgetsProps) { - const [editing, setEditing] = useState(false); - const [saving, setSaving] = useState(false); - const [editError, setEditError] = useState(null); - const apps = data.apps - .filter((app) => app.widgetEnabled) - .sort( - (left, right) => - left.widgetOrder - right.widgetOrder || - left.displayName.localeCompare(right.displayName), - ) - .slice(0, 8); - 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) => { - 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 ( -
-
-
-

LIVE UIT JE DOCKER-OMGEVING

-

- Servicepanelen -

-
-
- - -
-
- {editError && ( -

- {editError} -

- )} -
- {items.map((item, index) => { - const app = - item.kind === "app" - ? apps.find((candidate) => candidate.id === item.id) - : undefined; - return ( -
- {editing && ( - 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" ? ( - - ) : app ? ( - capability.appId === app.id, - )} - providerState={data.integrations.find( - (integration) => - integration.id === `widget-${app.technicalName}`, - )} - externalAccess={data.preferences.externalAccess} - index={index} - /> - ) : null} -
- ); - })} -
-
- ); -} diff --git a/src/client/components/Settings.tsx b/src/client/components/Settings.tsx deleted file mode 100644 index 79cad8b3..00000000 --- a/src/client/components/Settings.tsx +++ /dev/null @@ -1,2090 +0,0 @@ -import { - ArrowDownToLine, - Activity, - ArrowUpRight, - Boxes, - DatabaseBackup, - Eye, - EyeOff, - FolderKanban, - Globe2, - KeyRound, - Link2, - PanelsTopLeft, - Palette, - Plus, - RefreshCw, - Save, - Search, - ServerCog, - Settings2, - Settings as SettingsIcon, - Sparkles, - Star, - Trash2, - Upload, - X, -} from "lucide-react"; -import { - type FormEvent, - type DragEvent, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import type { - DashboardPayload, - DockApp, - IntegrationState, -} from "../../shared/contracts"; -import { api } from "../api"; -import { AppearanceSettings } from "./AppearanceSettings"; -import { - categoryIconOptions, - nextAvailableCategoryIcon, -} from "../categoryIcons"; -import { favoriteIconUrl } from "../../shared/domain"; -import { ServiceIcon } from "./ServiceIcon"; -import { WidgetConfigurator } from "./WidgetConfigurator"; -import { WidgetAdvancedConfigurator } from "./WidgetAdvancedConfigurator"; -import { ServerWidgetAdvancedConfigurator } from "./ServerWidgetAdvancedConfigurator"; -import { WidgetPresetManager } from "./WidgetPresetManager"; -import { OperationsSettings } from "./OperationsSettings"; -import { Brand } from "./Brand"; -import { CategoryIcon } from "./CategoryIcon"; -import { WeatherBadge } from "./WeatherBadge"; - -export type SettingsSection = - | "general" - | "apps" - | "favorites" - | "categories" - | "appearance" - | "integrations" - | "backup"; - -const sections: { id: SettingsSection; label: string; icon: typeof Boxes }[] = [ - { id: "general", label: "Algemeen", icon: Settings2 }, - { id: "apps", label: "Apps", icon: Boxes }, - { id: "favorites", label: "Favorieten", icon: Star }, - { id: "categories", label: "Categorieën", icon: FolderKanban }, - { id: "appearance", label: "Weergave", icon: Palette }, - { id: "integrations", label: "Integraties", icon: ServerCog }, - { id: "backup", label: "Back-up en herstel", icon: DatabaseBackup }, -]; - -const sectionSearchTerms: Record = { - general: - "algemeen dashboard panelen widgets server unraid status diagnostiek presets volgorde", - apps: "apps services containers zichtbaar verborgen url icoon categorie bewerken", - favorites: "favorieten snelle links websites favicon url categorie volgorde", - categories: "categorieën groepen organiseren icoon volgorde apps favorieten", - appearance: - "weergave uiterlijk thema donker licht haven accent dichtheid modus achtergrond beweging verversen interval status", - integrations: - "integraties docker unraid nginx proxy manager api credentials inloggegevens extern alleen-lezen", - backup: - "back-up herstel backup export import json sqlite configuratie downloaden uploaden", -}; - -interface Props { - data: DashboardPayload; - initialSection?: SettingsSection; - onBack: () => void; - syncing: boolean; - onSync: () => Promise; - onChanged: () => Promise; - notify: (message: string) => void; -} - -export function Settings({ - data, - initialSection = "apps", - onBack, - syncing, - onSync, - onChanged, - notify, -}: Props) { - const [section, setSection] = useState(initialSection); - const [saving, setSaving] = useState(false); - const [settingsQuery, setSettingsQuery] = useState(""); - const settingsNavRef = useRef(null); - const normalizedSettingsQuery = settingsQuery.trim().toLocaleLowerCase("nl"); - const filteredSections = useMemo( - () => - normalizedSettingsQuery - ? sections.filter((item) => - `${item.label} ${sectionSearchTerms[item.id]}` - .toLocaleLowerCase("nl") - .includes(normalizedSettingsQuery), - ) - : sections, - [normalizedSettingsQuery], - ); - - useEffect(() => { - if ( - normalizedSettingsQuery && - filteredSections.length > 0 && - !filteredSections.some((item) => item.id === section) - ) { - setSection(filteredSections[0]!.id); - } - }, [filteredSections, normalizedSettingsQuery, section]); - - useEffect(() => { - window.scrollTo({ top: 0, behavior: "auto" }); - if (window.matchMedia("(max-width: 900px)").matches) { - settingsNavRef.current - ?.querySelector(`[data-settings-section="${section}"]`) - ?.scrollIntoView({ block: "nearest", inline: "center" }); - } - }, [section]); - - const perform = async (action: () => Promise, message: string) => { - setSaving(true); - try { - await action(); - await onChanged(); - notify(message); - return true; - } catch (error) { - notify( - error instanceof Error - ? error.message - : "DockDeck kon deze wijziging niet opslaan.", - ); - return false; - } finally { - setSaving(false); - } - }; - - return ( -
-
-
- -
-
- - setSettingsQuery(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Escape") setSettingsQuery(""); - }} - /> - {settingsQuery && ( - - )} -
-
- -
- - - Opslaan - - - -
-
-
-
- -
- {section === "general" && ( - - )} - {section === "apps" && } - {section === "favorites" && ( - - )} - {section === "categories" && ( - - )} - {section === "appearance" && ( - - )} - {section === "integrations" && ( - - )} - {section === "backup" && } -
-
-
- ); -} - -function SectionHeader({ - eyebrow, - title, - copy, -}: { - eyebrow: string; - title: string; - copy: string; -}) { - return ( -
-

{eyebrow}

-

{title}

-

{copy}

-
- ); -} - -type Performer = ( - action: () => Promise, - message: string, -) => Promise; - -function GeneralSettings({ - data, - perform, -}: { - data: DashboardPayload; - perform: Performer; -}) { - const [view, setView] = useState<"panels" | "status">("panels"); - return ( - <> - -
- - -
- {view === "panels" ? ( - - ) : ( - - )} - - ); -} - -function appWidgetSections(app: DockApp) { - return [ - { - id: "summary", - label: "Live samenvatting", - description: "Huidige status en providercontext", - enabled: app.widgetShowSummary, - }, - { - id: "metrics", - label: "Servicemetingen", - description: "Belangrijkste waarden van provider of app", - enabled: app.widgetShowMetrics, - }, - { - id: "infrastructure", - label: "Docker-telemetrie", - description: "CPU, geheugen, verkeer en aantal processen", - enabled: app.widgetShowInfrastructure, - }, - { - id: "progress", - label: "Geheugenbalk", - description: "Werkgeheugen tegenover containerlimiet", - enabled: app.widgetShowProgress, - }, - ]; -} - -function AppsSettings({ - data, - perform, -}: { - data: DashboardPayload; - perform: Performer; -}) { - const [query, setQuery] = useState(""); - const [editingId, setEditingId] = useState(null); - const [page, setPage] = useState(1); - const returnFocusRef = useRef(null); - const [filter, setFilter] = useState<"all" | "visible" | "hidden" | "new">( - "all", - ); - const filteredApps = useMemo(() => { - const normalizedQuery = query.trim().toLowerCase(); - return data.apps.filter((app) => { - const matchesQuery = - !normalizedQuery || - `${app.displayName} ${app.technicalName} ${app.image ?? ""}` - .toLowerCase() - .includes(normalizedQuery); - const matchesFilter = - filter === "all" || - (filter === "visible" - ? app.visible - : filter === "hidden" - ? !app.visible - : app.isNew); - return matchesQuery && matchesFilter; - }); - }, [data.apps, filter, query]); - const pageSize = 20; - const pageCount = Math.max(1, Math.ceil(filteredApps.length / pageSize)); - const currentPage = Math.min(page, pageCount); - const visibleApps = filteredApps.slice( - (currentPage - 1) * pageSize, - currentPage * pageSize, - ); - const editingApp = data.apps.find((app) => app.id === editingId) ?? null; - const closeEditor = useCallback(() => { - setEditingId(null); - window.setTimeout(() => returnFocusRef.current?.focus(), 0); - }, []); - - return ( - <> - -
- - {data.apps.length} ontdekt - - - {data.apps.filter((app) => app.visible).length}{" "} - zichtbaar - - - {" "} - {data.apps.filter((app) => app.widgetEnabled).length} panelen - -
-
- -
- {(["all", "visible", "hidden", "new"] as const).map((option) => ( - - ))} -
-
-

- {filteredApps.length} van {data.apps.length} apps -

- -
- {filteredApps.length === 0 && ( -
- - Geen passende apps - Probeer een andere naam of zichtbaarheidsfilter. -
- )} - {visibleApps.map((app) => ( -
-
- - - {app.displayName} - - {app.technicalName} - {app.isNew && Nieuw} - - -
- - - {app.status === "online" - ? "Online" - : app.status === "offline" - ? "Offline" - : "Status onbekend"} - - - {data.categories.find( - (category) => category.id === app.categoryId, - )?.name ?? "Geen categorie"} - -
- - -
-
- ))} -
- {pageCount > 1 && ( - - )} - {editingApp && ( - - )} - - ); -} - -function AppEditorDrawer({ - app, - categories, - perform, - onClose, -}: { - app: DockApp; - categories: DashboardPayload["categories"]; - perform: Performer; - onClose: () => void; -}) { - const drawerRef = useRef(null); - - useEffect(() => { - const drawer = drawerRef.current; - if (!drawer) return; - const focusable = Array.from( - drawer.querySelectorAll( - 'button, input, select, [href], [tabindex]:not([tabindex="-1"])', - ), - ); - focusable[0]?.focus(); - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") onClose(); - if (event.key !== "Tab" || focusable.length === 0) return; - const first = focusable[0]!; - const last = focusable[focusable.length - 1]!; - if (event.shiftKey && document.activeElement === first) { - event.preventDefault(); - last.focus(); - } else if (!event.shiftKey && document.activeElement === last) { - event.preventDefault(); - first.focus(); - } - }; - document.addEventListener("keydown", onKeyDown); - return () => document.removeEventListener("keydown", onKeyDown); - }, [onClose]); - - return ( -
- - - -
-
-
-

Algemene informatie

-

De naam en het icoon waarmee je deze app herkent.

-
- - -
- -
-
-

Zichtbaarheid en categorie

-

- Nieuwe apps blijven verborgen tot je ze zelf zichtbaar maakt. -

-
- - - -
- -
-
-

Lokale URL

-

- De automatisch ontdekte waarde blijft actief totdat je deze - overschrijft. -

-
-

- Automatisch ontdekt - {app.localUrl ?? "Niet beschikbaar"} -

- -
- -
-
-

Externe URL

-

- Wordt alleen gebruikt wanneer externe toegang is ingeschakeld. -

-
- -
-
- -
- - -
- -
- ); -} - -function FavoritesSettings({ - data, - perform, -}: { - data: DashboardPayload; - perform: Performer; -}) { - const [adding, setAdding] = useState(false); - const submit = (event: FormEvent) => { - event.preventDefault(); - const form = new FormData(event.currentTarget); - perform( - () => - api.createFavorite({ - name: String(form.get("name")), - url: String(form.get("url")), - icon: String(form.get("icon")).trim() || null, - categoryId: String(form.get("categoryId")) || null, - sortOrder: data.favorites.length * 10, - }), - "Favoriet toegevoegd", - ).then((saved) => saved && setAdding(false)); - }; - return ( - <> - - - {adding && ( -
- - - - - -
- )} -
- {data.favorites.length === 0 && ( -
- - Nog geen favorieten -
- Voeg een externe website toe om die vanaf je dashboard te zoeken. -
-
- )} - {data.favorites.map((favorite) => ( -
-
- - - {favorite.name} - {favorite.url} - - - - Website openen - -
-
- Bewerken -
- - - - - -
-
-
- ))} -
- - ); -} - -function CategoriesSettings({ - data, - perform, -}: { - data: DashboardPayload; - perform: Performer; -}) { - const itemCount = (categoryId: string) => - data.apps.filter((app) => app.categoryId === categoryId).length + - data.favorites.filter((favorite) => favorite.categoryId === categoryId) - .length; - const submit = (event: FormEvent) => { - event.preventDefault(); - const form = event.currentTarget; - const formData = new FormData(form); - perform( - () => - api.createCategory({ - name: String(formData.get("name")), - icon: nextAvailableCategoryIcon( - data.categories.map((category) => category.icon), - ), - sortOrder: data.categories.length * 10, - }), - "Categorie aangemaakt", - ).then((saved) => saved && form.reset()); - }; - return ( - <> - -
- - - Slim indelen op servicetype - - Herkent onder meer media, downloads, monitoring, databases en - beheerapps. Onbekende containers en favorieten blijven ongewijzigd. - - - -
-
- - -
-
- {data.categories.map((category, index) => ( -
- - - {itemCount(category.id)}{" "} - {itemCount(category.id) === 1 ? "item" : "items"} - - - - - - - {String(index + 1).padStart(2, "0")} - -
- ))} -
- - ); -} - -function IntegrationsSettings({ - data, - perform, -}: { - data: DashboardPayload; - perform: Performer; -}) { - const activeWidgetNames = new Set( - data.apps - .filter((app) => app.widgetEnabled) - .map((app) => app.technicalName), - ); - const relevantIntegrations = data.integrations.filter( - (integration) => - !integration.id.startsWith("widget-") || - activeWidgetNames.has(integration.id.slice("widget-".length)), - ); - - return ( - <> - -
- - - Ontworpen als alleen-lezen - - De DockDeck-container krijgt nooit rechtstreeks toegang tot de - Docker-socket. De bestaande AppOps-inventaris wordt via - GET-verzoeken gelezen; een geïsoleerde, beperkte proxy blijft alleen - beschikbaar als terugvaloptie. - - -
-
- Actieve koppelingen - - App-specifieke koppelingen verschijnen zodra je hun paneel activeert - in Algemeen → Dashboardpanelen. - -
-
- {relevantIntegrations.map((integration) => ( -
- - - - - {integration.label} - {integration.message} - {integration.requiredEnvironment && - integration.state !== "connected" && ( - - {integration.requiredEnvironment.map((key) => ( - {key} - ))} - - )} - - - - {integration.state === "connected" - ? "verbonden" - : integration.state === "degraded" - ? "beperkt" - : "uitgeschakeld"} - -
- ))} -
-
- - Externe URL's verkiezen - - Gebruik gekoppelde Nginx Proxy Manager-hosts wanneer die beschikbaar - zijn. Lokale URL's blijven de terugvaloptie. - - - -
- - ); -} - -function WidgetsSettings({ - data, - perform, -}: { - data: DashboardPayload; - perform: Performer; -}) { - const serverSections = [ - { - id: "stats", - label: "Capaciteitsmetingen", - description: "CPU-kernen, geheugen en gestopte containers", - enabled: data.preferences.serverWidgetShowStats, - }, - { - id: "details", - label: "Hostdetails", - description: "Actief totaal, besturingssysteem, images en Docker-versie", - enabled: data.preferences.serverWidgetShowDetails, - }, - { - id: "progress", - label: "Statusbalk", - description: "Verhouding actieve containers in één oogopslag", - enabled: data.preferences.serverWidgetShowProgress, - }, - { - id: "native", - label: "Unraid-arraydetails", - description: "Array, pool, parity, temperatuur en optionele UPS-status", - enabled: data.preferences.serverWidgetShowNative, - }, - ]; - return ( - <> - -
-
- - - - - Unraid server - Hostcapaciteit, runtimestatus en Docker-inventaris - - -
-
- - -
- {data.preferences.showServerWidget && ( - <> - - void perform( - () => api.updatePreferences({ serverWidgetSize }), - `Unraid-paneel ingesteld op ${serverWidgetSize}`, - ) - } - onSectionChange={(id, enabled) => { - const patch = - id === "stats" - ? { serverWidgetShowStats: enabled } - : id === "details" - ? { serverWidgetShowDetails: enabled } - : id === "progress" - ? { serverWidgetShowProgress: enabled } - : { serverWidgetShowNative: enabled }; - void perform( - () => api.updatePreferences(patch), - "Inhoud van Unraid-paneel bijgewerkt", - ); - }} - /> - - void perform(() => api.updatePreferences(patch), message) - } - /> - - )} - -
- - - - ); -} - -function WidgetStudio({ - data, - perform, -}: { - data: DashboardPayload; - perform: Performer; -}) { - const [filter, setFilter] = useState<"recommended" | "all" | "active">( - "recommended", - ); - const [query, setQuery] = useState(""); - const [visibleCount, setVisibleCount] = useState(12); - const activeCount = data.apps.filter((app) => app.widgetEnabled).length; - const appsById = useMemo( - () => new Map(data.apps.map((app) => [app.id, app])), - [data.apps], - ); - const capabilities = useMemo(() => { - const needle = query.trim().toLowerCase(); - return data.widgetCapabilities.filter((capability) => { - const app = appsById.get(capability.appId); - if (!app) return false; - const matchesFilter = - filter === "all" || - (filter === "active" - ? app.widgetEnabled - : capability.recommended || app.widgetEnabled); - const matchesQuery = - !needle || - `${capability.label} ${capability.description} ${capability.features.join(" ")}` - .toLowerCase() - .includes(needle); - return matchesFilter && matchesQuery; - }); - }, [appsById, data.widgetCapabilities, filter, query]); - const shownCapabilities = capabilities.slice(0, visibleCount); - - return ( -
-
- - - - - Paneelstudio - - Kies maximaal acht panelen en stel daarna hun grootte en - informatielagen in. Uitgebreide providers worden automatisch benut - zodra ze verbonden zijn. - - - {activeCount} / 8 actief -
-
- Alleen-lezen, volledig aanpasbaar - - DockDeck gebruikt automatisch beschikbare servicegegevens en vult die - veilig aan met Dockertelemetrie. Na activatie verschijnen alle - inhoudsopties rechtstreeks onder het gekozen paneel. - -
-
    -
  1. - 1 - Kies een service - Zoek of filter in de catalogus. -
  2. -
  3. - 2 - Voeg het paneel toe - Maximaal acht tegelijk. -
  4. -
  5. - 3 - Stem de inhoud af - Kies grootte, metingen en grafiek. -
  6. -
-
- -
- {(["recommended", "all", "active"] as const).map((option) => ( - - ))} -
-
-
- {shownCapabilities.map((capability, index) => { - const app = appsById.get(capability.appId); - if (!app) return null; - const integration = data.integrations.find( - (item) => item.id === `widget-${app.technicalName}`, - ); - const unavailable = !app.widgetEnabled && activeCount >= 8; - return ( -
-
- - - {capability.label} - {app.displayName} - - - {capability.source === "provider" - ? "Rich API" - : capability.source === "custom-get" - ? "GET + Docker" - : "Docker+"} - -
-

{capability.description}

-
- {capability.features.map((feature) => ( - {feature} - ))} -
-
- - {capability.credentialMode === "none" ? ( - <> - Directe data - - ) : capability.credentialMode === "optional" ? ( - <> - Optioneel data-endpoint - - ) : ( - <> - API-inloggegeven - - )} - - -
- {app.widgetEnabled && ( - <> - - void perform( - () => api.updateApp(app.id, { widgetSize }), - `${app.displayName}-paneel ingesteld op ${widgetSize}`, - ) - } - onSectionChange={(id, enabled) => { - const patch = - id === "summary" - ? { widgetShowSummary: enabled } - : id === "metrics" - ? { widgetShowMetrics: enabled } - : id === "infrastructure" - ? { widgetShowInfrastructure: enabled } - : { widgetShowProgress: enabled }; - void perform( - () => api.updateApp(app.id, patch), - `Inhoud van ${app.displayName}-paneel bijgewerkt`, - ); - }} - /> - - capability.metrics.some( - (metric) => metric.label === stat.label, - ) - ? [] - : [ - { - label: stat.label, - description: `Live ${stat.label.toLowerCase()} uit de gekoppelde app-API`, - group: "activity" as const, - defaultVisible: true, - defaultStyle: "value" as const, - chartable: stat.numericValue !== undefined, - unit: "count" as const, - }, - ], - ) ?? []), - ]} - onChange={(patch, message) => - void perform(() => api.updateApp(app.id, patch), message) - } - /> - {integration && - (integration.credentialFields?.length ?? 0) > 0 && ( -
- App-datakoppeling - - Koppel de gedocumenteerde API of een alleen-lezen - JSON-endpoint voor metingen. Secrets blijven op de - server. - - -
- )} - - )} -
- ); - })} -
- {shownCapabilities.length < capabilities.length && ( - - )} - {capabilities.length === 0 && ( -
- - Geen passend paneel - Probeer een andere service of mogelijkheid. -
- )} -
- ); -} - -function IntegrationCredentials({ - integration, - perform, -}: { - integration: IntegrationState; - perform: Performer; -}) { - const [values, setValues] = useState>({}); - const [editing, setEditing] = useState(false); - const required = new Set(integration.requiredEnvironment ?? []); - const fields = - integration.state === "disabled" - ? (integration.credentialFields ?? []) - : integration.state === "degraded" || editing - ? (integration.credentialFields ?? []) - : []; - if (fields.length === 0) - return integration.state === "connected" && - (integration.credentialFields?.length ?? 0) > 0 ? ( - - ) : null; - - const submit = async (event: FormEvent) => { - event.preventDefault(); - const payload = Object.fromEntries( - fields.flatMap((field) => { - if (!Object.hasOwn(values, field.key)) return []; - return [[field.key, values[field.key]?.trim() ?? ""]]; - }), - ); - const saved = await perform( - () => api.updateIntegrationCredentials(integration.id, payload), - `Inloggegevens voor ${integration.label} opgeslagen`, - ); - if (saved) { - setValues({}); - setEditing(false); - } - }; - - return ( -
void submit(event)}> - - - - Provider-API koppelen - - Wordt op de server opgeslagen en daarna niet meer getoond. Gebruik - een vertrouwd LAN of HTTPS. - - - -
- {fields.map((field) => ( - - ))} -
- -
- ); -} - -function BackupSettings({ perform }: { perform: Performer }) { - const inputRef = useRef(null); - const [dragging, setDragging] = useState(false); - const [lastImportedFile, setLastImportedFile] = useState(null); - const importFile = async (file: File | undefined) => { - if (!file) return; - if (file.size > 10 * 1024 * 1024) { - await perform( - () => - Promise.reject( - new Error("De back-up mag maximaal 10 MB groot zijn."), - ), - "", - ); - return; - } - let payload: unknown; - try { - payload = JSON.parse(await file.text()) as unknown; - } catch { - await perform( - () => Promise.reject(new Error("Dit bestand bevat geen geldige JSON.")), - "", - ); - return; - } - if ( - !window.confirm( - "Deze back-up vervangt je huidige categorieën, favorieten en instellingen. Doorgaan?", - ) - ) - return; - const restored = await perform( - () => api.importBackup(payload), - "Back-up hersteld", - ); - if (restored) setLastImportedFile(file.name); - }; - const dropFile = (event: DragEvent) => { - event.preventDefault(); - setDragging(false); - void importFile(event.dataTransfer.files[0]); - }; - return ( - <> - -
-
- - - -

Configuratie exporteren

-

- Download categorieën, aangepaste app-instellingen, favorieten en - vormgevingsvoorkeuren. -

- - Back-up downloaden - -
-
- void importFile(event.target.files?.[0])} - /> - -

- DockDeck valideert de back-up voor herstel. Gevonden runtimegegevens - en integratiesecrets blijven intact. -

- {lastImportedFile && ( - - Laatst hersteld: {lastImportedFile} - - )} -
-
-
- - - Lokaal opgeslagen in SQLite - - Maak een back-up van de gekoppelde map /data voor - volledig herstel na een storing. - - -
- - ); -} diff --git a/src/client/components/WeatherBadge.tsx b/src/client/components/WeatherBadge.tsx deleted file mode 100644 index a62e0e36..00000000 --- a/src/client/components/WeatherBadge.tsx +++ /dev/null @@ -1,74 +0,0 @@ -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(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 ( - - - - {weather ? `${Math.round(weather.temperatureC)}°` : "--°"} - - {weather?.location ?? location} - - ); -} diff --git a/src/client/components/WidgetAdvancedConfigurator.tsx b/src/client/components/WidgetAdvancedConfigurator.tsx deleted file mode 100644 index 455048a1..00000000 --- a/src/client/components/WidgetAdvancedConfigurator.tsx +++ /dev/null @@ -1,466 +0,0 @@ -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 ( -
- - Volledige paneelconfiguratie - -
- - - - - -
- -
-
- - - Trendgrafiek - - Kies een echte reeks, grafiekstijl en geheugenvenster. - - -
-
- - - - - -
-
- -
-
- - - Visuele waarschuwing - - Markeer de kaart wanneer een gekozen numeriek signaal een - drempelwaarde overschrijdt. - - -
-
- - - -
-
- -
- Appspecifieke metingen - - Kies elke statistiek en of die als waarde, meter of voortgangsbalk - verschijnt. - -
- {availableMetrics.map((metric) => { - const visible = !app.widgetHiddenMetrics.includes(metric.label); - const style = - app.widgetMetricStyles[metric.label] ?? metric.defaultStyle; - return ( -
- - -
- ); - })} -
-
- {orderedMetrics.length > 1 && ( -
- {orderedMetrics.map((metric, index) => ( - - {metric} - - - - ))} -
- )} -
- ); -} diff --git a/src/client/components/WidgetConfigurator.tsx b/src/client/components/WidgetConfigurator.tsx deleted file mode 100644 index dd645503..00000000 --- a/src/client/components/WidgetConfigurator.tsx +++ /dev/null @@ -1,88 +0,0 @@ -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 ( -
-
- - Paneelformaat - - - {sections.filter((section) => section.enabled).length} content blocks - -
-
- {sizes.map((option) => ( - - ))} -
-
- - Zichtbare inhoud - - {sections.map((section) => ( - - ))} -
-
- ); -} diff --git a/src/client/components/WidgetEditToolbar.tsx b/src/client/components/WidgetEditToolbar.tsx deleted file mode 100644 index dcacc6dd..00000000 --- a/src/client/components/WidgetEditToolbar.tsx +++ /dev/null @@ -1,76 +0,0 @@ -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 ( -
- - - - -
- ); -} diff --git a/src/client/components/WidgetPresetManager.tsx b/src/client/components/WidgetPresetManager.tsx deleted file mode 100644 index 4cbd4eb4..00000000 --- a/src/client/components/WidgetPresetManager.tsx +++ /dev/null @@ -1,140 +0,0 @@ -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, - message: string, -) => Promise; - -interface WidgetPresetManagerProps { - presets: LayoutPreset[]; - perform: Performer; -} - -export function WidgetPresetManager({ - presets, - perform, -}: WidgetPresetManagerProps) { - const fileRef = useRef(null); - const create = (event: FormEvent) => { - 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) => { - 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 ( -
-
- - - - - Indelingspresets - - Wissel in één handeling tussen volledige paneelindelingen. - - -
-
- - - - void importPreset(event)} - /> -
-
- {presets.length === 0 && ( -

- Nog geen presets. Sla de huidige indeling op om je eerste te maken. -

- )} - {presets.map((preset) => ( -
- - {preset.name} - - {preset.configuration.apps.length} app-panelen · bijgewerkt{" "} - {new Date(preset.updatedAt).toLocaleDateString()} - - - - - Exporteren - - -
- ))} -
-
- ); -} diff --git a/src/client/components/WidgetSparkline.tsx b/src/client/components/WidgetSparkline.tsx deleted file mode 100644 index ecb1375a..00000000 --- a/src/client/components/WidgetSparkline.tsx +++ /dev/null @@ -1,80 +0,0 @@ -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 ( -
- - {showLegend && ( -
- {label} - - {values.at(-1)?.toLocaleString(undefined, { - maximumFractionDigits: 1, - })}{" "} - · {values.length} samples - -
- )} -
- ); -} diff --git a/src/client/design-tokens.css b/src/client/design-tokens.css deleted file mode 100644 index 50a6b157..00000000 --- a/src/client/design-tokens.css +++ /dev/null @@ -1,138 +0,0 @@ -: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)); -} diff --git a/src/client/main.tsx b/src/client/main.tsx deleted file mode 100644 index a45defe5..00000000 --- a/src/client/main.tsx +++ /dev/null @@ -1,21 +0,0 @@ -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( - - - , -); - -if (import.meta.env.PROD && "serviceWorker" in navigator) { - window.addEventListener("load", () => { - void navigator.serviceWorker.register("/sw.js"); - }); -} diff --git a/src/client/stitch-direct.css b/src/client/stitch-direct.css deleted file mode 100644 index 690307f6..00000000 --- a/src/client/stitch-direct.css +++ /dev/null @@ -1,5715 +0,0 @@ -/* - * DockDeck Canon v1 — direct Stitch composition layer. - * This is intentionally the final presentation layer: existing product - * components and contracts remain intact, while their hierarchy and density - * follow the selected canon_* exports as closely as real data allows. - */ - -:root { - --stitch-bg: #f8f9fd; - --stitch-surface-lowest: var(--stitch-bg); - --stitch-surface: #ffffff; - --stitch-surface-low: #f1f3f9; - --stitch-surface-high: #e7eaf2; - --stitch-surface-highest: #dde1eb; - --stitch-border: #d7dbe5; - --stitch-border-strong: #b9bfcd; - --stitch-text: #161b2c; - --stitch-text-muted: #59647a; - --stitch-primary: #6f78ee; - --stitch-primary-soft: #e3e6ff; - --stitch-on-primary: #10163c; - --stitch-success: #00a572; - --stitch-success-bright: #2edca4; - --stitch-error: #c43f45; - --stitch-unknown: #8190a8; - --stitch-canvas: 1384px; -} - -:root[data-theme="dark"] { - --stitch-bg: #0b1326; - --stitch-surface: #10192d; - --stitch-surface-low: #131b2e; - --stitch-surface-high: #222a3e; - --stitch-surface-highest: #2d3449; - --stitch-border: #252f46; - --stitch-border-strong: #454653; - --stitch-text: #dbe2fd; - --stitch-text-muted: #bfc6dc; - --stitch-primary: #818cf8; - --stitch-primary-soft: #29335f; - --stitch-on-primary: #0c1748; - --stitch-success: #00a572; - --stitch-success-bright: #4edea3; - --stitch-error: #ff9a9a; - --stitch-unknown: #929bb3; -} - -:root[data-theme="harbor"] { - --stitch-bg: #071723; - --stitch-surface: #0d2030; - --stitch-surface-low: #102738; - --stitch-surface-high: #173246; - --stitch-surface-highest: #223e52; - --stitch-border: #27485b; - --stitch-border-strong: #3e6475; - --stitch-text: #e3f3f7; - --stitch-text-muted: #a9c4cb; -} - -html, -body, -#root, -.app-shell { - min-height: 100%; - background: var(--stitch-bg); - color: var(--stitch-text); -} - -body { - overflow-x: hidden; - font-family: "Geist Variable", Geist, ui-sans-serif, system-ui, sans-serif; -} - -/* Dashboard top app bar */ -.dashboard-topbar { - position: sticky; - top: 0; - z-index: 50; - width: 100%; - height: 64px; - display: block; - border-bottom: 1px solid var(--stitch-border-strong); - padding: 0; - background: color-mix(in srgb, var(--stitch-bg) 96%, transparent); - backdrop-filter: blur(14px); -} - -.dashboard-topbar-inner { - width: min(calc(100% - 48px), var(--stitch-canvas)); - height: 64px; - margin: 0 auto; - display: grid; - grid-template-columns: minmax(190px, 1fr) minmax(320px, 560px) auto auto; - align-items: center; - gap: 32px; -} - -.dashboard-topbar .brand-lockup { - gap: 10px; -} - -.dashboard-topbar .brand-lockup.compact img { - width: 32px; - height: 32px; - flex-basis: 32px; -} - -.dashboard-topbar .brand-lockup.compact strong { - color: var(--stitch-text); - font-size: 20px; - font-weight: 700; - letter-spacing: -0.035em; -} - -.dashboard-topbar nav { - justify-self: end; - gap: 24px; -} - -.dashboard-topbar nav button { - min-height: 34px; - border: 0; - border-bottom: 2px solid transparent; - padding: 0 0 3px; - color: var(--stitch-text-muted); - background: transparent; - font-size: 12px; - font-weight: 650; - letter-spacing: 0.05em; -} - -.dashboard-topbar nav button.active, -.dashboard-topbar nav button:hover { - border-bottom-color: var(--stitch-primary); - color: var(--stitch-text); -} - -.topbar-actions { - gap: 4px; -} - -.dashboard-topbar .icon-button { - position: relative; - width: 40px; - height: 40px; - border: 0; - border-radius: 999px; - color: var(--stitch-primary); -} - -.dashboard-topbar .icon-button:hover { - background: var(--stitch-surface-highest); -} - -.sync-online-dot { - position: absolute; - top: 7px; - right: 7px; - width: 8px; - height: 8px; - border-radius: 999px; - background: var(--stitch-success-bright); - box-shadow: 0 0 0 2px var(--stitch-bg); -} - -.topbar-search .search-bar { - height: 40px; - border: 0; - border-radius: 8px; - padding-inline: 15px 12px; - color: var(--stitch-text-muted); - background: var(--stitch-surface-high); -} - -.topbar-search .search-bar input { - font-size: 14px; -} - -.search-bar input:focus { - border: 0; - outline: 0; - box-shadow: none; -} - -.topbar-search .search-bar kbd { - display: none; -} - -/* Direct Stitch dashboard canvas */ -.dashboard-canvas { - position: relative; - width: min(calc(100% - 48px), var(--stitch-canvas)); - margin: 0 auto; - padding: 32px 0 56px; -} - -.dashboard-canvas::before, -.dashboard-canvas::after { - content: ""; - position: fixed; - z-index: -1; - width: 360px; - height: 360px; - border-radius: 999px; - filter: blur(110px); - opacity: 0.08; - pointer-events: none; -} - -.dashboard-canvas::before { - top: 120px; - right: 8%; - background: var(--stitch-primary); -} - -.dashboard-canvas::after { - bottom: 40px; - left: 5%; - background: var(--stitch-success); -} - -.dashboard-hero { - display: flex; - align-items: center; - justify-content: space-between; - gap: 24px; - margin: 0 0 32px; -} - -.dashboard-hero .hero-copy h1 { - margin: 0 0 4px; - color: var(--stitch-text); - font-size: 32px; - font-weight: 600; - line-height: 40px; - letter-spacing: -0.035em; -} - -.dashboard-hero .hero-intro { - margin: 0; - color: var(--stitch-text-muted); - font-size: 16px; - line-height: 24px; -} - -.dashboard-hero .discovery-banner { - width: auto; - min-width: 0; - min-height: 42px; - margin: 0; - gap: 12px; - border: 1px solid color-mix(in srgb, var(--stitch-success) 45%, transparent); - border-radius: 12px; - padding: 8px 16px; - color: var(--stitch-success-bright); - background: color-mix(in srgb, var(--stitch-success) 11%, transparent); -} - -.dashboard-hero .discovery-banner strong { - color: inherit; - font-size: 14px; - font-weight: 600; -} - -.degraded-banner { - margin: -12px 0 24px; -} - -.category-tabs { - display: flex; - gap: 8px; - margin: 0 0 32px; - overflow-x: auto; - padding: 0 0 2px; - scrollbar-width: none; -} - -.category-tabs::-webkit-scrollbar { - display: none; -} - -.category-tabs button { - min-height: 40px; - flex: 0 0 auto; - gap: 8px; - border: 0; - border-radius: 999px; - padding: 0 20px; - color: var(--stitch-text-muted); - background: var(--stitch-surface-high); - font-size: 12px; - font-weight: 650; - letter-spacing: 0.05em; -} - -.category-tabs button.active { - color: var(--stitch-on-primary); - background: var(--stitch-primary); -} - -.category-tabs button > span { - display: none; -} - -.favorites-section, -.app-section { - margin: 0 0 32px; -} - -.favorites-section > header, -.app-section > header { - display: flex; - align-items: center; - margin: 0 0 16px; - padding: 0; -} - -.favorites-section h2, -.app-section h2 { - display: inline-flex; - align-items: center; - gap: 10px; - margin: 0; - color: var(--stitch-text); - font-size: 20px; - font-weight: 500; - line-height: 28px; - letter-spacing: -0.02em; -} - -.favorites-section h2 svg, -.app-section h2 svg { - color: var(--stitch-primary); -} - -.favorite-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(230px, 288px)); - gap: 16px; -} - -.app-grid { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 24px; -} - -.app-grid.feature-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); -} - -.app-tile { - position: relative; - min-width: 0; - min-height: 164px; - display: grid; - grid-template-columns: 1fr auto; - grid-template-rows: 48px 1fr; - align-items: start; - gap: 14px; - border: 1px solid var(--stitch-border); - border-radius: 16px; - padding: 20px; - color: var(--stitch-text); - background: color-mix(in srgb, var(--stitch-surface) 68%, transparent); - box-shadow: none; - backdrop-filter: blur(8px); -} - -.app-tile:hover { - border-color: color-mix( - in srgb, - var(--stitch-primary) 35%, - var(--stitch-border) - ); - background: var(--stitch-surface-high); - box-shadow: none; - transform: translateY(-2px); -} - -.app-tile > .app-icon-image, -.app-tile > .app-monogram { - grid-column: 1; - grid-row: 1; - width: 40px; - height: 40px; - border-radius: 8px; - padding: 7px; - background: var(--stitch-surface-highest); - object-fit: contain; -} - -.app-tile > .app-monogram { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 0; - color: var(--stitch-primary); - font-size: 17px; - font-weight: 700; -} - -.app-tile .tile-copy { - grid-column: 1 / -1; - grid-row: 2; - align-self: end; - display: grid; -} - -.app-tile .tile-copy strong { - color: var(--stitch-text); - font-size: 18px; - font-weight: 500; - line-height: 26px; -} - -.app-tile .tile-copy small { - margin-top: 0; - overflow: hidden; - color: var(--stitch-text-muted); - font-size: 14px; - line-height: 20px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.tile-status { - grid-column: 2; - grid-row: 1; - min-height: 28px; - display: inline-flex; - align-items: center; - gap: 6px; - border: 1px solid var(--stitch-border-strong); - border-radius: 999px; - padding: 3px 9px; - color: var(--stitch-text-muted); - background: var(--stitch-surface-highest); - font-size: 12px; - line-height: 18px; - white-space: nowrap; -} - -.tile-status > span { - width: 8px; - height: 8px; - border-radius: 999px; - background: var(--stitch-unknown); -} - -.tile-status.online { - border-color: color-mix(in srgb, var(--stitch-success) 42%, transparent); - color: var(--stitch-success-bright); - background: color-mix(in srgb, var(--stitch-success) 18%, transparent); -} - -.tile-status.online > span { - background: var(--stitch-success-bright); -} - -.tile-status.offline { - border-color: color-mix(in srgb, var(--stitch-error) 35%, transparent); - color: var(--stitch-error); - background: color-mix(in srgb, var(--stitch-error) 12%, transparent); -} - -.tile-status.offline > span { - background: var(--stitch-error); -} - -.app-tile .tile-arrow { - display: none; -} - -.app-tile.feature { - min-height: 128px; - grid-template-columns: 72px 1fr auto; - grid-template-rows: 1fr; - align-items: center; - gap: 16px; - padding: 24px 28px; -} - -.app-tile.feature > .app-icon-image, -.app-tile.feature > .app-monogram { - grid-column: 1; - grid-row: 1; - width: 64px; - height: 64px; - border: 1px solid color-mix(in srgb, var(--stitch-primary) 28%, transparent); - border-radius: 999px; - padding: 13px; - background: color-mix(in srgb, var(--stitch-primary) 12%, transparent); -} - -.app-tile.feature .tile-copy { - grid-column: 2; - grid-row: 1; - align-self: center; -} - -.app-tile.feature .tile-copy strong { - font-size: 20px; - line-height: 28px; -} - -.app-tile.feature .tile-status { - position: absolute; - top: 75px; - left: 83px; - width: 14px; - height: 14px; - min-height: 14px; - overflow: hidden; - border: 4px solid var(--stitch-surface); - padding: 0; - font-size: 0; - background: var(--stitch-unknown); -} - -.app-tile.feature .tile-status > span { - display: none; -} - -.app-tile.feature .tile-status.online { - background: var(--stitch-success-bright); -} - -.app-tile.feature .tile-status.offline { - background: var(--stitch-error); -} - -.favorite-grid .app-tile { - min-height: 96px; - display: flex; - align-items: center; - gap: 16px; - border-radius: 12px; - padding: 16px 20px; -} - -.favorite-grid .app-tile > .app-icon-image, -.favorite-grid .app-tile > .app-monogram { - width: 48px; - height: 48px; - flex: 0 0 48px; -} - -.favorite-grid .app-tile .tile-copy { - display: flex; - align-items: center; -} - -.favorite-grid .app-tile .tile-copy strong { - font-size: 16px; - line-height: 24px; -} - -.favorite-grid .app-tile .tile-copy small, -.favorite-grid .status-icon { - display: none; -} - -.dashboard-insights { - display: none; -} - -.dashboard-footer { - display: none; -} - -/* Quick Launch follows the full-screen canon overlay, not a dropdown. */ -.search-shell.expanded { - position: fixed; - inset: 0; - z-index: 100; - width: 100vw; - max-width: none; - margin: 0; - display: flex; - flex-direction: column; - align-items: center; - padding: min(12vh, 120px) 20px 40px; -} - -.dashboard-topbar:has(.search-shell.expanded) { - backdrop-filter: none; -} - -.search-shell.expanded .search-backdrop { - position: absolute; - inset: 0; - z-index: 0; - border: 0; - background: color-mix(in srgb, var(--stitch-bg) 93%, transparent); - backdrop-filter: blur(7px); -} - -.search-shell.expanded .search-bar, -.search-results { - width: min(100%, 800px); -} - -.search-shell.expanded .search-bar { - z-index: 2; - height: 106px; - border: 1px solid var(--stitch-border-strong); - border-radius: 16px 16px 0 0; - padding-inline: 30px; - background: var(--stitch-bg); - box-shadow: 0 32px 90px rgb(0 0 0 / 0.36); -} - -.search-shell.expanded .search-bar input { - font-size: 22px; -} - -.search-shell.expanded .search-bar kbd { - display: inline-flex; -} - -.search-results { - position: relative; - inset: auto; - z-index: 2; - max-height: min(66vh, 630px); - overflow-y: auto; - border: 1px solid var(--stitch-border-strong); - border-top: 0; - border-radius: 0 0 16px 16px; - padding: 18px 10px 0; - background: var(--stitch-bg); - box-shadow: 0 32px 90px rgb(0 0 0 / 0.36); -} - -.result-group { - padding: 0 0 10px; -} - -.result-group h2 { - padding: 9px 20px; - color: var(--stitch-text-muted); - font-size: 12px; - letter-spacing: 0.06em; - text-transform: none; -} - -.search-results a, -.search-results button.google-result { - min-height: 74px; - border: 0 !important; - border-radius: 10px !important; - padding: 9px 20px; - color: var(--stitch-text); -} - -.search-results a:hover, -.search-results button:hover, -.search-results a.selected, -.search-results button.selected { - background: color-mix( - in srgb, - var(--stitch-primary) 24%, - var(--stitch-surface) - ); -} - -.result-icon { - width: 50px; - height: 50px; - border-radius: 10px; - background: var(--stitch-surface-highest); -} - -.result-icon .app-icon-image, -.result-icon .app-monogram { - width: 100%; - height: 100%; - border-radius: inherit; - object-fit: contain; -} - -.search-shortcuts { - min-height: 66px; - margin: 0 -10px; - padding: 0 30px; - background: var(--stitch-surface-low); -} - -/* Settings shell */ -.settings-topbar { - height: 72px; - grid-template-columns: minmax(210px, auto) 1fr auto; - gap: 36px; - border-bottom: 1px solid var(--stitch-border-strong); - padding: 0 30px; - background: var(--stitch-bg); - backdrop-filter: none; -} - -.settings-topbar .settings-brand .brand-lockup img { - width: 32px; - height: 32px; -} - -.settings-topbar .settings-brand .brand-lockup strong { - color: var(--stitch-text); - font-size: 22px; -} - -.settings-topbar nav { - justify-self: end; - gap: 40px; -} - -.settings-topbar nav button, -.settings-topbar nav span { - min-height: 44px; - padding: 0 0 3px; - color: var(--stitch-text-muted); - font-size: 13px; - letter-spacing: 0.04em; -} - -.settings-topbar .back-button { - display: none; -} - -.settings-topbar .saving { - min-width: 78px; - color: var(--stitch-success-bright); -} - -.settings-layout { - width: 100%; - max-width: none; - min-height: calc(100vh - 72px); - grid-template-columns: 320px minmax(0, 1fr); - margin: 0; -} - -.settings-sidebar { - position: sticky; - top: 72px; - align-self: start; - height: calc(100vh - 72px); - border-right: 1px solid var(--stitch-border-strong); - padding: 28px 14px 28px 30px; - background: var(--stitch-surface-low); -} - -.settings-sidebar > .eyebrow { - margin: 0 0 3px; - color: var(--stitch-text); - font-size: 17px; - font-weight: 500; - letter-spacing: 0; - text-transform: none; -} - -.settings-sidebar h1 { - margin: 0 0 32px; - color: var(--stitch-text-muted); - font-size: 13px; - font-weight: 400; -} - -.settings-sidebar nav { - display: grid; - gap: 6px; -} - -.settings-sidebar nav button { - min-height: 52px; - gap: 16px; - border-radius: 8px; - padding: 0 18px; - color: var(--stitch-text-muted); - font-size: 14px; - font-weight: 450; -} - -.settings-sidebar nav button svg { - width: 21px; - height: 21px; -} - -.settings-sidebar nav button.active { - color: #061e18; - background: var(--stitch-success); -} - -.settings-page[data-section="appearance"] .settings-sidebar nav button.active { - color: var(--stitch-on-primary); - background: var(--stitch-primary); -} - -.settings-sidebar .local-note { - bottom: 26px; - color: var(--stitch-text-muted); -} - -.settings-content { - width: 100%; - max-width: 1320px; - padding: 40px 30px 80px; -} - -.settings-heading { - max-width: 780px; - margin: 0 0 20px; -} - -.settings-heading .eyebrow { - margin: 0 0 12px; - color: var(--stitch-text-muted); - font-size: 11px; - font-weight: 650; - letter-spacing: 0.06em; - text-transform: none; -} - -.settings-heading h2 { - margin: 0 0 8px; - color: var(--stitch-text); - font-size: 32px; - font-weight: 600; - line-height: 40px; - letter-spacing: -0.035em; -} - -.settings-heading > p:last-child { - color: var(--stitch-text-muted); - font-size: 15px; - line-height: 24px; -} - -.settings-summary { - display: none; -} - -.management-toolbar { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 14px; - margin: 0 0 22px; -} - -.settings-search { - order: 2; - min-width: min(100%, 320px); - margin-left: auto; -} - -.settings-search input { - height: 42px; - border-color: var(--stitch-border); - background: var(--stitch-surface-low); -} - -.filter-pills { - order: 1; - gap: 10px; - border: 0; - padding: 0; - background: transparent; -} - -.filter-pills button { - min-height: 42px; - border: 0; - border-radius: 8px; - padding: 0 18px; - color: var(--stitch-text-muted); - background: var(--stitch-surface-high); - font-size: 13px; -} - -.filter-pills button.active { - color: var(--stitch-on-primary); - background: var(--stitch-primary); -} - -.results-note { - display: none; -} - -.management-table-head { - min-height: 54px; - display: grid; - grid-template-columns: minmax(260px, 1.4fr) 150px 180px 210px 90px; - align-items: center; - gap: 16px; - border: 1px solid var(--stitch-border); - border-radius: 16px 16px 0 0; - padding: 0 30px; - color: var(--stitch-text-muted); - background: var(--stitch-surface-high); - font-size: 12px; - font-weight: 650; - letter-spacing: 0.04em; -} - -.management-list:has(> .app-management-row) { - position: relative; - gap: 0; - overflow: hidden; - border: 1px solid var(--stitch-border); - border-top: 0; - border-radius: 0 0 16px 16px; - background: var(--stitch-surface-low); -} - -.management-list:has(> .app-management-row)::before { - content: ""; - position: absolute; - z-index: 2; - inset: 0 auto 0 0; - width: 5px; - background: var(--stitch-success-bright); - box-shadow: 0 0 18px - color-mix(in srgb, var(--stitch-success-bright) 70%, transparent); -} - -.app-management-row { - min-height: 82px; - grid-template-columns: minmax(260px, 1.4fr) 150px 180px minmax(280px, 300px); - gap: 16px; - border: 0; - border-bottom: 1px solid var(--stitch-border); - border-radius: 0; - padding: 12px 30px; - background: transparent; -} - -.app-management-row:last-child { - border-bottom: 0; -} - -.app-management-row .management-title .app-icon-image, -.app-management-row .management-title .app-monogram { - width: 46px; - height: 46px; - border-radius: 9px; - background: var(--stitch-surface-highest); -} - -.app-management-row .management-title strong { - color: var(--stitch-text); - font-size: 16px; - font-weight: 500; -} - -.app-management-row .management-title small { - color: var(--stitch-text-muted); -} - -.inline-status { - color: var(--stitch-text-muted); -} - -.category-badge { - width: fit-content; - border: 1px solid var(--stitch-border-strong); - border-radius: 999px; - padding: 5px 12px; - color: var(--stitch-text-muted); - background: transparent; -} - -.app-row-actions { - display: grid; - grid-template-columns: 150px 1fr; - align-items: center; - gap: 18px; -} - -.visibility-toggle { - min-height: 38px; - justify-content: flex-start; - border: 0; - color: var(--stitch-text-muted); - background: transparent; -} - -.visibility-toggle::before { - content: ""; - width: 50px; - height: 28px; - flex: 0 0 50px; - border-radius: 999px; - background: var(--stitch-surface-highest); - box-shadow: inset 0 0 0 1px var(--stitch-border); -} - -.visibility-toggle::after { - content: ""; - position: absolute; - left: 4px; - width: 20px; - height: 20px; - border-radius: 999px; - background: #f8f9fd; - transition: transform 160ms ease; -} - -.visibility-toggle { - position: relative; -} - -.visibility-toggle.on::before { - background: var(--stitch-primary); -} - -.visibility-toggle.on { - border: 0; - color: var(--stitch-text-muted); - background: transparent; -} - -.visibility-toggle.on::after { - transform: translateX(22px); -} - -.visibility-toggle svg { - display: none; -} - -.edit-app-button { - justify-content: flex-end; - color: var(--stitch-primary); -} - -.edit-app-button svg { - display: none; -} - -/* App editor sheet */ -.drawer-layer { - z-index: 120; -} - -.drawer-backdrop { - background: rgb(2 7 16 / 0.78); - backdrop-filter: blur(7px); -} - -.app-editor-drawer { - width: min(100%, 570px); - border-left: 1px solid var(--stitch-border-strong); - border-radius: 0; - background: var(--stitch-surface-low); - box-shadow: -24px 0 60px rgb(0 0 0 / 0.32); -} - -.app-editor-drawer > header { - min-height: 100px; - border-bottom: 1px solid var(--stitch-border-strong); - padding: 20px 30px; - background: var(--stitch-surface-low); -} - -.drawer-app-title > .app-icon-image, -.drawer-app-title > .app-monogram { - display: none; -} - -.drawer-app-title small { - order: -1; - color: var(--stitch-primary); - font-size: 11px; - font-weight: 650; - letter-spacing: 0.07em; - text-transform: uppercase; -} - -.drawer-app-title strong { - color: var(--stitch-text); - font-size: 22px; - font-weight: 550; -} - -.drawer-content { - padding: 28px 30px 120px; -} - -.drawer-section { - margin: 0 0 36px; - border: 0; - padding: 0; - background: transparent; -} - -.drawer-section > header h3 { - color: var(--stitch-primary); - font-size: 11px; - letter-spacing: 0.06em; - text-transform: uppercase; -} - -.drawer-section input, -.drawer-section select, -.drawer-section .input-with-icon { - min-height: 58px; - border-color: var(--stitch-border-strong); - border-radius: 9px; - color: var(--stitch-text); - background: var(--stitch-bg); -} - -.drawer-section.two-column { - grid-template-columns: 1fr; -} - -.drawer-visibility { - min-height: 84px; - display: flex; - align-items: center; - justify-content: space-between; - gap: 20px; - border: 1px solid var(--stitch-border-strong); - border-radius: 9px; - padding: 14px 16px; - color: var(--stitch-text); - background: var(--stitch-bg); - text-align: left; -} - -.drawer-visibility > span { - display: grid; -} - -.drawer-visibility small { - margin-top: 2px; - color: var(--stitch-text-muted); -} - -.drawer-visibility i { - position: relative; - width: 52px; - height: 30px; - flex: 0 0 52px; - border-radius: 999px; - background: var(--stitch-surface-highest); -} - -.drawer-visibility i::after { - content: ""; - position: absolute; - top: 4px; - left: 4px; - width: 22px; - height: 22px; - border-radius: 999px; - background: #f8f9fd; - transition: transform 160ms ease; -} - -.drawer-visibility.on i { - background: var(--stitch-success); -} - -.drawer-visibility.on i::after { - transform: translateX(22px); -} - -.app-editor-drawer > footer { - min-height: 92px; - border-top: 1px solid var(--stitch-border-strong); - padding: 18px 30px; - background: var(--stitch-surface-low); -} - -.app-editor-drawer > footer .danger-button { - margin-right: auto; -} - -.app-editor-drawer > footer .primary-button { - min-width: 138px; - min-height: 52px; - color: var(--stitch-on-primary); - background: var(--stitch-primary); -} - -/* Appearance screen mirrors the canon preview hierarchy. Additional real - product options remain available below the canonical first viewport. */ -.settings-page[data-section="appearance"] .settings-content { - max-width: 1180px; - padding-left: 80px; - padding-right: 50px; -} - -.settings-page[data-section="appearance"] .settings-heading { - margin-bottom: 20px; -} - -.theme-grid { - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 30px; - margin: 0 0 46px; - padding-bottom: 46px; - border-bottom: 1px solid var(--stitch-border-strong); -} - -.theme-card { - min-height: 250px; - display: grid; - grid-template-rows: 198px auto; - border: 0; - border-radius: 0; - padding: 0; - color: var(--stitch-text-muted); - background: transparent; -} - -.theme-card:hover { - transform: none; -} - -.theme-card.selected { - border-color: transparent; - box-shadow: none; -} - -.theme-card .theme-preview { - width: 100%; - height: 198px; - border: 2px solid transparent; - border-radius: 14px; - padding: 14px; - background: var(--stitch-surface-high); -} - -.theme-card.selected .theme-preview { - border-color: var(--stitch-primary); - box-shadow: 0 0 0 1px var(--stitch-text); -} - -.theme-card > span:nth-child(2) { - display: grid; - grid-template-columns: 1fr auto; - padding: 14px 5px 0; - text-align: left; -} - -.theme-card > span:nth-child(2) strong { - color: var(--stitch-text); - font-size: 16px; - font-weight: 500; -} - -.theme-card > span:nth-child(2) small { - display: none; -} - -.theme-card > svg { - position: absolute; - right: 6px; - bottom: 14px; - color: var(--stitch-primary); -} - -.settings-page[data-section="appearance"] .setting-label { - margin: 30px 0 14px; - color: var(--stitch-text); - font-size: 20px; - font-weight: 550; - letter-spacing: -0.02em; - text-transform: none; -} - -.canon-preference-columns { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 40px; - margin-bottom: 44px; - padding-bottom: 44px; - border-bottom: 1px solid var(--stitch-border-strong); -} - -.canon-preference-columns .setting-label { - margin-top: 0 !important; -} - -.canon-preference-columns .view-grid, -.canon-preference-columns .density-grid { - grid-template-columns: 1fr; - gap: 14px; -} - -.canon-preference-columns .option-card { - min-height: 100px; - display: grid; - grid-template-columns: auto 1fr auto; - grid-template-rows: auto auto; - align-items: center; - column-gap: 14px; - text-align: left; -} - -.canon-preference-columns .option-card > svg:first-child { - grid-row: 1 / 3; -} - -.canon-preference-columns .option-card > strong, -.canon-preference-columns .option-card > small { - grid-column: 2; -} - -.canon-preference-columns .option-card > svg:last-child { - grid-column: 3; - grid-row: 1 / 3; -} - -.view-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 16px; -} - -.view-card, -.option-card, -.background-card, -.toggle-list article { - border-color: var(--stitch-border-strong); - color: var(--stitch-text); - background: transparent; -} - -.view-card.selected, -.option-card.selected, -.background-card.selected { - border-color: var(--stitch-primary); - background: color-mix(in srgb, var(--stitch-primary) 9%, transparent); - box-shadow: inset 0 0 0 1px var(--stitch-primary); -} - -.view-card { - min-height: 100px; -} - -.toggle-list article { - min-height: 104px; - border-radius: 16px; - background: var(--stitch-surface-low); -} - -.switch.on, -.visibility-toggle.on::before { - background: var(--stitch-primary); -} - -/* Keep the remainder of the settings workflows in the same material family. */ -.management-card, -.integration-list article, -.backup-grid article, -.privacy-note, -.security-callout, -.external-toggle, -.server-widget-config, -.widget-studio, -.appearance-preview { - border-color: var(--stitch-border); - color: var(--stitch-text); - background: var(--stitch-surface-low); - box-shadow: none; -} - -.primary-button { - color: var(--stitch-on-primary); - background: var(--stitch-primary); -} - -.secondary-button { - border-color: var(--stitch-border-strong); - color: var(--stitch-text); - background: transparent; -} - -@media (max-width: 1300px) { - .dashboard-topbar-inner { - grid-template-columns: auto minmax(260px, 1fr) auto; - gap: 18px; - } - - .dashboard-topbar nav { - display: none; - } - - .app-grid { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } - - .settings-layout { - grid-template-columns: 248px minmax(0, 1fr); - } - - .settings-sidebar { - padding-left: 18px; - } - - .management-table-head { - display: none; - } - - .management-list:has(> .app-management-row) { - border-top: 1px solid var(--stitch-border); - border-radius: 16px; - } - - .app-management-row { - grid-template-columns: minmax(220px, 1fr) 130px minmax(260px, auto); - } - - .app-management-row .category-badge { - display: none; - } -} - -.mobile-dashboard-search { - display: none; -} - -@media (max-width: 720px) { - .app-shell { - padding-bottom: 82px; - } - - .dashboard-topbar { - height: 86px; - background: var(--stitch-bg); - } - - .dashboard-topbar-inner { - width: calc(100% - 44px); - height: 86px; - grid-template-columns: 1fr auto auto; - gap: 10px; - } - - .dashboard-topbar .brand-lockup.compact img { - width: 44px; - height: 44px; - flex-basis: 44px; - } - - .dashboard-topbar .brand-lockup.compact strong { - font-size: 23px; - } - - .dashboard-topbar .topbar-search { - justify-self: end; - width: 42px; - grid-column: auto; - grid-row: auto; - } - - .dashboard-topbar .topbar-search .search-bar { - width: 42px; - height: 42px; - justify-content: center; - padding: 0; - color: var(--stitch-text); - background: transparent; - } - - .dashboard-topbar .topbar-search .search-bar input, - .dashboard-topbar .topbar-search .search-bar kbd { - display: none; - } - - .dashboard-topbar .topbar-search .search-bar input { - position: absolute; - inset: 0; - display: block; - width: 100%; - opacity: 0; - cursor: pointer; - } - - .dashboard-topbar .topbar-actions .sync-status-button { - display: none; - } - - .dashboard-topbar .icon-button { - color: var(--stitch-text); - } - - .dashboard-canvas { - width: calc(100% - 44px); - padding: 22px 0 40px; - } - - .dashboard-hero { - display: block; - margin-bottom: 22px; - } - - .dashboard-hero .hero-copy { - display: none; - } - - .dashboard-hero .discovery-banner { - position: relative; - width: 100%; - min-height: 102px; - display: grid; - grid-template-columns: 1fr auto; - grid-template-rows: auto auto; - align-content: center; - gap: 2px 8px; - border: 0; - border-radius: 16px; - padding: 18px; - color: var(--stitch-on-primary); - background: var(--stitch-primary); - text-align: left; - } - - .dashboard-hero .discovery-banner::before { - content: "ONTDEKKING"; - grid-column: 1; - color: color-mix(in srgb, var(--stitch-on-primary) 80%, transparent); - font-size: 12px; - font-weight: 600; - letter-spacing: 0.08em; - } - - .dashboard-hero .discovery-banner::after { - content: "BEKIJK"; - grid-column: 2; - grid-row: 1 / 3; - align-self: center; - border-radius: 999px; - padding: 9px 16px; - color: var(--stitch-primary); - background: var(--stitch-on-primary); - font-size: 12px; - font-weight: 650; - letter-spacing: 0.04em; - } - - .dashboard-hero .discovery-banner strong { - grid-column: 1; - color: var(--stitch-on-primary); - font-size: 22px; - font-weight: 500; - line-height: 29px; - } - - .dashboard-hero .discovery-banner > svg { - display: none; - } - - .mobile-dashboard-search { - display: block; - margin-bottom: 32px; - } - - .mobile-dashboard-search .search-bar { - height: 62px; - border: 1px solid var(--stitch-border); - border-radius: 14px; - padding: 0 16px; - color: var(--stitch-text); - background: var(--stitch-surface-low); - } - - .mobile-dashboard-search .search-bar input { - font-size: 16px; - } - - .mobile-dashboard-search .search-bar kbd { - display: none; - } - - .category-tabs { - position: relative; - gap: 16px; - margin: 0 -22px 42px; - padding: 38px 22px 0; - } - - .category-tabs::before { - content: "CATEGORIEËN"; - position: absolute; - top: 0; - left: 22px; - color: var(--stitch-text); - font-size: 12px; - font-weight: 650; - letter-spacing: 0.07em; - } - - .category-tabs button { - position: relative; - width: 76px; - height: 98px; - min-height: 98px; - display: flex; - flex-direction: column; - justify-content: flex-end; - gap: 20px; - overflow: visible; - border-radius: 0; - padding: 0; - color: var(--stitch-text-muted); - background: transparent; - font-size: 12px; - letter-spacing: 0.02em; - } - - .category-tabs button::before { - content: ""; - position: absolute; - z-index: 0; - top: 0; - left: 0; - width: 76px; - height: 76px; - border: 1px solid var(--stitch-border); - border-radius: 999px; - background: var(--stitch-surface-low); - } - - .category-tabs button.active { - color: var(--stitch-text); - background: transparent; - } - - .category-tabs button.active::before { - border-color: var(--stitch-border-strong); - background: var(--stitch-surface-highest); - } - - .category-tabs button svg { - position: absolute; - z-index: 1; - top: 25px; - left: 27px; - width: 22px; - height: 22px; - color: var(--stitch-primary); - } - - .favorites-section, - .app-section { - margin-bottom: 42px; - } - - .favorites-section > header, - .app-section > header { - margin-bottom: 18px; - } - - .favorites-section h2, - .app-section h2 { - font-size: 26px; - line-height: 34px; - } - - .favorite-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 14px; - } - - .favorite-grid .app-tile { - min-height: 176px; - flex-direction: column; - align-items: flex-start; - justify-content: space-between; - gap: 14px; - border-radius: 16px; - padding: 22px; - } - - .favorite-grid .app-tile > .app-icon-image, - .favorite-grid .app-tile > .app-monogram { - width: 54px; - height: 54px; - } - - .favorite-grid .app-tile .tile-copy { - display: grid; - } - - .favorite-grid .app-tile .tile-copy strong { - font-size: 19px; - } - - .favorite-grid .app-tile .tile-copy small { - display: block; - } - - .favorite-grid .app-tile:nth-child(3):last-child { - grid-column: 1 / -1; - min-height: 110px; - flex-direction: row; - align-items: center; - padding: 18px 22px; - } - - .favorite-grid .app-tile:nth-child(3):last-child .tile-copy { - margin-right: auto; - } - - .app-grid { - grid-template-columns: 1fr; - gap: 10px; - } - - .app-tile.standard { - min-height: 90px; - grid-template-columns: 56px minmax(0, 1fr) auto; - grid-template-rows: 1fr; - align-items: center; - gap: 14px; - border-radius: 14px; - padding: 12px 16px; - } - - .app-tile.standard > .app-icon-image, - .app-tile.standard > .app-monogram { - grid-column: 1; - grid-row: 1; - width: 56px; - height: 56px; - } - - .app-tile.standard .tile-copy { - grid-column: 2; - grid-row: 1; - align-self: center; - } - - .app-tile.standard .tile-copy strong { - font-size: 17px; - line-height: 22px; - } - - .app-tile.standard .tile-copy small { - font-size: 11px; - letter-spacing: 0.05em; - text-transform: uppercase; - } - - .app-tile.standard .tile-status { - grid-column: 3; - grid-row: 1; - width: 12px; - min-height: 12px; - overflow: hidden; - border: 0; - padding: 0; - font-size: 0; - background: transparent; - } - - .app-tile.standard .tile-status > span { - width: 8px; - height: 8px; - } - - .app-grid.feature-grid { - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 12px; - } - - .app-tile.feature { - min-height: 150px; - display: flex; - flex-direction: column; - justify-content: center; - gap: 18px; - border: 0; - border-radius: 16px; - padding: 16px 8px; - background: var(--stitch-surface-high); - text-align: center; - } - - .app-tile.feature > .app-icon-image, - .app-tile.feature > .app-monogram { - width: 52px; - height: 52px; - flex: 0 0 52px; - margin: 0 auto; - border: 0; - border-radius: 0; - background: transparent; - } - - .app-tile.feature .tile-copy strong { - overflow: hidden; - font-size: 12px; - line-height: 18px; - text-overflow: ellipsis; - white-space: nowrap; - } - - .app-tile.feature .tile-copy small, - .app-tile.feature .tile-status { - display: none; - } - - .mobile-nav { - z-index: 80; - height: 82px; - border-top: 1px solid var(--stitch-border); - padding: 8px 16px 5px; - background: var(--stitch-bg); - } - - .mobile-nav button { - min-height: 66px; - border-radius: 999px; - color: var(--stitch-text-muted); - } - - .mobile-nav button.active { - color: var(--stitch-text); - background: transparent; - } - - .mobile-nav button.active svg { - width: 52px; - height: 40px; - border-radius: 999px; - padding: 9px 15px; - color: var(--stitch-on-primary); - background: var(--stitch-primary); - } - - .mobile-nav button span { - font-size: 10px; - } - - .search-shell.expanded { - padding: 18px 12px 96px; - } - - .search-shell.expanded .search-bar { - height: 72px; - padding-inline: 18px; - } - - .search-shell.expanded .search-bar input { - position: static; - display: block; - width: auto; - opacity: 1; - font-size: 18px; - } - - .search-results { - max-height: calc(100vh - 186px); - } - - .settings-topbar { - height: 70px; - grid-template-columns: 1fr auto; - padding: 0 18px; - } - - .settings-topbar nav { - display: none; - } - - .settings-layout { - display: block; - min-height: calc(100vh - 70px); - } - - .settings-sidebar { - position: sticky; - top: 70px; - z-index: 35; - width: 100%; - height: auto; - overflow-x: auto; - border-right: 0; - border-bottom: 1px solid var(--stitch-border); - padding: 10px 16px; - } - - .settings-sidebar > .eyebrow, - .settings-sidebar > h1, - .settings-sidebar .local-note { - display: none; - } - - .settings-sidebar nav { - display: flex; - gap: 8px; - } - - .settings-sidebar nav button { - min-height: 42px; - flex: 0 0 auto; - gap: 8px; - padding: 0 14px; - } - - .settings-content, - .settings-page[data-section="appearance"] .settings-content { - padding: 28px 18px 100px; - } - - .settings-heading h2 { - font-size: 28px; - line-height: 34px; - } - - .settings-search { - order: 1; - width: 100%; - margin: 0; - } - - .filter-pills { - order: 2; - width: 100%; - overflow-x: auto; - } - - .management-list:has(> .app-management-row) { - display: grid; - gap: 12px; - overflow: visible; - border: 0; - background: transparent; - } - - .management-list:has(> .app-management-row)::before { - display: none; - } - - .app-management-row { - min-height: 180px; - grid-template-columns: 1fr auto; - grid-template-rows: auto auto auto; - gap: 12px; - border: 1px solid var(--stitch-border); - border-radius: 14px; - padding: 18px; - background: var(--stitch-surface-low); - } - - .app-management-row .management-title { - grid-column: 1 / -1; - } - - .app-management-row .inline-status { - grid-column: 1; - } - - .app-management-row .category-badge { - grid-column: 2; - display: inline-flex; - } - - .app-row-actions { - grid-column: 1 / -1; - grid-template-columns: 1fr auto; - } - - .theme-grid { - grid-template-columns: 1fr; - } - - .canon-preference-columns { - grid-template-columns: 1fr; - } - - .app-editor-drawer { - width: 100%; - } -} - -/* Platform geometry v2 — shared scaling, readable settings and widescreen dock. */ -:root { - --platform-max: 3040px; - --platform-gutter: clamp(24px, 2.25vw, 56px); - --platform-inset: clamp(48px, 4.5vw, 112px); - --widget-dock-width: clamp(360px, 22vw, 430px); -} - -.dashboard-topbar-inner, -.dashboard-workspace { - width: calc(100% - var(--platform-inset)); - max-width: var(--platform-max); -} - -.dashboard-workspace { - grid-template-columns: clamp(248px, 15vw, 292px) minmax(0, 1fr); - gap: clamp(24px, 2vw, 36px); -} - -.dashboard-rail { - padding: 20px; -} - -.rail-intro span, -.rail-favorites nav a small, -.rail-widget-link small, -.rail-empty-action small, -.rail-deck-health p, -.rail-deck-health > button { - font-size: 12px; - line-height: 1.45; -} - -.rail-favorites nav a strong, -.rail-widget-link strong, -.rail-empty-action strong, -.rail-favorites > header > span, -.rail-deck-health > header > span { - font-size: 14px; -} - -.app-tile.standard { - isolation: isolate; - overflow: hidden; - border-color: color-mix( - in srgb, - var(--stitch-border) 88%, - var(--stitch-primary) 12% - ); - background: linear-gradient( - 145deg, - color-mix(in srgb, var(--stitch-surface) 94%, var(--stitch-primary) 6%), - var(--stitch-surface) - ); -} - -.app-tile.standard::before { - content: ""; - position: absolute; - z-index: -1; - inset: 0 auto 0 0; - width: 3px; - background: linear-gradient( - 180deg, - var(--stitch-primary), - var(--stitch-success-bright) - ); - opacity: 0; - transition: opacity 180ms ease; -} - -.app-tile.standard:hover, -.app-tile.standard:focus-visible { - border-color: color-mix( - in srgb, - var(--stitch-primary) 46%, - var(--stitch-border) - ); - box-shadow: 0 16px 34px rgb(0 0 0 / 0.13); - transform: translateY(-3px); -} - -.app-tile.standard:hover::before, -.app-tile.standard:focus-visible::before { - opacity: 1; -} - -.app-tile.standard .tile-arrow { - opacity: 0.42; - transition: - opacity 160ms ease, - transform 160ms ease; -} - -.app-tile.standard:hover .tile-arrow, -.app-tile.standard:focus-visible .tile-arrow { - opacity: 1; - transform: translate(2px, -2px); -} - -.quick-launch-home { - display: grid; - gap: 10px; - padding-bottom: 12px; -} - -.quick-links-section { - padding: 0 10px 12px; -} - -.quick-links-section > h2 { - margin: 0; - padding: 10px 10px 12px; - color: var(--stitch-text-muted); - font-size: 12px; - font-weight: 650; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.quick-links-grid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 10px; -} - -.search-results .quick-links-grid > a { - min-width: 0; - min-height: 108px; - display: grid; - grid-template-columns: 46px minmax(0, 1fr) auto; - grid-template-rows: 1fr; - align-items: center; - gap: 12px; - border: 1px solid var(--stitch-border) !important; - border-radius: 13px !important; - padding: 14px; - background: color-mix(in srgb, var(--stitch-surface) 82%, transparent); -} - -.search-results .quick-links-grid > a:hover, -.search-results .quick-links-grid > a:focus-visible { - border-color: color-mix( - in srgb, - var(--stitch-primary) 48%, - var(--stitch-border) - ) !important; - background: var(--stitch-primary-soft); - transform: translateY(-2px); -} - -.quick-links-grid > a > span:nth-child(2) { - min-width: 0; - display: grid; - gap: 4px; -} - -.quick-links-grid > a strong, -.quick-links-grid > a small { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.quick-links-grid > a strong { - font-size: 14px; -} - -.quick-links-grid > a small { - color: var(--stitch-text-muted); - font-size: 12px; -} - -.quick-links-grid .result-icon { - width: 46px; - height: 46px; -} - -.settings-topbar { - padding-right: max( - calc(var(--platform-gutter) + 30px), - calc((100vw - var(--platform-max)) / 2 + 30px) - ); - padding-left: max( - calc(var(--platform-gutter) + 30px), - calc((100vw - var(--platform-max)) / 2 + 30px) - ); -} - -.settings-layout { - width: calc(100% - var(--platform-inset)); - max-width: var(--platform-max); - grid-template-columns: clamp(300px, 18vw, 348px) minmax(0, 1fr); - margin: 0 auto; -} - -.settings-content, -.settings-page[data-section="appearance"] .settings-content { - max-width: none; - padding-right: clamp(40px, 4vw, 76px); - padding-left: clamp(40px, 4vw, 76px); - font-size: 16px; -} - -.settings-sidebar nav button { - font-size: 15.5px; - font-weight: 520; -} - -.settings-sidebar h1, -.settings-sidebar .local-note { - font-size: 13px; - line-height: 1.45; -} - -.settings-heading .eyebrow { - font-size: 13px; -} - -.settings-heading h2 { - font-size: clamp(34px, 2vw, 40px); - line-height: 1.15; -} - -.settings-heading > p:last-child { - max-width: 900px; - font-size: 17px; - line-height: 1.6; -} - -.settings-content :where(input, select, textarea, button) { - font-size: 15px; -} - -.settings-global-search input { - font-size: 15.5px; -} - -.settings-content :where(.setting-label, label > span, .results-note) { - font-size: 13px; - line-height: 1.45; -} - -.settings-content - :where( - .management-title small, - .management-card small, - .widget-option > p, - .widget-option > header small, - .integration-list article small, - .security-callout small, - .toggle-list article small, - .appearance-advanced small, - .integration-section-heading small - ) { - font-size: 13px; - line-height: 1.55; -} - -.settings-content - :where( - .management-title strong, - .management-card strong, - .widget-option strong, - .toggle-list article strong, - .integration-list article strong - ) { - font-size: 15px; -} - -.management-table-head, -.filter-pills button, -.pagination span, -.favorite-editor > summary { - font-size: 13px; -} - -@media (min-width: 1900px) { - .dashboard-canvas { - padding-right: calc(var(--widget-dock-width) + 32px); - } - - .dashboard-insights-panel { - position: fixed; - z-index: 20; - top: 96px; - right: max(var(--platform-gutter), calc((100vw - var(--platform-max)) / 2)); - width: var(--widget-dock-width); - max-height: calc(100vh - 120px); - overflow: auto; - margin: 0; - background: color-mix(in srgb, var(--stitch-surface) 92%, transparent); - box-shadow: 0 20px 70px rgb(0 0 0 / 0.18); - backdrop-filter: blur(20px); - scrollbar-width: thin; - } - - .dashboard-insights-panel > summary { - position: sticky; - z-index: 3; - top: 0; - min-height: 76px; - background: color-mix(in srgb, var(--stitch-surface) 96%, transparent); - backdrop-filter: blur(16px); - } - - .dashboard-insights-panel .dashboard-widgets { - padding: 18px; - } - - .dashboard-insights-panel .widgets-grid { - grid-template-columns: 1fr; - } - - .dashboard-insights-panel .widget-shell.widget-size-wide { - grid-column: auto; - } - - .dashboard-insights-panel .widgets-section > header { - align-items: flex-start; - gap: 14px; - } - - .dashboard-insights-panel .widgets-actions { - width: 100%; - } - - .dashboard-insights-panel .widgets-edit, - .dashboard-insights-panel .widgets-refresh { - flex: 1 1 0; - justify-content: center; - } - - .dashboard-canvas .app-grid { - grid-template-columns: repeat(auto-fill, minmax(235px, 1fr)); - } - - .dashboard-canvas .app-grid.feature-grid { - grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); - } -} - -/* Unified platform header, settings forms and panel catalog. */ -.dashboard-topbar, -.settings-topbar.dashboard-topbar { - height: 72px; - display: block; - padding: 0; -} - -.dashboard-topbar-inner { - height: 72px; -} - -.dashboard-topbar nav button, -.settings-topbar nav button { - cursor: pointer; - transition: - color 160ms ease, - border-color 160ms ease; -} - -.dashboard-topbar nav button:hover, -.settings-topbar nav button:hover, -.dashboard-topbar nav button:focus-visible, -.settings-topbar nav button:focus-visible, -.dashboard-topbar nav button.active, -.settings-topbar nav button.active { - border-bottom-color: var(--stitch-primary); - color: var(--stitch-text); -} - -.settings-search-slot, -.settings-search-slot .settings-global-search { - width: 100%; -} - -.settings-topbar .topbar-actions { - position: relative; -} - -.settings-topbar .saving { - position: absolute; - right: calc(100% + 12px); - width: max-content; - color: var(--stitch-text-muted); - font-size: 12px; -} - -.settings-topbar .icon-button.active { - border-color: color-mix( - in srgb, - var(--stitch-primary) 52%, - var(--stitch-border) - ); - color: var(--stitch-primary); - background: var(--stitch-primary-soft); -} - -.integration-settings-list { - display: flex; - flex-direction: column; - gap: 0; - overflow: hidden; - border: 1px solid var(--stitch-border); - border-radius: 10px; - background: var(--stitch-surface-low); -} - -.integration-settings-list article { - min-height: 0; - grid-template-columns: 46px minmax(0, 1fr) auto; - gap: 16px; - border: 0; - border-bottom: 1px solid var(--stitch-border); - border-radius: 0; - padding: 22px 24px; - background: transparent; -} - -.integration-settings-list article:last-child { - border-bottom: 0; -} - -.integration-settings-list article > span:nth-child(2) { - gap: 8px; - padding: 2px 0 0; -} - -.integration-settings-list article > span:nth-child(2) > strong { - font-size: 17px; - line-height: 1.3; -} - -.integration-settings-list article > span:nth-child(2) > small, -.integration-settings-list .credential-heading small { - max-width: 820px; - font-size: 14px; - line-height: 1.55; -} - -.integration-settings-list .state-badge { - position: static; - align-self: start; - justify-self: end; - margin: 2px 0 0; - font-size: 11px; -} - -.integration-settings-list .credential-form { - max-width: 920px; - margin-top: 12px; - padding-top: 16px; -} - -.integration-settings-list .credential-heading strong, -.widget-inline-connection .credential-heading strong { - font-size: 14px; -} - -.integration-settings-list .credential-fields, -.widget-inline-connection .credential-fields { - gap: 14px; -} - -.integration-settings-list .credential-fields label > span, -.widget-inline-connection .credential-fields label > span { - font-size: 13px; -} - -.integration-settings-list .credential-fields input, -.widget-inline-connection .credential-fields input { - height: 46px; - border-radius: 8px; - padding: 0 14px; - font-size: 15px; -} - -.integration-settings-list .credential-save, -.integration-settings-list .credential-reconfigure { - min-height: 40px; - font-size: 13px; -} - -.widget-studio { - border-radius: 10px; - padding: clamp(20px, 2.5vw, 32px); -} - -.widget-studio-heading { - align-items: flex-start; - gap: 16px; -} - -.widget-studio-heading strong { - font-size: 21px; -} - -.widget-studio-heading small { - max-width: 820px; - font-size: 14px; - line-height: 1.55; -} - -.studio-capacity { - padding: 8px 12px; - font-size: 12px; -} - -.widget-data-contract { - margin: 20px 0 12px; - border-radius: 8px; - padding: 16px 18px; - font-size: 14px; - line-height: 1.55; -} - -.studio-steps { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 10px; - margin: 0 0 18px; - padding: 0; - list-style: none; -} - -.studio-steps li { - min-width: 0; - display: grid; - grid-template-columns: 30px minmax(0, 1fr); - gap: 2px 10px; - border: 1px solid var(--stitch-border); - border-radius: 8px; - padding: 13px; - background: var(--stitch-surface); -} - -.studio-steps li > span { - grid-row: 1 / span 2; - width: 30px; - height: 30px; - display: grid; - place-items: center; - border-radius: 7px; - color: var(--stitch-on-primary); - background: var(--stitch-primary); - font-size: 13px; - font-weight: 750; -} - -.studio-steps strong { - font-size: 14px; -} - -.studio-steps small { - color: var(--stitch-text-muted); - font-size: 12px; - line-height: 1.4; -} - -.widget-studio-toolbar { - align-items: stretch; - gap: 12px; - margin: 0 0 14px; -} - -.widget-studio-toolbar > label, -.widget-studio-toolbar input { - min-height: 44px; -} - -.widget-studio-toolbar input { - font-size: 14px; -} - -.studio-filters button { - min-height: 38px; - padding: 0 13px; - font-size: 12px; -} - -.widget-catalog { - grid-template-columns: 1fr; - gap: 10px; -} - -.widget-option { - grid-template-columns: minmax(240px, 0.9fr) minmax(280px, 1.4fr) auto; - grid-template-rows: auto auto; - column-gap: 22px; - align-items: center; - border-radius: 8px; - padding: 16px 18px; - background: var(--stitch-surface); -} - -.widget-option > header { - grid-column: 1; - grid-row: 1 / span 2; -} - -.widget-option-icon { - width: 42px; - height: 42px; - flex-basis: 42px; -} - -.widget-option > header strong { - font-size: 15px; -} - -.widget-option > header small { - font-size: 12px; -} - -.widget-option > p { - grid-column: 2; - min-height: 0; - margin: 0; - font-size: 13px; - line-height: 1.5; -} - -.capability-features { - grid-column: 2; - gap: 6px; - margin-top: 8px; -} - -.capability-features span { - padding: 4px 7px; - font-size: 11px; -} - -.widget-option > footer { - grid-column: 3; - grid-row: 1 / span 2; - flex-direction: column; - align-items: flex-end; - border: 0; - margin: 0; - padding: 0; -} - -.credential-mode { - font-size: 11px; -} - -.widget-option .secondary-button { - min-height: 38px; - padding: 0 12px; - font-size: 12px; -} - -.widget-option > .widget-config, -.widget-option > .widget-advanced-config, -.widget-option > .widget-inline-connection { - grid-column: 1 / -1; -} - -@media (max-width: 1050px) { - .widget-option { - grid-template-columns: minmax(220px, 1fr) auto; - } - - .widget-option > p, - .widget-option > .capability-features { - grid-column: 1; - grid-row: auto; - margin-left: 50px; - } - - .widget-option > footer { - grid-column: 2; - } -} - -@media (max-width: 720px) { - .settings-topbar.dashboard-topbar, - .settings-topbar.dashboard-topbar .dashboard-topbar-inner { - height: 116px; - } - - .settings-topbar .dashboard-topbar-inner { - width: calc(100% - 28px); - grid-template-columns: minmax(0, 1fr) auto; - grid-template-rows: 56px 48px; - gap: 4px 12px; - } - - .settings-topbar .settings-search-slot { - grid-column: 1 / -1; - grid-row: 2; - } - - .settings-topbar .topbar-actions { - grid-column: 2; - grid-row: 1; - } - - .settings-topbar .saving, - .settings-topbar .icon-button.active { - display: none; - } - - .settings-sidebar { - top: 116px; - } - - .studio-steps { - grid-template-columns: 1fr; - } - - .widget-studio-toolbar { - flex-direction: column; - } - - .widget-option { - display: grid; - grid-template-columns: 1fr; - gap: 12px; - } - - .widget-option > header, - .widget-option > p, - .widget-option > .capability-features, - .widget-option > footer { - grid-column: 1; - grid-row: auto; - margin-left: 0; - } - - .widget-option > footer { - align-items: stretch; - } - - .integration-settings-list article { - grid-template-columns: 42px minmax(0, 1fr); - padding: 18px; - } - - .integration-settings-list .state-badge { - grid-column: 2; - justify-self: start; - } - - .integration-settings-list .credential-fields { - grid-template-columns: 1fr; - } -} - -@media (min-width: 2600px) { - .dashboard-canvas .app-grid { - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); - } - - .category-card-grid, - .integration-list, - .settings-page[data-section="favorites"] .compact-list { - grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); - } -} - -@media (max-width: 1399px) and (min-width: 1101px) { - .dashboard-workspace { - grid-template-columns: 232px minmax(0, 1fr); - } -} - -@media (max-width: 900px) { - .settings-topbar { - padding-right: 22px; - padding-left: 22px; - } - - .settings-layout { - width: 100%; - } - - .settings-content, - .settings-page[data-section="appearance"] .settings-content { - font-size: 15px; - } -} - -@media (max-width: 720px) { - .quick-links-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .search-results .quick-links-grid > a { - min-height: 94px; - grid-template-columns: 42px minmax(0, 1fr); - padding: 12px; - } - - .quick-links-grid > a > svg { - display: none; - } - - .quick-links-grid .result-icon { - width: 42px; - height: 42px; - } -} - -/* Finale visuele audit — launcher-first hiërarchie en compacte beheerflows. */ -.dashboard-insights-panel { - margin: 4px 0 30px; - border: 1px solid var(--stitch-border); - border-radius: 14px; - background: color-mix(in srgb, var(--stitch-surface) 66%, transparent); -} - -.dashboard-insights-panel > summary { - min-height: 68px; - display: grid; - grid-template-columns: 38px minmax(0, 1fr) auto; - align-items: center; - gap: 12px; - padding: 12px 16px; - color: var(--stitch-text); - cursor: pointer; - list-style: none; -} - -.dashboard-insights-panel > summary::-webkit-details-marker { - display: none; -} - -.dashboard-insights-panel > summary:hover { - background: var(--stitch-surface-low); -} - -.insights-summary-icon { - width: 38px; - height: 38px; - display: grid; - place-items: center; - border-radius: 10px; - color: var(--stitch-primary); - background: var(--stitch-primary-soft); -} - -.dashboard-insights-panel > summary > span:nth-child(2) { - min-width: 0; - display: grid; - gap: 2px; -} - -.dashboard-insights-panel > summary strong { - font-size: 14px; - font-weight: 650; -} - -.dashboard-insights-panel > summary small { - color: var(--stitch-text-muted); - font-size: 12px; -} - -.insights-chevron { - color: var(--stitch-text-muted); - transition: transform 160ms ease; -} - -.dashboard-insights-panel[open] .insights-chevron { - transform: rotate(90deg); -} - -.dashboard-insights-panel[open] > summary { - border-bottom: 1px solid var(--stitch-border); -} - -.dashboard-insights-panel .dashboard-widgets { - margin: 0; - padding: 20px; -} - -.dashboard-insights-panel .widgets-section > header { - margin-bottom: 18px; -} - -.settings-sidebar nav button.active, -.settings-page[data-section="appearance"] .settings-sidebar nav button.active { - color: var(--stitch-on-primary); - background: var(--stitch-primary); -} - -.settings-content { - padding-right: clamp(28px, 4vw, 64px); - padding-left: clamp(28px, 4vw, 64px); -} - -.settings-heading > p:last-child, -.integration-list article small, -.security-callout small, -.management-card small, -.widget-option > p { - overflow-wrap: anywhere; -} - -.management-title small, -.rail-favorites nav a small, -.rail-widget-link small, -.rail-empty-action small, -.results-note, -.widget-option > header small { - font-size: 11px; -} - -.new-app-badge { - display: inline-flex; - margin-left: 8px; - border: 1px solid color-mix(in srgb, var(--stitch-success) 42%, transparent); - border-radius: 5px; - padding: 2px 5px; - color: var(--stitch-success-bright); - background: color-mix(in srgb, var(--stitch-success) 10%, transparent); - font-size: 9px; - font-weight: 750; - letter-spacing: 0.06em; - text-transform: uppercase; -} - -.pagination { - min-height: 54px; - display: flex; - align-items: center; - justify-content: flex-end; - gap: 12px; - margin-top: 14px; -} - -.pagination button, -.catalog-more { - min-height: 38px; - border: 1px solid var(--stitch-border); - border-radius: 9px; - padding: 0 14px; - color: var(--stitch-text); - background: var(--stitch-surface-low); -} - -.pagination button:hover:not(:disabled), -.catalog-more:hover { - border-color: var(--stitch-primary); - background: var(--stitch-primary-soft); -} - -.pagination button:disabled { - color: var(--stitch-text-muted); - opacity: 0.46; -} - -.pagination span { - color: var(--stitch-text-muted); - font-size: 12px; -} - -.catalog-more { - display: flex; - margin: 18px auto 0; -} - -.category-card-grid { - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); - gap: 14px; -} - -.category-card-grid .row-card { - min-height: 176px; - gap: 12px; - padding: 16px; -} - -.category-card-grid .row-card label > span { - font-size: 11px; -} - -.category-item-count { - white-space: nowrap; -} - -.category-position { - right: 58px; - bottom: 20px; -} - -.settings-page[data-section="favorites"] .compact-list { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); - align-items: start; - gap: 16px; -} - -.favorite-management { - min-width: 0; - padding: 18px; -} - -.favorite-management .management-title { - display: grid; - grid-template-columns: 42px minmax(0, 1fr) auto auto; - gap: 10px; -} - -.favorite-editor { - margin-top: 16px; - border-top: 1px solid var(--stitch-border); -} - -.favorite-editor > summary { - padding: 14px 0 0; - color: var(--stitch-primary); - font-size: 12px; - font-weight: 650; - cursor: pointer; -} - -.favorite-editor .favorite-fields { - margin-top: 14px; -} - -.integration-section-heading { - display: grid; - gap: 4px; - margin: 28px 0 14px; -} - -.integration-section-heading strong { - color: var(--stitch-text); - font-size: 18px; -} - -.integration-section-heading small { - color: var(--stitch-text-muted); - font-size: 12px; -} - -.integration-list { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); - gap: 16px; -} - -.integration-list article { - position: relative; - min-width: 0; - min-height: 188px; - display: grid; - grid-template-columns: 46px minmax(0, 1fr); - grid-template-rows: 1fr; - align-items: start; - gap: 14px; - border: 1px solid var(--stitch-border); - border-radius: 14px; - padding: 18px; - background: var(--stitch-surface-low); -} - -.integration-list article > span:nth-child(2) { - min-width: 0; - display: grid; - gap: 7px; - padding-top: 4px; - padding-right: 74px; -} - -.integration-list article > span:nth-child(2) > strong { - font-size: 15px; -} - -.integration-list article > span:nth-child(2) > small { - color: var(--stitch-text-muted); - font-size: 12px; - line-height: 1.5; -} - -.integration-list .state-badge { - position: absolute; - top: 18px; - right: 18px; - width: auto; - white-space: nowrap; -} - -.theme-grid { - gap: 18px; - margin-bottom: 30px; - padding-bottom: 30px; -} - -.theme-card { - min-height: 176px; - grid-template-rows: 126px auto; -} - -.theme-card .theme-preview { - height: 126px; -} - -.settings-page[data-section="appearance"] .settings-content { - padding-right: clamp(28px, 4vw, 64px); - padding-left: clamp(28px, 4vw, 64px); -} - -.canon-preference-columns { - gap: 24px; - margin-bottom: 30px; - padding-bottom: 30px; -} - -.canon-preference-columns .option-card, -.view-card { - min-height: 84px; -} - -.toggle-list article { - min-height: 82px; -} - -.appearance-advanced { - overflow: hidden; - border: 1px solid var(--stitch-border); - border-radius: 14px; - background: var(--stitch-surface-low); -} - -.appearance-advanced > summary { - min-height: 72px; - display: flex; - align-items: center; - padding: 14px 18px; - color: var(--stitch-text); - cursor: pointer; -} - -.appearance-advanced > summary > span { - display: grid; - gap: 3px; -} - -.appearance-advanced > summary strong { - font-size: 15px; -} - -.appearance-advanced > summary small { - color: var(--stitch-text-muted); - font-size: 12px; -} - -.appearance-advanced-content { - border-top: 1px solid var(--stitch-border); - padding: 4px 18px 22px; -} - -@media (max-width: 900px) { - .settings-layout { - display: block; - min-height: calc(100vh - 72px); - } - - .settings-sidebar { - position: sticky; - top: 72px; - z-index: 35; - width: 100%; - height: auto; - overflow-x: auto; - border-right: 0; - border-bottom: 1px solid var(--stitch-border); - padding: 10px 16px; - background: var(--stitch-surface-low); - scrollbar-width: none; - } - - .settings-sidebar::-webkit-scrollbar, - .settings-sidebar nav::-webkit-scrollbar { - display: none; - } - - .settings-sidebar > .eyebrow, - .settings-sidebar > h1, - .settings-sidebar .local-note { - display: none; - } - - .settings-sidebar nav { - display: flex; - width: max-content; - gap: 8px; - } - - .settings-sidebar nav button { - min-height: 42px; - flex: 0 0 auto; - gap: 8px; - padding: 0 14px; - font-size: 12px; - } - - .settings-content, - .settings-page[data-section="appearance"] .settings-content { - max-width: none; - padding: 28px 22px 100px; - } -} - -@media (max-width: 600px) { - .settings-topbar { - height: 116px; - grid-template-columns: minmax(0, 1fr) auto; - grid-template-rows: 56px 44px; - gap: 4px 12px; - padding: 4px 16px 12px; - } - - .settings-topbar .settings-brand { - grid-column: 1; - grid-row: 1; - justify-self: start; - } - - .settings-topbar .saving { - grid-column: 2; - grid-row: 1; - } - - .settings-global-search { - grid-column: 1 / -1; - grid-row: 2; - width: 100%; - } - - .settings-global-search input::placeholder { - color: var(--stitch-text-muted); - opacity: 1; - } - - .settings-sidebar { - top: 116px; - } - - .settings-content, - .settings-page[data-section="appearance"] .settings-content { - padding: 24px 16px 96px; - } - - .settings-heading { - margin-bottom: 18px; - } - - .settings-heading h2 { - font-size: 27px; - line-height: 1.18; - } - - .settings-heading > p:last-child { - font-size: 14px; - line-height: 1.55; - } - - .app-management-row { - min-height: 0; - padding: 14px; - } - - .pagination { - justify-content: space-between; - } - - .pagination span { - text-align: center; - } - - .category-card-grid, - .settings-page[data-section="favorites"] .compact-list, - .integration-list { - grid-template-columns: 1fr; - } - - .favorite-management .management-title { - grid-template-columns: 42px minmax(0, 1fr) auto; - } - - .favorite-management .favorite-visit-link { - grid-column: 2; - justify-self: start; - margin-left: 0; - } - - .theme-grid { - gap: 14px; - margin-bottom: 24px; - padding-bottom: 24px; - } - - .theme-card { - min-height: 160px; - grid-template-rows: 112px auto; - } - - .theme-card .theme-preview { - height: 112px; - } - - .dashboard-insights-panel { - margin-bottom: 24px; - } - - .dashboard-insights-panel .dashboard-widgets { - padding: 14px; - } -} - -@media (prefers-reduced-motion: reduce) { - .app-tile, - .theme-card, - .visibility-toggle::after { - transition: none; - } -} - -/* Canon settings upgrades: global discovery, richer management cards and safe restore. */ -.settings-topbar { - grid-template-columns: auto minmax(280px, 520px) 1fr auto; -} - -.settings-global-search { - min-width: 0; - height: 42px; - display: flex; - align-items: center; - gap: 10px; - border: 1px solid var(--stitch-border); - border-radius: 10px; - padding: 0 12px; - color: var(--stitch-text-muted); - background: var(--stitch-surface-low); -} - -.settings-global-search:focus-within { - border-color: var(--stitch-primary); - box-shadow: 0 0 0 3px - color-mix(in srgb, var(--stitch-primary) 14%, transparent); -} - -.settings-global-search input { - width: 100%; - min-width: 0; - border: 0; - padding: 0; - color: var(--stitch-text); - background: transparent; - outline: 0; -} - -.settings-global-search button { - width: 28px; - height: 28px; - flex: 0 0 28px; - display: grid; - place-items: center; - border: 0; - border-radius: 7px; - padding: 0; - color: var(--stitch-text-muted); - background: transparent; -} - -.settings-global-search button:hover { - color: var(--stitch-text); - background: var(--stitch-surface-high); -} - -.settings-search-empty { - margin: 8px 12px; - color: var(--stitch-text-muted); - font-size: 12px; - line-height: 1.55; -} - -.inline-select-control select { - min-width: 150px; - height: 40px; - border-color: var(--stitch-border); - background: var(--stitch-surface-high); -} - -.favorite-visit-link { - display: inline-flex; - align-items: center; - gap: 5px; - margin-left: auto; - color: var(--stitch-primary); - font-size: 12px; - font-weight: 600; - text-decoration: none; -} - -.favorite-management .danger-icon { - order: 4; -} - -.favorite-management .favorite-visit-link { - order: 3; -} - -.category-card-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 18px; -} - -.category-card-grid .row-card { - position: relative; - min-height: 220px; - display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; - align-content: start; - gap: 16px 12px; - padding: 20px; - border-radius: 14px; -} - -.category-card-grid .row-card label, -.category-card-grid .row-card label.grow, -.category-card-grid .row-card label.icon-field, -.category-card-grid .row-card label.small-field { - width: auto; - margin: 0; -} - -.category-card-grid .row-card label.grow { - grid-column: 1 / -1; -} - -.category-card-grid .row-card label.icon-field { - grid-column: 1 / 3; -} - -.category-card-grid .row-card label.small-field { - grid-column: 3; -} - -.category-card-grid .row-card .danger-icon { - grid-column: 3; - justify-self: end; -} - -.category-icon-preview { - width: 48px; - height: 48px; - display: grid; - place-items: center; - border-radius: 12px; - color: var(--stitch-primary); - background: var(--stitch-surface-highest); -} - -.category-item-count { - grid-column: 3; - align-self: start; - border: 1px solid color-mix(in srgb, var(--stitch-success) 38%, transparent); - border-radius: 999px; - padding: 5px 9px; - color: var(--stitch-success-bright); - background: color-mix(in srgb, var(--stitch-success) 10%, transparent); - font-size: 11px; -} - -.category-position { - position: absolute; - right: 64px; - bottom: 26px; - color: var(--stitch-text-muted); - font: 600 10px/1 var(--font-mono, monospace); -} - -.backup-import-card { - display: flex; - flex-direction: column; -} - -.backup-dropzone { - min-height: 188px; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 8px; - border: 1px dashed var(--stitch-border-strong); - border-radius: 12px; - padding: 18px; - color: var(--stitch-text); - background: color-mix(in srgb, var(--stitch-surface-high) 70%, transparent); - text-align: center; -} - -.backup-dropzone:hover, -.backup-dropzone.dragging { - border-color: var(--stitch-primary); - background: color-mix( - in srgb, - var(--stitch-primary) 8%, - var(--stitch-surface-high) - ); -} - -.backup-dropzone .backup-icon { - margin-bottom: 4px; -} - -.backup-dropzone > span:not(.backup-icon), -.backup-dropzone small, -.backup-success { - color: var(--stitch-text-muted); - font-size: 11px; -} - -.backup-import-card > p { - min-height: 0; - margin: 14px 0 0; -} - -.backup-success { - margin-top: 8px; - color: var(--stitch-success-bright); -} - -html[data-reduced-motion="true"] *, -html[data-reduced-motion="true"] *::before, -html[data-reduced-motion="true"] *::after { - scroll-behavior: auto !important; - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - transition-duration: 0.01ms !important; -} - -@media (max-width: 960px) { - .settings-topbar { - grid-template-columns: auto minmax(180px, 1fr) auto; - gap: 14px; - } - - .settings-topbar nav { - display: none; - } - - .category-card-grid { - grid-template-columns: 1fr; - } -} - -@media (max-width: 600px) { - .settings-topbar { - grid-template-columns: auto minmax(0, 1fr) auto; - padding: 0 14px; - } - - .settings-global-search { - height: 38px; - } - - .settings-global-search input::placeholder { - color: transparent; - } - - .inline-select-control { - grid-column: 1 / -1; - width: 100%; - } - - .inline-select-control select { - width: 100%; - } -} - -/* Stitch launcher workspace: favorite rail + first-class live widgets. */ -.app-brand-icon { - width: var(--tile-icon); - height: var(--tile-icon); - flex: 0 0 var(--tile-icon); - display: inline-flex; - align-items: center; - justify-content: center; - overflow: hidden; - border-radius: 12px; - color: var(--stitch-primary); - background: var(--stitch-surface-highest); -} - -.app-brand-icon svg { - width: 58%; - height: 58%; - flex: 0 0 auto; -} - -.app-brand-icon.actual-brand { - color: var(--stitch-text); - background: color-mix(in srgb, var(--stitch-surface-highest) 88%, white 12%); -} - -.dashboard-topbar-inner, -.dashboard-workspace { - width: min(calc(100% - 48px), 1680px); -} - -.dashboard-workspace { - margin: 0 auto; - display: grid; - grid-template-columns: 248px minmax(0, 1fr); - align-items: start; - gap: 32px; -} - -.dashboard-canvas { - width: 100%; - min-width: 0; - margin: 0; -} - -.dashboard-rail { - position: sticky; - top: 88px; - height: calc(100vh - 112px); - min-height: 520px; - display: flex; - flex-direction: column; - gap: 22px; - overflow: hidden; - border: 1px solid var(--stitch-border); - border-radius: 18px; - padding: 18px; - background: color-mix(in srgb, var(--stitch-surface) 72%, transparent); - backdrop-filter: blur(12px); -} - -.rail-intro { - display: flex; - align-items: flex-end; - justify-content: space-between; - gap: 12px; - border-bottom: 1px solid var(--stitch-border); - padding: 0 2px 16px; -} - -.rail-intro span { - max-width: 110px; - color: var(--stitch-text-muted); - font-size: 11px; - font-weight: 600; - line-height: 15px; - text-transform: capitalize; -} - -.rail-intro strong { - color: var(--stitch-text); - font-size: 20px; - font-weight: 550; - letter-spacing: -0.03em; -} - -.rail-favorites { - min-height: 0; - display: flex; - flex: 1 1 auto; - flex-direction: column; -} - -.rail-favorites > header, -.rail-deck-health > header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; -} - -.rail-favorites > header > span, -.rail-deck-health > header > span { - display: inline-flex; - align-items: center; - gap: 8px; - color: var(--stitch-text); - font-size: 13px; - font-weight: 650; - letter-spacing: 0.02em; -} - -.rail-favorites > header svg, -.rail-deck-health > header svg { - color: var(--stitch-primary); -} - -.rail-favorites > header button { - width: 30px; - height: 30px; - display: inline-grid; - place-items: center; - border: 1px solid var(--stitch-border); - border-radius: 9px; - padding: 0; - color: var(--stitch-text-muted); - background: transparent; -} - -.rail-favorites > header button:hover { - border-color: var(--stitch-border-strong); - color: var(--stitch-text); - background: var(--stitch-surface-high); -} - -.rail-favorites nav { - min-height: 0; - display: grid; - align-content: start; - gap: 6px; - overflow-y: auto; - margin-top: 12px; - padding-right: 2px; - scrollbar-width: thin; -} - -.rail-favorites nav a { - min-width: 0; - min-height: 54px; - display: grid; - grid-template-columns: 38px minmax(0, 1fr) auto; - align-items: center; - gap: 10px; - border: 1px solid transparent; - border-radius: 11px; - padding: 7px 8px; - color: var(--stitch-text); -} - -.rail-favorites nav a:hover { - border-color: var(--stitch-border); - background: var(--stitch-surface-high); - transform: translateX(2px); -} - -.rail-favorites .rail-app-icon { - width: 38px; - height: 38px; - flex-basis: 38px; - border-radius: 9px; - padding: 7px; - object-fit: contain; -} - -.rail-favorites .rail-app-icon.app-brand-icon { - padding: 0; -} - -.rail-favorites nav a > span:nth-child(2) { - min-width: 0; - display: grid; -} - -.rail-favorites nav a strong, -.rail-widget-link strong, -.rail-empty-action strong { - overflow: hidden; - color: var(--stitch-text); - font-size: 13px; - font-weight: 600; - text-overflow: ellipsis; - white-space: nowrap; -} - -.rail-favorites nav a small, -.rail-widget-link small, -.rail-empty-action small { - overflow: hidden; - color: var(--stitch-text-muted); - font-size: 10px; - line-height: 15px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.rail-favorites nav a > svg { - color: var(--stitch-text-muted); - opacity: 0; - transition: opacity 160ms ease; -} - -.rail-favorites nav a:hover > svg { - opacity: 1; -} - -.rail-empty-action { - min-height: 92px; - display: flex; - align-items: center; - gap: 11px; - margin-top: 12px; - border: 1px dashed var(--stitch-border-strong); - border-radius: 12px; - padding: 14px; - color: var(--stitch-primary); - background: transparent; - text-align: left; -} - -.rail-empty-action > span { - min-width: 0; - display: grid; -} - -.rail-deck-health { - border: 1px solid var(--stitch-border); - border-radius: 12px; - padding: 13px; - background: var(--stitch-surface-low); -} - -.rail-deck-health > header > strong { - color: var(--stitch-success-bright); - font-size: 12px; -} - -.rail-health-bar { - height: 4px; - overflow: hidden; - margin-top: 12px; - border-radius: 999px; - background: var(--stitch-surface-highest); -} - -.rail-health-bar span { - height: 100%; - display: block; - border-radius: inherit; - background: var(--stitch-success-bright); -} - -.rail-deck-health p { - display: flex; - align-items: center; - gap: 6px; - margin: 9px 0 10px; - color: var(--stitch-text-muted); - font-size: 10px; - line-height: 14px; -} - -.rail-deck-health p svg { - flex: 0 0 auto; - color: var(--stitch-success-bright); -} - -.rail-deck-health > button { - display: inline-flex; - align-items: center; - gap: 6px; - border: 0; - padding: 0; - color: var(--stitch-primary); - background: transparent; - font-size: 10px; - font-weight: 650; -} - -.rail-widget-link { - min-height: 60px; - display: grid; - grid-template-columns: 36px minmax(0, 1fr) auto; - align-items: center; - gap: 10px; - border: 1px solid var(--stitch-border); - border-radius: 12px; - padding: 10px; - color: var(--stitch-text-muted); - background: transparent; - text-align: left; -} - -.rail-widget-link:hover { - border-color: color-mix( - in srgb, - var(--stitch-primary) 45%, - var(--stitch-border) - ); - background: var(--stitch-surface-high); -} - -.rail-widget-icon { - width: 36px; - height: 36px; - display: grid; - place-items: center; - border-radius: 9px; - color: var(--stitch-primary); - background: var(--stitch-primary-soft); -} - -.rail-widget-link > span:nth-child(2) { - min-width: 0; - display: grid; -} - -.app-tile > .app-brand-icon { - grid-column: 1; - grid-row: 1; - width: 40px; - height: 40px; - border-radius: 8px; -} - -.app-tile.feature > .app-brand-icon { - grid-column: 1; - grid-row: 1; - width: 64px; - height: 64px; - border: 1px solid color-mix(in srgb, var(--stitch-primary) 28%, transparent); - border-radius: 999px; - background: color-mix(in srgb, var(--stitch-primary) 12%, transparent); -} - -.favorite-grid .app-tile > .app-brand-icon { - width: 48px; - height: 48px; - flex: 0 0 48px; -} - -.management-title > .app-brand-icon, -.drawer-app-title > .app-brand-icon, -.result-icon > .app-brand-icon { - width: 42px; - height: 42px; - flex-basis: 42px; - border-radius: 10px; -} - -.dashboard-widgets { - margin: 0 0 40px; -} - -.dashboard-widgets:empty { - display: none; -} - -.dashboard-widgets .widgets-section { - margin: 0; -} - -.dashboard-widgets .widgets-section > header { - margin-bottom: 16px; -} - -.dashboard-widgets .widgets-section .section-kicker { - color: var(--stitch-primary); - font-size: 10px; - letter-spacing: 0.12em; -} - -.dashboard-widgets .widgets-section h2 { - color: var(--stitch-text); - font-size: 20px; - font-weight: 520; - letter-spacing: -0.02em; -} - -.dashboard-widgets .widgets-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 16px; -} - -.dashboard-widgets .widget-shell.widget-size-wide { - grid-column: 1 / -1; -} - -.dashboard-widgets .service-widget, -.dashboard-widgets .server-widget { - border: 1px solid var(--stitch-border); - border-radius: 16px; - color: var(--stitch-text); - background: color-mix(in srgb, var(--stitch-surface) 74%, transparent); - box-shadow: none; -} - -.dashboard-widgets .service-widget:hover, -.dashboard-widgets .server-widget:hover { - border-color: color-mix( - in srgb, - var(--stitch-primary) 40%, - var(--stitch-border) - ); - box-shadow: none; - transform: translateY(-2px); -} - -.dashboard-widgets .widget-service-icon.app-brand-icon { - width: 42px; - height: 42px; - flex-basis: 42px; -} - -.dashboard-widgets .widgets-edit, -.dashboard-widgets .widgets-refresh { - border: 1px solid var(--stitch-border); - border-radius: 9px; - color: var(--stitch-text-muted); - background: transparent; -} - -.dashboard-widgets .widgets-edit:hover, -.dashboard-widgets .widgets-refresh:hover, -.dashboard-widgets .widgets-edit.active { - border-color: var(--stitch-border-strong); - color: var(--stitch-text); - background: var(--stitch-surface-high); -} - -.app-tile > .app-brand-icon.actual-brand, -.dashboard-widgets .app-brand-icon.actual-brand, -.rail-favorites .app-brand-icon.actual-brand, -.management-title > .app-brand-icon.actual-brand, -.drawer-app-title > .app-brand-icon.actual-brand, -.result-icon > .app-brand-icon.actual-brand { - background: #f3f4f8; -} - -.app-tile > .app-icon-image, -.dashboard-widgets .app-icon-image, -.rail-favorites img.rail-app-icon, -.management-title > .app-icon-image, -.drawer-app-title > .app-icon-image, -.result-icon > .app-icon-image { - background: #f3f4f8; -} - -@media (min-width: 1101px) { - .favorites-section { - display: none; - } -} - -@media (max-width: 1300px) and (min-width: 1101px) { - .dashboard-workspace { - grid-template-columns: 224px minmax(0, 1fr); - gap: 24px; - } - - .dashboard-rail { - padding: 15px; - } - - .dashboard-canvas .app-grid { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } - - .dashboard-canvas .app-grid.feature-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } -} - -@media (max-width: 1100px) { - .dashboard-workspace { - width: 100%; - display: block; - } - - .dashboard-rail { - display: none; - } - - .dashboard-canvas { - width: min(calc(100% - 48px), var(--stitch-canvas)); - margin: 0 auto; - display: flex; - flex-direction: column; - } - - .dashboard-widgets .widgets-grid { - grid-template-columns: 1fr; - } - - .dashboard-widgets .widget-shell.widget-size-wide { - grid-column: auto; - } - - .dashboard-widgets { - order: 10; - } - - .dashboard-footer { - order: 11; - } -} - -@media (max-width: 720px) { - .dashboard-canvas { - width: calc(100% - 44px); - } - - .favorite-grid .app-tile > .app-brand-icon { - width: 54px; - height: 54px; - flex-basis: 54px; - } - - .app-tile.standard > .app-brand-icon { - grid-column: 1; - grid-row: 1; - width: 56px; - height: 56px; - } - - .app-tile.feature > .app-brand-icon { - width: 52px; - height: 52px; - flex: 0 0 52px; - margin: 0 auto; - border: 0; - border-radius: 12px; - background: var(--stitch-surface-low); - } - - .dashboard-widgets { - margin-bottom: 42px; - } - - .dashboard-widgets .widgets-section > header { - align-items: flex-start; - } - - .dashboard-widgets .widgets-actions { - width: 100%; - } - - .dashboard-widgets .widgets-edit, - .dashboard-widgets .widgets-refresh { - flex: 1 1 0; - justify-content: center; - } -} - -/* Cascade guard: platform geometry must win over the legacy launcher layer. */ -.dashboard-topbar-inner, -.dashboard-workspace { - width: calc(100% - var(--platform-inset)); - max-width: var(--platform-max); -} - -.dashboard-workspace { - grid-template-columns: clamp(248px, 15vw, 292px) minmax(0, 1fr); - gap: clamp(24px, 2vw, 36px); -} - -.dashboard-rail { - padding: 20px; -} - -.rail-intro span, -.rail-favorites nav a small, -.rail-widget-link small, -.rail-empty-action small, -.rail-deck-health p, -.rail-deck-health > button { - font-size: 12px; - line-height: 1.45; -} - -.rail-favorites nav a strong, -.rail-widget-link strong, -.rail-empty-action strong, -.rail-favorites > header > span, -.rail-deck-health > header > span { - font-size: 14px; -} - -.settings-layout { - width: calc(100% - var(--platform-inset)); - max-width: var(--platform-max); - grid-template-columns: clamp(300px, 18vw, 348px) minmax(0, 1fr); - margin: 0 auto; -} - -.settings-content, -.settings-page[data-section="appearance"] .settings-content { - max-width: none; - padding-right: clamp(40px, 4vw, 76px); - padding-left: clamp(40px, 4vw, 76px); - font-size: 16px; -} - -.settings-sidebar nav button { - font-size: 15.5px; - font-weight: 520; -} - -.settings-heading .eyebrow { - font-size: 13px; -} - -.settings-heading h2 { - font-size: clamp(34px, 2vw, 40px); - line-height: 1.15; -} - -.settings-heading > p:last-child { - max-width: 900px; - font-size: 17px; - line-height: 1.6; -} - -.settings-content :where(input, select, textarea, button) { - font-size: 15px; -} - -.settings-global-search input { - font-size: 15.5px; -} - -.settings-content - :where( - .management-title small, - .management-card small, - .widget-option > p, - .widget-option > header small, - .integration-list article small, - .security-callout small, - .toggle-list article small, - .appearance-advanced small, - .integration-section-heading small - ) { - font-size: 13px; - line-height: 1.55; -} - -@media (min-width: 1900px) { - .dashboard-canvas { - padding-right: calc(var(--widget-dock-width) + 32px); - } - - .dashboard-insights-panel { - position: fixed; - z-index: 20; - top: 96px; - right: max(var(--platform-gutter), calc((100vw - var(--platform-max)) / 2)); - width: var(--widget-dock-width); - max-height: calc(100vh - 120px); - overflow: auto; - margin: 0; - background: color-mix(in srgb, var(--stitch-surface) 92%, transparent); - box-shadow: 0 20px 70px rgb(0 0 0 / 0.18); - backdrop-filter: blur(20px); - scrollbar-width: thin; - } - - .dashboard-insights-panel > summary { - position: sticky; - z-index: 3; - top: 0; - min-height: 76px; - background: color-mix(in srgb, var(--stitch-surface) 96%, transparent); - backdrop-filter: blur(16px); - } - - .dashboard-insights-panel .widgets-grid { - grid-template-columns: 1fr; - } - - .dashboard-insights-panel .widget-shell.widget-size-wide { - grid-column: auto; - } - - .dashboard-canvas .app-grid { - grid-template-columns: repeat(auto-fill, minmax(235px, 1fr)); - } - - .dashboard-canvas .app-grid.feature-grid { - grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); - } -} - -@media (max-width: 1399px) and (min-width: 1101px) { - .dashboard-workspace { - grid-template-columns: 232px minmax(0, 1fr); - } -} - -@media (max-width: 1100px) { - .dashboard-workspace { - width: 100%; - display: block; - } -} - -@media (max-width: 900px) { - .settings-layout { - width: 100%; - display: block; - } - - .settings-content, - .settings-page[data-section="appearance"] .settings-content { - padding: 28px 22px 100px; - font-size: 15px; - } -} - -@media (max-width: 600px) { - .settings-content, - .settings-page[data-section="appearance"] .settings-content { - padding: 24px 16px 96px; - } -} - -/* Stitch edge rails: the dashboard and settings navigation now use the same - anchored shell language instead of floating inside a conservative canvas. */ -@media (min-width: 901px) { - .settings-layout { - width: 100%; - max-width: none; - margin: 0; - grid-template-columns: clamp(300px, 18vw, 348px) minmax(0, 1fr); - } - - .settings-sidebar { - top: 72px; - height: calc(100vh - 72px); - min-height: calc(100vh - 72px); - border-radius: 0; - padding: 28px 14px 28px 30px; - background: var(--stitch-surface-low); - box-shadow: none; - } -} - -@media (min-width: 1101px) { - .dashboard-topbar-inner { - width: calc(100% - 64px); - max-width: none; - } - - .dashboard-workspace { - width: 100%; - max-width: none; - margin: 0; - grid-template-columns: clamp(300px, 18vw, 348px) minmax(0, 1fr); - gap: 0; - align-items: stretch; - } - - .dashboard-rail { - position: sticky; - top: 72px; - z-index: 24; - align-self: start; - height: calc(100vh - 72px); - min-height: calc(100vh - 72px); - overflow-y: auto; - border: 0; - border-right: 1px solid var(--stitch-border-strong); - border-radius: 0; - padding: 28px 14px 28px 30px; - background: var(--stitch-surface-low); - box-shadow: none; - backdrop-filter: none; - scrollbar-width: thin; - } - - .dashboard-canvas { - width: 100%; - max-width: none; - padding: 34px clamp(34px, 4vw, 78px) 72px; - } - - .dashboard-groups { - gap: clamp(38px, 3vw, 54px); - } - - .app-section > header { - margin-bottom: 18px; - } - - .app-section > header h2 { - font-size: clamp(22px, 1.45vw, 28px); - font-weight: 560; - letter-spacing: -0.035em; - } - - .dashboard-canvas .app-grid { - grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); - gap: 16px; - } - - .dashboard-canvas .app-grid.feature-grid { - grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); - } -} - -.rail-intro { - align-items: flex-start; - padding: 0 16px 20px; -} - -.rail-intro span { - max-width: 150px; - font-size: 12.5px; - line-height: 1.45; -} - -.rail-intro strong { - font-size: 24px; - font-weight: 620; -} - -.rail-favorites, -.rail-deck-health, -.rail-widget-link, -.rail-empty-action { - border-radius: 8px; -} - -.rail-favorites { - padding: 0; -} - -.rail-favorites > header, -.rail-deck-health > header { - padding: 0 16px; -} - -.rail-favorites > header > span, -.rail-deck-health > header > span { - font-size: 14px; - font-weight: 650; - letter-spacing: 0; -} - -.rail-favorites nav { - gap: 6px; - margin-top: 14px; - padding-right: 0; -} - -.rail-favorites nav a, -.rail-widget-link, -.rail-empty-action { - min-height: 52px; - border: 0; - border-radius: 8px; - padding: 0 16px; - background: transparent; -} - -.rail-favorites nav a { - grid-template-columns: 38px minmax(0, 1fr) auto; - gap: 12px; -} - -.rail-favorites nav a:hover, -.rail-widget-link:hover, -.rail-empty-action:hover { - background: var(--stitch-surface); - transform: none; -} - -.rail-favorites .rail-app-icon { - width: 38px; - height: 38px; - border-radius: 8px; - padding: 7px; -} - -.rail-favorites nav a strong, -.rail-widget-link strong, -.rail-empty-action strong { - font-size: 14px; - font-weight: 590; -} - -.rail-favorites nav a small, -.rail-widget-link small, -.rail-empty-action small { - font-size: 12px; - line-height: 1.35; -} - -.rail-deck-health { - margin-top: 6px; - border: 1px solid var(--stitch-border); - padding: 18px 0; - background: color-mix(in srgb, var(--stitch-surface) 70%, transparent); -} - -.rail-deck-health p, -.rail-deck-health > button { - margin-right: 16px; - margin-left: 16px; -} - -.rail-widget-link { - grid-template-columns: 38px minmax(0, 1fr) auto; - gap: 12px; -} - -.rail-widget-icon { - width: 38px; - height: 38px; - border-radius: 8px; -} - -@media (min-width: 721px) { - .app-tile.standard { - min-height: 132px; - grid-template-columns: 64px minmax(0, 1fr) auto; - grid-template-rows: auto 1fr auto; - gap: 14px 16px; - overflow: hidden; - border-radius: 8px; - padding: 18px; - border-color: color-mix( - in srgb, - var(--stitch-border-strong) 70%, - transparent - ); - background: - radial-gradient( - circle at 0 0, - color-mix(in srgb, var(--stitch-primary) 16%, transparent), - transparent 38% - ), - linear-gradient( - 135deg, - color-mix(in srgb, var(--stitch-surface-high) 94%, white 6%), - var(--stitch-surface) - ); - box-shadow: 0 16px 42px rgb(0 0 0 / 0.08); - backdrop-filter: none; - } - - .app-tile.standard::before { - content: ""; - position: absolute; - inset: 0 auto 0 0; - width: 3px; - background: linear-gradient( - 180deg, - var(--stitch-primary), - color-mix(in srgb, var(--stitch-primary) 35%, transparent) - ); - opacity: 0.85; - } - - .app-tile.standard > .app-icon-image, - .app-tile.standard > .app-monogram, - .app-tile.standard > .app-brand-icon { - grid-column: 1; - grid-row: 1 / span 2; - align-self: center; - width: 58px; - height: 58px; - border: 1px solid color-mix(in srgb, var(--stitch-border) 80%, transparent); - border-radius: 8px; - padding: 10px; - background: #f3f4f8; - box-shadow: 0 10px 24px rgb(15 23 42 / 0.1); - } - - .app-tile.standard > .app-monogram { - padding: 0; - color: var(--stitch-primary); - background: color-mix(in srgb, var(--stitch-primary) 12%, white 88%); - font-size: 20px; - } - - .app-tile.standard .tile-copy { - grid-column: 2; - grid-row: 1 / span 2; - align-self: center; - gap: 4px; - } - - .app-tile.standard .tile-copy strong { - font-size: 17px; - font-weight: 650; - line-height: 1.2; - letter-spacing: -0.02em; - } - - .app-tile.standard .tile-copy small { - display: -webkit-box; - overflow: hidden; - color: var(--stitch-text-muted); - font-size: 13px; - line-height: 1.35; - text-overflow: ellipsis; - white-space: normal; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; - } - - .app-tile.standard .tile-status { - position: static; - grid-column: 1 / 3; - grid-row: 3; - justify-self: start; - min-height: 26px; - padding: 0 10px; - border-radius: 999px; - font-size: 12px; - } - - .app-tile.standard .tile-arrow { - grid-column: 3; - grid-row: 1; - width: 34px; - height: 34px; - display: inline-flex; - align-items: center; - justify-content: center; - border: 1px solid var(--stitch-border); - border-radius: 999px; - padding: 8px; - color: var(--stitch-text-muted); - background: color-mix( - in srgb, - var(--stitch-surface-highest) 88%, - transparent - ); - opacity: 1; - } - - .app-tile.standard:hover, - .app-tile.standard:focus-visible { - border-color: color-mix( - in srgb, - var(--stitch-primary) 54%, - var(--stitch-border) - ); - background: - radial-gradient( - circle at 0 0, - color-mix(in srgb, var(--stitch-primary) 22%, transparent), - transparent 42% - ), - var(--stitch-surface-high); - box-shadow: 0 22px 58px rgb(0 0 0 / 0.14); - transform: translateY(-3px); - } - - .app-tile.standard:hover .tile-arrow, - .app-tile.standard:focus-visible .tile-arrow { - border-color: color-mix( - in srgb, - var(--stitch-primary) 42%, - var(--stitch-border) - ); - color: var(--stitch-primary); - background: var(--stitch-primary-soft); - } - - .app-tile.feature { - min-height: 154px; - border-radius: 8px; - padding: 26px 30px; - border-color: color-mix( - in srgb, - var(--stitch-primary) 26%, - var(--stitch-border) - ); - background: - linear-gradient( - 135deg, - color-mix(in srgb, var(--stitch-primary) 16%, transparent), - transparent 42% - ), - var(--stitch-surface-high); - } - - .app-tile.feature > .app-icon-image, - .app-tile.feature > .app-monogram, - .app-tile.feature > .app-brand-icon { - border-radius: 8px; - } -} - -@media (min-width: 1900px) { - .dashboard-canvas { - padding-right: calc(var(--widget-dock-width) + 78px); - } - - .dashboard-insights-panel { - top: 72px; - right: 0; - width: var(--widget-dock-width); - height: calc(100vh - 72px); - max-height: none; - border-top: 0; - border-right: 0; - border-bottom: 0; - border-radius: 0; - background: var(--stitch-surface-low); - box-shadow: none; - backdrop-filter: none; - } - - .dashboard-insights-panel > summary { - min-height: 72px; - background: var(--stitch-surface-low); - backdrop-filter: none; - } -} - -/* Final cascade guard for the shared header and form-oriented settings views. */ -.dashboard-topbar, -.settings-topbar.dashboard-topbar { - height: 72px; - display: block; - padding: 0; -} - -.dashboard-topbar-inner { - height: 72px; -} - -.settings-topbar .dashboard-topbar-inner { - grid-template-columns: minmax(190px, 1fr) minmax(320px, 560px) auto auto; -} - -.integration-list.integration-settings-list { - display: flex; - grid-template-columns: none; - flex-direction: column; - gap: 0; -} - -.integration-list.integration-settings-list article { - min-height: 0; - grid-template-columns: 46px minmax(0, 1fr) auto; - gap: 16px; - border: 0; - border-bottom: 1px solid var(--stitch-border); - border-radius: 0; - padding: 22px 24px; - background: transparent; -} - -.integration-list.integration-settings-list article:last-child { - border-bottom: 0; -} - -.integration-list.integration-settings-list article > span:nth-child(2) { - gap: 8px; - padding: 2px 0 0; -} - -.integration-list.integration-settings-list .state-badge { - position: static; - align-self: start; - justify-self: end; - margin: 2px 0 0; -} - -@media (max-width: 1300px) and (min-width: 721px) { - .settings-topbar .dashboard-topbar-inner { - grid-template-columns: auto minmax(260px, 1fr) auto; - } - - .settings-topbar nav { - display: none; - } -} - -@media (max-width: 720px) { - .settings-topbar.dashboard-topbar, - .settings-topbar.dashboard-topbar .dashboard-topbar-inner { - height: 116px; - } - - .settings-topbar .dashboard-topbar-inner { - grid-template-columns: minmax(0, 1fr) auto; - grid-template-rows: 56px 48px; - } - - .integration-list.integration-settings-list article { - grid-template-columns: 42px minmax(0, 1fr); - padding: 18px; - } -} - -/* Integration typography must remain readable after all legacy overrides. */ -.settings-page[data-section="integrations"] .security-callout { - gap: 14px; - padding: 18px 20px; -} - -.settings-page[data-section="integrations"] .security-callout strong, -.settings-page[data-section="integrations"] .external-toggle strong { - font-size: 15px; -} - -.settings-page[data-section="integrations"] .security-callout small, -.settings-page[data-section="integrations"] .integration-section-heading small, -.settings-page[data-section="integrations"] - .integration-settings-list - article - small, -.settings-page[data-section="integrations"] .external-toggle small, -.settings-page[data-section="integrations"] .credential-heading small { - font-size: 14px; - line-height: 1.55; -} - -.settings-page[data-section="integrations"] .external-toggle { - min-height: 84px; - padding: 18px 20px; -} - -.dashboard-topbar nav button, -.settings-topbar nav button { - border-radius: 6px 6px 0 0; - padding-right: 9px; - padding-left: 9px; -} - -.dashboard-topbar nav button:hover, -.settings-topbar nav button:hover, -.dashboard-topbar nav button:focus-visible, -.settings-topbar nav button:focus-visible { - color: var(--stitch-text); - background: var(--stitch-primary-soft); -} - -/* Widescreen audit: readable service panels and deliberate use of long canvases. */ -.settings-content :where(small, .integration-copy) { - font-size: 13px !important; - line-height: 1.5; -} - -.settings-content :where(label > span:first-child, .setting-label) { - font-size: 12px !important; - line-height: 1.35; -} - -.settings-content - :where( - .settings-summary span, - .filter-pills button, - .results-note, - .pagination span, - .favorite-visit-link, - .favorite-editor > summary - ) { - font-size: 12px; -} - -.server-widget-config > header strong, -.widget-studio-heading strong { - font-size: 16px; -} - -.widget-config-heading > span, -.widget-content-picker legend, -.widget-advanced-config summary { - font-size: 12px; -} - -.widget-size-picker strong, -.widget-content-picker strong, -.widget-config-section > header strong { - font-size: 13px; -} - -.capability-features span, -.credential-mode { - font-size: 11px; - line-height: 1.35; -} - -.widget-option .secondary-button { - min-height: 38px; - font-size: 12px; -} - -.backup-grid p { - font-size: 14px; -} - -.privacy-note strong { - font-size: 14px; -} - -.category-organizer { - max-width: 1120px; - display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; - align-items: center; - gap: 14px; - margin-bottom: 18px; - border: 1px solid - color-mix(in srgb, var(--stitch-primary) 30%, var(--stitch-border)); - border-radius: 14px; - padding: 16px 18px; - background: color-mix( - in srgb, - var(--stitch-primary) 7%, - var(--stitch-surface-low) - ); -} - -.category-organizer > span:nth-child(2) { - display: grid; - gap: 4px; -} - -.category-organizer strong { - font-size: 15px; -} - -@media (min-width: 1400px) { - .settings-page[data-section="apps"] - .settings-content - :where( - .settings-summary, - .management-toolbar, - .results-note, - .management-table-head, - .management-list, - .pagination - ) { - width: 100%; - max-width: 2400px; - } - - .settings-page[data-section="favorites"] .compact-list { - grid-template-columns: repeat(auto-fill, minmax(420px, 1fr)); - } - - .settings-page[data-section="categories"] .category-card-grid { - grid-template-columns: repeat(auto-fill, minmax(480px, 1fr)); - } - - .settings-page[data-section="integrations"] - :where(.integration-settings-list, .security-callout, .external-toggle) { - max-width: 2200px; - } - - .settings-page[data-section="backup"] :where(.backup-grid, .privacy-note) { - max-width: 2200px; - } -} - -@media (min-width: 1900px) { - :root { - --widget-dock-width: clamp(480px, 25vw, 540px); - } - - .dashboard-insights-panel .dashboard-widgets { - padding: 24px; - } - - .dashboard-insights-panel .widgets-grid { - gap: 16px; - } - - .dashboard-insights-panel .service-widget { - padding: 20px; - } - - .dashboard-insights-panel .widget-title small, - .dashboard-insights-panel .widget-state small, - .dashboard-insights-panel .provider-stats small, - .dashboard-insights-panel .provider-note, - .dashboard-insights-panel .widget-infra-line { - font-size: 11px !important; - line-height: 1.4; - } - - .dashboard-insights-panel .widget-title strong { - font-size: 17px; - } - - .dashboard-insights-panel .widget-state strong, - .dashboard-insights-panel .provider-stats strong { - font-size: 14px; - } -} - -@media (max-width: 700px) { - .category-organizer { - grid-template-columns: auto minmax(0, 1fr); - } - - .category-organizer .secondary-button { - grid-column: 1 / -1; - justify-content: center; - } -} - -/* Premium canvas, weather and explicit controls */ -.visibility-toggle { - display: inline-flex; - align-items: center; - gap: 10px; - min-height: 38px; - padding: 0; -} - -.visibility-toggle::before, -.visibility-toggle::after { - content: none; -} - -.visibility-toggle-track { - position: relative; - width: 52px; - height: 28px; - flex: 0 0 52px; - border: 1px solid var(--stitch-border-strong); - border-radius: 999px; - background: var(--stitch-surface-highest); - box-shadow: inset 0 2px 5px rgb(0 0 0 / 14%); - transition: - background 180ms ease, - border-color 180ms ease; -} - -.visibility-toggle-track > span { - position: absolute; - top: 3px; - left: 3px; - width: 20px; - height: 20px; - border-radius: 50%; - background: #fff; - box-shadow: 0 2px 6px rgb(0 0 0 / 28%); - transform: translateX(0); - transition: transform 180ms cubic-bezier(0.2, 0.8, 0.2, 1); -} - -.visibility-toggle.on .visibility-toggle-track { - border-color: color-mix(in srgb, var(--stitch-primary) 76%, white); - background: var(--stitch-primary); -} - -.visibility-toggle.on .visibility-toggle-track > span { - transform: translateX(24px); -} - -.visibility-toggle-label { - display: inline-flex; - align-items: center; - gap: 6px; - white-space: nowrap; -} - -.visibility-toggle .visibility-toggle-label svg { - display: block; -} - -.weather-badge { - display: inline-flex; - min-width: 104px; - height: 40px; - align-items: center; - justify-content: center; - gap: 7px; - border: 1px solid - color-mix(in srgb, var(--stitch-primary) 24%, var(--stitch-border)); - border-radius: 999px; - padding: 0 13px; - color: var(--stitch-text); - background: linear-gradient( - 135deg, - color-mix(in srgb, var(--stitch-primary) 13%, var(--stitch-surface-high)), - color-mix(in srgb, var(--stitch-surface-high) 94%, transparent) - ); - box-shadow: inset 0 1px 0 rgb(255 255 255 / 8%); -} - -.weather-badge svg { - color: var(--stitch-primary); - filter: drop-shadow( - 0 0 7px color-mix(in srgb, var(--stitch-primary) 45%, transparent) - ); -} - -.weather-badge strong { - font-size: 14px; - font-variant-numeric: tabular-nums; -} - -.weather-badge > span { - color: var(--stitch-text-muted); - font-size: 12px; -} - -.weather-badge.loading { - opacity: 0.68; -} - -.dashboard-canvas { - position: relative; - overflow: clip; - isolation: isolate; - min-height: calc(100vh - 72px); - background-color: var(--stitch-surface-lowest); - background-repeat: no-repeat; - transition: background-image 220ms ease; -} - -:root[data-background="aurora"] .dashboard-canvas { - background-image: - radial-gradient( - circle at 10% 7%, - color-mix(in srgb, var(--stitch-primary) 28%, transparent) 0, - transparent 32rem - ), - radial-gradient( - circle at 82% 28%, - color-mix(in srgb, #7c5cff 20%, transparent) 0, - transparent 38rem - ), - radial-gradient( - circle at 52% 72%, - color-mix(in srgb, var(--stitch-success) 10%, transparent) 0, - transparent 34rem - ), - linear-gradient( - 180deg, - color-mix(in srgb, var(--stitch-surface-lowest) 88%, transparent), - var(--stitch-surface-lowest) 72% - ); -} - -:root[data-background="grid"] .dashboard-canvas { - background-image: - linear-gradient( - color-mix(in srgb, var(--stitch-primary) 15%, transparent) 1px, - transparent 1px - ), - linear-gradient( - 90deg, - color-mix(in srgb, var(--stitch-primary) 15%, transparent) 1px, - transparent 1px - ), - radial-gradient( - circle at 18% 8%, - color-mix(in srgb, var(--stitch-primary) 15%, transparent), - transparent 30rem - ), - linear-gradient( - 180deg, - color-mix(in srgb, var(--stitch-surface-low) 90%, transparent), - var(--stitch-surface-lowest) 76% - ); - background-size: - 40px 40px, - 40px 40px, - auto, - auto; -} - -:root[data-background="minimal"] .dashboard-canvas { - background-image: linear-gradient( - 180deg, - color-mix(in srgb, var(--stitch-surface-low) 42%, transparent), - transparent 34rem - ); -} - -.dashboard-wallpaper-stage { - position: fixed; - z-index: 0; - top: 72px; - right: 0; - bottom: 0; - left: 0; - overflow: hidden; - pointer-events: none; - background: var(--stitch-surface-lowest); -} - -.dashboard-wallpaper-stage img { - position: absolute; - inset: 0; - width: 100%; - height: 100%; -} - -.dashboard-wallpaper-ambient { - z-index: 0; - object-fit: cover; - object-position: var(--dashboard-wallpaper-position-x, 50%) - var(--dashboard-wallpaper-position-y, 50%); - filter: blur(30px) saturate(0.86) brightness(0.68); - transform: scale(1.06); -} - -.dashboard-wallpaper-image { - z-index: 1; - object-fit: var(--dashboard-wallpaper-fit, contain); - object-position: var(--dashboard-wallpaper-position-x, 50%) - var(--dashboard-wallpaper-position-y, 50%); - transform: scale(var(--dashboard-wallpaper-zoom, 1)); - transform-origin: var(--dashboard-wallpaper-position-x, 50%) - var(--dashboard-wallpaper-position-y, 50%); - transition: - transform 220ms ease, - object-position 220ms ease; -} - -.dashboard-wallpaper-stage::after { - content: ""; - position: absolute; - z-index: 2; - inset: 0; - background: linear-gradient( - 180deg, - color-mix(in srgb, var(--stitch-surface-lowest) 16%, transparent), - color-mix(in srgb, var(--stitch-surface-lowest) 28%, transparent) - ); -} - -:root[data-background="aurora"] .dashboard-wallpaper-stage::after { - background: - radial-gradient( - circle at 12% 9%, - color-mix(in srgb, var(--stitch-primary) 32%, transparent), - transparent 34rem - ), - radial-gradient( - circle at 84% 30%, - color-mix(in srgb, #7c5cff 24%, transparent), - transparent 40rem - ), - linear-gradient( - 180deg, - color-mix(in srgb, var(--stitch-surface-lowest) 34%, transparent), - color-mix(in srgb, var(--stitch-surface-lowest) 64%, transparent) 82%, - var(--stitch-surface-lowest) - ); -} - -:root[data-background="grid"] .dashboard-wallpaper-stage::after { - background: - linear-gradient( - color-mix(in srgb, var(--stitch-primary) 22%, transparent) 1px, - transparent 1px - ), - linear-gradient( - 90deg, - color-mix(in srgb, var(--stitch-primary) 22%, transparent) 1px, - transparent 1px - ), - linear-gradient( - 180deg, - color-mix(in srgb, var(--stitch-surface-lowest) 38%, transparent), - color-mix(in srgb, var(--stitch-surface-lowest) 68%, transparent) 82%, - var(--stitch-surface-lowest) - ); - background-size: - 40px 40px, - 40px 40px, - auto; -} - -:root[data-background="minimal"] .dashboard-wallpaper-stage::after { - background: linear-gradient( - 180deg, - color-mix(in srgb, var(--stitch-surface-lowest) 26%, transparent), - color-mix(in srgb, var(--stitch-surface-lowest) 56%, transparent) 82%, - var(--stitch-surface-lowest) - ); -} - -/* An uploaded wallpaper owns the complete platform canvas. Presets only apply - while no wallpaper is present; rails and widgets become glass layers above it. */ -.dashboard.has-wallpaper { - position: relative; - isolation: isolate; - background: transparent; -} - -.dashboard.has-wallpaper .dashboard-workspace { - position: relative; - z-index: 1; -} - -:root[data-background] .dashboard.has-wallpaper .dashboard-canvas { - background-color: transparent; - background-image: none; -} - -.dashboard.has-wallpaper .dashboard-canvas::before, -.dashboard.has-wallpaper .dashboard-canvas::after { - content: none; -} - -:root[data-background] - .dashboard.has-wallpaper - .dashboard-wallpaper-stage::after { - background: linear-gradient( - 180deg, - color-mix(in srgb, var(--stitch-surface-lowest) 16%, transparent), - color-mix(in srgb, var(--stitch-surface-lowest) 28%, transparent) - ); -} - -.dashboard.has-wallpaper .dashboard-rail, -.dashboard.has-wallpaper .dashboard-insights-panel, -.dashboard.has-wallpaper .dashboard-insights-panel > summary { - border-color: color-mix( - in srgb, - var(--stitch-border-strong) 42%, - transparent - ); - background: color-mix(in srgb, var(--stitch-surface-low) 62%, transparent); - box-shadow: 0 18px 55px rgb(2 8 23 / 12%); - backdrop-filter: blur(18px) saturate(1.1); -} - -.dashboard.has-wallpaper .app-tile.standard, -.dashboard.has-wallpaper .app-tile.feature, -.dashboard.has-wallpaper .favorite-grid .app-tile, -.dashboard.has-wallpaper .degraded-banner, -.dashboard.has-wallpaper .soft-empty { - border-color: color-mix( - in srgb, - var(--stitch-border-strong) 74%, - transparent - ); - background: color-mix(in srgb, var(--stitch-surface-high) 78%, transparent); - box-shadow: 0 14px 38px rgb(2 8 23 / 14%); - backdrop-filter: blur(16px) saturate(1.08); -} - -.app-section > header h2::after { - content: ""; - display: inline-block; - width: 42px; - height: 2px; - margin-left: 12px; - border-radius: 999px; - vertical-align: middle; - background: linear-gradient(90deg, var(--stitch-primary), transparent); -} - -.app-tile { - position: relative; - overflow: hidden; -} - -.app-tile::after { - content: ""; - position: absolute; - inset: 0; - pointer-events: none; - background: linear-gradient( - 115deg, - transparent 15%, - rgb(255 255 255 / 7%) 48%, - transparent 70% - ); - transform: translateX(-130%); - transition: transform 520ms ease; -} - -.app-tile:hover::after { - transform: translateX(130%); -} - -.wallpaper-settings { - display: grid; - gap: 16px; - margin: 18px 0 30px; - border: 1px solid var(--stitch-border); - border-radius: 22px; - padding: 18px; - background: - radial-gradient( - circle at 92% 8%, - color-mix(in srgb, var(--stitch-primary) 13%, transparent), - transparent 24rem - ), - color-mix(in srgb, var(--stitch-surface-high) 91%, transparent); -} - -.wallpaper-editor-heading { - display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; - gap: 12px; - align-items: center; -} - -.wallpaper-editor-heading > span:nth-child(2) { - display: grid; - gap: 4px; -} - -.wallpaper-editor-icon { - display: grid; - width: 40px; - height: 40px; - place-items: center; - border: 1px solid color-mix(in srgb, var(--stitch-primary) 30%, transparent); - border-radius: 12px; - color: var(--stitch-primary); - background: color-mix(in srgb, var(--stitch-primary) 12%, transparent); -} - -.wallpaper-editor-preview { - position: relative; - isolation: isolate; - justify-self: center; - width: min(100%, 1600px); - aspect-ratio: 21 / 9; - min-height: 220px; - overflow: hidden; - border: 1px solid var(--stitch-border); - border-radius: 17px; - background: - radial-gradient( - circle at 25% 20%, - color-mix(in srgb, var(--stitch-primary) 38%, transparent), - transparent 55% - ), - var(--stitch-surface-highest); -} - -.wallpaper-editor-preview img { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - transition: - transform 140ms ease, - object-position 140ms ease; -} - -.wallpaper-preview-image { - z-index: -2; -} - -.wallpaper-preview-ambient { - z-index: -3; - object-fit: cover; - filter: blur(18px) saturate(0.86) brightness(0.68); - transform: scale(1.08); -} - -.wallpaper-preview-empty { - display: grid; - height: 100%; - place-content: center; - justify-items: center; - gap: 8px; - color: var(--stitch-text-muted); -} - -.wallpaper-preview-overlay { - position: absolute; - z-index: -1; - inset: 0; - background: linear-gradient( - 180deg, - color-mix(in srgb, var(--stitch-surface-lowest) 28%, transparent), - color-mix(in srgb, var(--stitch-surface-lowest) 62%, transparent) - ); -} - -.wallpaper-editor-preview.aurora .wallpaper-preview-overlay { - background: - radial-gradient( - circle at 74% 30%, - color-mix(in srgb, #7c5cff 27%, transparent), - transparent 48% - ), - linear-gradient( - 180deg, - color-mix(in srgb, var(--stitch-surface-lowest) 26%, transparent), - color-mix(in srgb, var(--stitch-surface-lowest) 62%, transparent) - ); -} - -.wallpaper-editor-preview.grid .wallpaper-preview-overlay { - background: - linear-gradient( - color-mix(in srgb, var(--stitch-primary) 28%, transparent) 1px, - transparent 1px - ), - linear-gradient( - 90deg, - color-mix(in srgb, var(--stitch-primary) 28%, transparent) 1px, - transparent 1px - ), - linear-gradient( - 180deg, - color-mix(in srgb, var(--stitch-surface-lowest) 30%, transparent), - color-mix(in srgb, var(--stitch-surface-lowest) 64%, transparent) - ); - background-size: - 22px 22px, - 22px 22px, - auto; -} - -.wallpaper-editor-preview.has-wallpaper .wallpaper-preview-overlay { - background: linear-gradient( - 180deg, - color-mix(in srgb, var(--stitch-surface-lowest) 16%, transparent), - color-mix(in srgb, var(--stitch-surface-lowest) 28%, transparent) - ); -} - -.wallpaper-editor-preview.has-wallpaper - :where(.wallpaper-preview-sidebar, .wallpaper-preview-topbar) { - border-color: color-mix( - in srgb, - var(--stitch-border-strong) 42%, - transparent - ); - background: color-mix(in srgb, var(--stitch-surface-low) 62%, transparent); -} - -.wallpaper-editor-preview.has-wallpaper .wallpaper-preview-card { - background: color-mix(in srgb, var(--stitch-surface-high) 78%, transparent); -} - -.wallpaper-preview-sidebar, -.wallpaper-preview-topbar, -.wallpaper-preview-card { - position: absolute; - border: 1px solid color-mix(in srgb, var(--stitch-border) 84%, transparent); - background: color-mix(in srgb, var(--stitch-surface-high) 82%, transparent); - box-shadow: 0 12px 30px rgb(2 8 23 / 18%); - backdrop-filter: blur(8px); -} - -.wallpaper-preview-sidebar { - inset: 0 auto 0 0; - width: 10%; - border-block: 0; - border-left: 0; -} - -.wallpaper-preview-topbar { - inset: 0 0 auto 10%; - height: 14%; - border-top: 0; - border-right: 0; -} - -.wallpaper-preview-card { - top: 36%; - width: 22%; - height: 28%; - border-radius: 9px; -} - -.wallpaper-preview-card.one { - left: 16%; -} - -.wallpaper-preview-card.two { - left: 41%; -} - -.wallpaper-preview-card.three { - left: 66%; -} - -.wallpaper-editor-controls { - display: grid; - justify-self: center; - width: min(100%, 1600px); - gap: 15px; - border-top: 1px solid var(--stitch-border); - padding-top: 16px; -} - -.wallpaper-fit-control, -.wallpaper-editor-footer { - display: flex; - align-items: center; - justify-content: space-between; - gap: 18px; -} - -.wallpaper-fit-control > span { - display: grid; - gap: 3px; -} - -.segmented-control { - display: inline-flex; - gap: 4px; - border: 1px solid var(--stitch-border); - border-radius: 11px; - padding: 4px; - background: var(--stitch-surface-lowest); -} - -.segmented-control button { - min-width: 92px; - border: 0; - border-radius: 8px; - padding: 8px 12px; - color: var(--stitch-text-muted); - background: transparent; -} - -.segmented-control button.active { - color: var(--stitch-text); - background: color-mix(in srgb, var(--stitch-primary) 18%, transparent); - box-shadow: 0 4px 14px rgb(2 8 23 / 20%); -} - -.wallpaper-slider-grid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 12px; -} - -.wallpaper-slider-grid label { - display: grid; - gap: 9px; - border: 1px solid var(--stitch-border); - border-radius: 13px; - padding: 12px; - background: color-mix(in srgb, var(--stitch-surface-lowest) 62%, transparent); -} - -.wallpaper-slider-grid label > span { - display: flex; - justify-content: space-between; - gap: 10px; - color: var(--stitch-text-muted); - font-size: 0.82rem; -} - -.wallpaper-slider-grid label > span strong { - color: var(--stitch-text); -} - -.wallpaper-slider-grid input[type="range"] { - width: 100%; - accent-color: var(--stitch-primary); -} - -.wallpaper-editor-footer > small { - max-width: 52ch; -} - -.wallpaper-editor-footer > div { - display: flex; - flex-shrink: 0; - gap: 8px; -} - -.wallpaper-settings small { - color: var(--stitch-text-muted); - line-height: 1.5; -} - -.wallpaper-actions { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.weather-location-fields { - display: grid; - grid-template-columns: minmax(110px, 1.5fr) minmax(86px, 1fr) minmax( - 86px, - 1fr - ); - gap: 7px; - max-width: 390px; -} - -.weather-location-fields input { - min-width: 0; - height: 38px; - border: 1px solid var(--stitch-border); - border-radius: 10px; - padding: 0 10px; - color: var(--stitch-text); - background: var(--stitch-surface-highest); -} - -:root[data-reduced-motion="true"] .app-tile::after { - transition: none; -} - -@media (max-width: 900px) { - .weather-badge > span { - display: none; - } - .weather-badge { - min-width: 72px; - padding-inline: 10px; - } - .wallpaper-settings { - padding: 14px; - } - .wallpaper-editor-heading { - grid-template-columns: auto minmax(0, 1fr); - } - .wallpaper-editor-heading .wallpaper-actions { - grid-column: 1 / -1; - } - .wallpaper-editor-preview { - aspect-ratio: 16 / 9; - min-height: 190px; - } - .wallpaper-fit-control, - .wallpaper-editor-footer { - align-items: stretch; - flex-direction: column; - } - .segmented-control, - .wallpaper-editor-footer > div { - width: 100%; - } - .segmented-control button, - .wallpaper-editor-footer button { - flex: 1; - } - .wallpaper-slider-grid { - grid-template-columns: 1fr; - } - .weather-location-row { - align-items: start; - } - .weather-location-fields { - grid-column: 1 / -1; - width: 100%; - } -} diff --git a/src/client/styles.css b/src/client/styles.css deleted file mode 100644 index e38e07d9..00000000 --- a/src/client/styles.css +++ /dev/null @@ -1,4229 +0,0 @@ -:root { - font-family: "Geist Variable", Geist, ui-sans-serif, system-ui, sans-serif; - color: #20231f; - background: #f3f4ef; - font-synthesis: none; - text-rendering: optimizeLegibility; - --bg: #f3f4ef; - --surface: #fbfcf8; - --surface-raised: #ffffff; - --surface-muted: #eaebe5; - --text: #20231f; - --text-soft: #696d66; - --text-faint: #93978f; - --border: #dde0d8; - --border-strong: #ced1c8; - --accent: #e66040; - --accent-soft: #fae9e2; - --green: #4c9a69; - --amber: #c68b35; - --shadow: 0 18px 45px rgba(38, 43, 35, 0.07); - --shadow-soft: 0 5px 18px rgba(38, 43, 35, 0.045); - --radius-sm: 10px; - --radius: 16px; - --radius-lg: 24px; - --rail: 238px; - --content-max: 2300px; - --tile-min: 240px; - --tile-max: 380px; - --tile-height: 96px; - --tile-padding: 16px; - --tile-icon: 44px; -} - -:root[data-theme="dark"] { - color-scheme: dark; - --bg: #141613; - --surface: #191c18; - --surface-raised: #20231f; - --surface-muted: #292c27; - --text: #f0f1eb; - --text-soft: #adb1a8; - --text-faint: #7c8178; - --border: #30342e; - --border-strong: #41463e; - --accent: #f17a59; - --accent-soft: #38231d; - --green: #68b580; - --amber: #d9a455; - --shadow: 0 20px 55px rgba(0, 0, 0, 0.22); - --shadow-soft: 0 8px 20px rgba(0, 0, 0, 0.13); -} - -:root[data-theme="harbor"] { - color-scheme: dark; - --bg: #0e1820; - --surface: #121f29; - --surface-raised: #172732; - --surface-muted: #20333e; - --text: #eef6f6; - --text-soft: #a9bdbe; - --text-faint: #718e91; - --border: #29404a; - --border-strong: #385761; - --accent: #e58963; - --accent-soft: #332924; - --green: #69b99b; - --amber: #d9ae67; - --shadow: 0 20px 55px rgba(3, 9, 13, 0.3); - --shadow-soft: 0 8px 20px rgba(3, 9, 13, 0.2); -} - -:root[data-accent="coral"] { - --accent: #e66040; - --accent-soft: color-mix(in srgb, #e66040 14%, var(--surface)); -} -:root[data-accent="ocean"] { - --accent: #3188c7; - --accent-soft: color-mix(in srgb, #3188c7 14%, var(--surface)); -} -:root[data-accent="violet"] { - --accent: #8068d7; - --accent-soft: color-mix(in srgb, #8068d7 14%, var(--surface)); -} -:root[data-accent="lime"] { - --accent: #65a84b; - --accent-soft: color-mix(in srgb, #65a84b 14%, var(--surface)); -} - -:root[data-content-width="focused"] { - --content-max: 1120px; -} -:root[data-content-width="wide"] { - --content-max: 2300px; -} -:root[data-content-width="fluid"] { - --content-max: 3200px; -} - -:root[data-density="compact"] { - --tile-min: 190px; - --tile-max: 320px; - --tile-height: 76px; - --tile-padding: 12px; - --tile-icon: 36px; -} -:root[data-density="comfortable"] { - --tile-min: 240px; - --tile-max: 380px; - --tile-height: 96px; - --tile-padding: 16px; - --tile-icon: 44px; -} -:root[data-density="spacious"] { - --tile-min: 290px; - --tile-max: 460px; - --tile-height: 116px; - --tile-padding: 20px; - --tile-icon: 50px; -} - -* { - box-sizing: border-box; -} -html { - min-width: 320px; - min-height: 100%; - background: var(--bg); -} -body { - margin: 0; - min-width: 320px; - min-height: 100vh; - background: var(--bg); - color: var(--text); - overflow-x: hidden; -} -body::before { - content: ""; - position: fixed; - z-index: -1; - inset: 0; - pointer-events: none; -} -:root[data-background="aurora"] body::before { - background: - radial-gradient( - circle at 72% -10%, - color-mix(in srgb, var(--accent) 14%, transparent), - transparent 32rem - ), - radial-gradient( - circle at 12% 88%, - color-mix(in srgb, var(--green) 8%, transparent), - transparent 28rem - ); -} -:root[data-background="grid"] body::before { - opacity: 0.55; - background-image: - linear-gradient(var(--border) 1px, transparent 1px), - linear-gradient(90deg, var(--border) 1px, transparent 1px); - background-size: 42px 42px; - mask-image: linear-gradient(to bottom, black, transparent 85%); -} -:root[data-background="minimal"] body::before { - background: none; -} -button, -input, -select { - font: inherit; -} -button, -a { - -webkit-tap-highlight-color: transparent; -} -button { - color: inherit; -} -button:focus-visible, -a:focus-visible, -input:focus-visible, -select:focus-visible { - outline: 3px solid color-mix(in srgb, var(--accent) 45%, transparent); - outline-offset: 2px; -} -a { - color: inherit; -} -.skip-link { - position: fixed; - left: 12px; - top: -60px; - z-index: 1000; - background: var(--text); - color: var(--bg); - padding: 10px 14px; - border-radius: 8px; - transition: top 0.15s ease; -} -.skip-link:focus { - top: 12px; -} -.sr-only { - position: absolute !important; - width: 1px !important; - height: 1px !important; - overflow: hidden !important; - clip: rect(0, 0, 0, 0) !important; - white-space: nowrap !important; - clip-path: inset(50%) !important; -} - -.app-shell { - min-height: 100vh; - isolation: isolate; -} -.rail { - position: fixed; - inset: 0 auto 0 0; - z-index: 20; - width: var(--rail); - padding: 31px 22px 25px; - border-right: 1px solid var(--border); - background: color-mix(in srgb, var(--surface) 86%, transparent); - display: flex; - flex-direction: column; - backdrop-filter: blur(14px); -} -.brand { - display: flex; - align-items: center; - gap: 12px; - border: 0; - background: none; - padding: 0 6px 29px; - cursor: pointer; - text-align: left; -} -.logo-mark { - width: 38px; - height: 38px; - display: grid; - place-items: center; - color: #fff; - border-radius: 12px; - background: var(--accent); - box-shadow: 0 7px 18px color-mix(in srgb, var(--accent) 27%, transparent); - transform: rotate(-2deg); -} -.brand-copy { - display: grid; - line-height: 1.1; -} -.brand-copy strong { - font-size: 15px; - letter-spacing: -0.01em; -} -.brand-copy small { - color: var(--text-faint); - font-size: 10px; - margin-top: 4px; - text-transform: uppercase; - letter-spacing: 0.1em; -} -.rail nav { - display: grid; - gap: 7px; -} -.rail nav button { - width: 100%; - display: flex; - align-items: center; - gap: 12px; - border: 0; - border-radius: 11px; - padding: 11px 13px; - color: var(--text-soft); - background: transparent; - cursor: pointer; - font-size: 13px; - font-weight: 600; - text-align: left; - transition: 0.16s ease; -} -.rail nav button:hover { - color: var(--text); - background: var(--surface-muted); -} -.rail nav button.active { - color: var(--text); - background: var(--surface-raised); - box-shadow: var(--shadow-soft); -} -.rail nav button.active svg { - color: var(--accent); -} -.rail-content { - min-height: 0; - display: grid; - gap: 12px; - margin-top: 38px; - overflow-y: auto; - scrollbar-width: thin; -} -.rail-clock { - display: grid; - gap: 3px; - padding: 0 8px 13px; - border-bottom: 1px solid var(--border); -} -.rail-clock small { - color: var(--text-faint); - font-size: 9px; - text-transform: capitalize; -} -.rail-clock strong { - font-family: "Newsreader", Georgia, serif; - font-size: 29px; - font-weight: 500; - letter-spacing: -0.04em; -} -.rail-pulse, -.rail-services { - border: 1px solid var(--border); - border-radius: 13px; - padding: 12px; - background: color-mix(in srgb, var(--surface-raised) 72%, transparent); -} -.rail-pulse header { - display: flex; - align-items: center; - justify-content: space-between; - font-size: 9px; -} -.rail-pulse header span { - display: flex; - align-items: center; - gap: 6px; - color: var(--text-soft); - font-weight: 700; -} -.rail-pulse header svg, -.rail-pulse header strong { - color: var(--accent); -} -.rail-progress { - height: 4px; - margin: 11px 0 8px; - overflow: hidden; - border-radius: 4px; - background: var(--surface-muted); -} -.rail-progress span { - display: block; - height: 100%; - border-radius: inherit; - background: linear-gradient(90deg, var(--green), var(--accent)); -} -.rail-pulse p { - margin: 0; - color: var(--text-faint); - font-size: 8px; - line-height: 1.5; -} -.rail-services > small { - display: flex; - align-items: center; - gap: 5px; - margin-bottom: 7px; - color: var(--text-faint); - font-size: 7px; - font-weight: 700; - letter-spacing: 0.12em; -} -.rail-favorite-icon { - width: 20px; - height: 20px; - flex-basis: 20px; - border-radius: 6px; - padding: 2px; - font-size: 8px; -} -.app-remove { - flex: 0 0 auto; -} -.rail-services a { - min-width: 0; - display: flex; - align-items: center; - gap: 7px; - border-radius: 8px; - padding: 7px 5px; - text-decoration: none; -} -.rail-services a:hover { - background: var(--surface-muted); -} -.rail-services a strong { - min-width: 0; - flex: 1; - overflow: hidden; - font-size: 9px; - text-overflow: ellipsis; - white-space: nowrap; -} -.rail-services a > svg { - color: var(--text-faint); -} -.rail-status { - margin-top: auto; - border-top: 1px solid var(--border); - padding: 19px 8px 0; - display: flex; - gap: 10px; - align-items: center; -} -.rail-status > span:last-child { - display: grid; -} -.rail-status strong { - font-size: 11px; -} -.rail-status small { - font-size: 10px; - color: var(--text-faint); - margin-top: 2px; -} -.online-mark { - display: inline-block; - width: 7px; - height: 7px; - border-radius: 50%; - background: var(--green); - box-shadow: 0 0 0 4px color-mix(in srgb, var(--green) 13%, transparent); -} -.mobile-nav { - display: none; -} - -.dashboard { - margin-left: var(--rail); - min-height: 100vh; - width: calc(100% - var(--rail)); - padding: clamp(32px, 4vw, 72px) clamp(26px, 3vw, 84px) 36px; -} -.dashboard-hero, -.dashboard-toolbar, -.search-shell, -.degraded-banner, -.widgets-section, -.category-tabs, -.dashboard-groups, -.empty-state, -.dashboard-footer { - width: 100%; - max-width: var(--content-max); - margin-inline: auto; -} -.dashboard-hero { - position: relative; - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(320px, 520px); - gap: clamp(28px, 4vw, 72px); - align-items: end; - margin-bottom: clamp(34px, 4vw, 54px); -} -.dashboard-hero::after { - content: ""; - position: absolute; - left: 0; - right: 0; - bottom: -22px; - height: 1px; - background: linear-gradient(90deg, var(--border), transparent 80%); -} -.eyebrow { - margin: 0 0 12px; - color: var(--accent); - font-size: 10px; - line-height: 1; - font-weight: 700; - letter-spacing: 0.18em; -} -.hero-copy h1 { - margin: 0; - max-width: 820px; - font-size: clamp(42px, 4.25vw, 74px); - line-height: 0.94; - letter-spacing: -0.06em; - font-weight: 600; -} -.hero-copy h1 em { - display: block; - margin-top: 0.08em; - font-family: "Newsreader", Georgia, serif; - color: color-mix(in srgb, var(--text) 68%, var(--accent)); - font-weight: 500; -} -.hero-intro { - max-width: 560px; - margin: 22px 0 0; - color: var(--text-soft); - font-size: 14px; - line-height: 1.65; -} -.hero-overview { - width: 100%; - max-width: 520px; - justify-self: end; - border: 1px solid color-mix(in srgb, var(--border) 80%, var(--accent)); - border-radius: var(--radius-lg); - padding: 18px; - background: color-mix(in srgb, var(--surface-raised) 84%, transparent); - box-shadow: var(--shadow); - backdrop-filter: blur(18px); -} -.overview-topline { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 15px; - color: var(--text-soft); - font-size: 11px; - font-weight: 700; -} -.overview-topline > span { - display: flex; - align-items: center; - gap: 9px; -} -.hero-metrics { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 8px; -} -.hero-metrics > span { - min-width: 0; - display: grid; - grid-template-columns: auto 1fr; - align-items: center; - column-gap: 7px; - border: 1px solid var(--border); - border-radius: 13px; - padding: 11px; - background: var(--surface); -} -.hero-metrics svg { - grid-row: span 2; - color: var(--accent); -} -.hero-metrics strong { - font-size: 18px; - line-height: 1; -} -.hero-metrics small { - margin-top: 3px; - overflow: hidden; - color: var(--text-faint); - font-size: 9px; - text-overflow: ellipsis; -} -.dashboard-toolbar { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 28px; -} -.dashboard-toolbar h1 { - margin: 0; - font-size: clamp(28px, 3vw, 42px); - letter-spacing: -0.045em; -} -.icon-button { - width: 41px; - height: 41px; - display: grid; - place-items: center; - border: 1px solid var(--border); - border-radius: 50%; - background: var(--surface); - color: var(--text-soft); - cursor: pointer; - transition: 0.16s ease; -} -.icon-button:hover { - color: var(--text); - border-color: var(--border-strong); - transform: translateY(-1px); -} -.icon-button:disabled { - opacity: 0.6; - cursor: wait; -} - -.search-shell { - position: relative; - z-index: 10; - margin-bottom: 34px; -} -.search-bar { - height: 64px; - display: flex; - align-items: center; - gap: 15px; - padding: 0 18px 0 21px; - border: 1px solid var(--border); - border-radius: 19px; - background: color-mix(in srgb, var(--surface-raised) 92%, transparent); - box-shadow: 0 10px 34px rgba(20, 24, 20, 0.06); - backdrop-filter: blur(16px); - transition: 0.18s ease; -} -.search-shell.expanded .search-bar, -.search-bar:focus-within { - border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); - box-shadow: 0 10px 32px color-mix(in srgb, var(--accent) 8%, transparent); -} -.search-bar svg { - color: var(--text-faint); - flex: 0 0 auto; -} -.search-bar input { - flex: 1; - height: 100%; - border: 0; - outline: 0; - background: transparent; - color: var(--text); - font-size: 15px; -} -.search-bar input::placeholder { - color: var(--text-faint); -} -kbd { - border: 1px solid var(--border); - border-bottom-color: var(--border-strong); - border-radius: 6px; - padding: 4px 7px; - color: var(--text-faint); - background: var(--surface-muted); - font: - 10px "Geist Variable", - sans-serif; -} -.search-results { - position: absolute; - top: calc(100% + 8px); - inset-inline: 0; - padding: 8px; - border: 1px solid var(--border); - border-radius: 17px; - background: var(--surface-raised); - box-shadow: var(--shadow); - animation: appear 0.15s ease both; -} -.search-results a, -.search-results button { - width: 100%; - min-height: 56px; - display: flex; - align-items: center; - gap: 12px; - border: 0; - border-radius: 11px; - padding: 8px 11px; - text-align: left; - text-decoration: none; - background: transparent; - cursor: pointer; -} -.search-results a:hover, -.search-results button:hover, -.search-results a.selected { - background: var(--surface-muted); -} -.search-results a > span:nth-child(2), -.search-results button > span:nth-child(2) { - flex: 1; - display: grid; -} -.search-results strong { - font-size: 13px; -} -.search-results small { - margin-top: 3px; - color: var(--text-faint); - font-size: 10px; -} -.result-icon { - width: 34px; - height: 34px; - display: grid; - place-items: center; - border-radius: 9px; - background: var(--accent-soft); - color: var(--accent); - font-size: 13px; - font-weight: 700; -} -.result-icon.google { - background: #e8eef8; - color: #356ac3; -} -.search-results > * > svg { - color: var(--text-faint); -} -.google-result { - border-top: 1px solid var(--border) !important; - border-radius: 0 0 11px 11px !important; - margin-top: 5px; -} - -.degraded-banner { - margin: -12px auto 25px; - min-height: 44px; - display: flex; - gap: 11px; - align-items: center; - padding: 10px 13px; - border: 1px solid color-mix(in srgb, var(--amber) 25%, var(--border)); - border-radius: 11px; - background: color-mix(in srgb, var(--amber) 8%, var(--surface)); - font-size: 11px; - color: var(--text-soft); -} -.degraded-banner > span:nth-child(2) { - flex: 1; -} -.degraded-banner strong { - color: var(--text); -} -.degraded-banner button { - border: 0; - background: none; - color: var(--amber); - font-weight: 700; - cursor: pointer; -} -.status-pulse { - width: 7px; - height: 7px; - border-radius: 50%; - background: var(--amber); -} -.category-tabs { - margin: -10px auto 31px; - display: flex; - gap: 6px; - overflow-x: auto; - scrollbar-width: none; -} - -.widgets-section { - margin: 0 auto clamp(34px, 3vw, 48px); -} -.widgets-section > header { - display: flex; - align-items: end; - justify-content: space-between; - padding: 0 2px 14px; -} -.widgets-section h2 { - margin: 0; - display: flex; - align-items: center; - gap: 8px; - font-size: clamp(20px, 1.5vw, 24px); - letter-spacing: -0.025em; -} -.widgets-section h2 svg { - color: var(--accent); -} -.widgets-section > header > span, -.widgets-refresh { - color: var(--text-faint); - font-size: 10px; -} -.widgets-refresh { - display: flex; - align-items: center; - gap: 6px; - border: 0; - padding: 7px 9px; - color: var(--text-soft); - background: transparent; - cursor: pointer; -} -.widgets-refresh:hover { - color: var(--accent); -} -.widgets-refresh:disabled { - cursor: wait; -} -.widgets-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); - grid-auto-flow: dense; - gap: clamp(11px, 1vw, 16px); -} -.service-widget { - position: relative; - min-width: 0; - min-height: 250px; - display: flex; - flex-direction: column; - overflow: hidden; - border: 1px solid var(--border); - border-radius: 20px; - padding: 17px; - color: var(--text); - background: - radial-gradient( - circle at 100% 0, - color-mix(in srgb, var(--accent) 11%, transparent), - transparent 45% - ), - color-mix(in srgb, var(--surface-raised) 92%, transparent); - box-shadow: var(--shadow-soft); - text-decoration: none; - transition: - transform 0.18s ease, - border-color 0.18s ease, - box-shadow 0.18s ease; -} -.service-widget.widget-size-compact { - min-height: 178px; - padding: 14px; -} -.service-widget.widget-size-standard { - min-height: 250px; -} -.service-widget.widget-size-wide { - grid-column: span 2; - min-height: 250px; - padding: 20px; -} -.widget-size-wide .provider-stats { - gap: 10px; -} -.widget-size-wide .provider-stats > span { - padding: 12px; -} -.widget-size-wide .widget-state { - max-width: 70%; -} -.widget-size-compact .widget-state { - margin: 14px 0 10px; -} -.widget-size-compact .provider-stats > span { - padding: 8px; -} -.widget-size-compact .widget-infra-line, -.widget-size-compact .server-footer { - flex-wrap: wrap; - gap: 7px; -} -.service-widget:hover { - transform: translateY(-3px); - border-color: color-mix(in srgb, var(--accent) 33%, var(--border)); - box-shadow: 0 18px 42px color-mix(in srgb, var(--accent) 9%, transparent); -} -.service-widget > header { - display: flex; - align-items: center; - gap: 11px; -} -.widget-service-icon { - width: 42px; - height: 42px; - flex-basis: 42px; - border-radius: 12px; -} -.widget-title { - min-width: 0; - flex: 1; - display: grid; -} -.widget-title small { - color: var(--text-faint); - font-size: 8px; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; -} -.widget-title strong { - margin-top: 3px; - overflow: hidden; - font-size: 13px; - text-overflow: ellipsis; - white-space: nowrap; -} -.widget-kind { - width: 32px; - height: 32px; - display: grid; - place-items: center; - border-radius: 10px; - color: var(--accent); - background: var(--accent-soft); -} -.widget-state { - display: grid; - grid-template-columns: auto 1fr; - align-items: center; - column-gap: 8px; - margin: 20px 0 15px; -} -.widget-state .status-dot { - grid-row: span 2; -} -.widget-state strong { - font-size: 12px; -} -.widget-state small { - margin-top: 2px; - overflow: hidden; - color: var(--text-faint); - font-size: 8px; - text-overflow: ellipsis; - white-space: nowrap; -} -.widget-metrics { - display: grid; - grid-template-columns: 0.72fr 1fr 1.45fr; - gap: 6px; - margin-top: auto; -} -.widget-metrics > span { - min-width: 0; - display: grid; - grid-template-columns: auto 1fr; - align-items: center; - column-gap: 5px; - border: 1px solid var(--border); - border-radius: 10px; - padding: 8px; - background: color-mix(in srgb, var(--surface) 80%, transparent); -} -.widget-metrics svg { - color: var(--text-faint); -} -.widget-metrics small { - color: var(--text-faint); - font-size: 7px; - text-transform: uppercase; -} -.widget-metrics strong { - grid-column: 1 / -1; - margin-top: 5px; - overflow: hidden; - font-size: 10px; - text-overflow: ellipsis; - white-space: nowrap; -} -.widget-metrics .network-value { - display: flex; - align-items: center; - gap: 3px; - font-size: 8px; -} -.provider-stats { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 7px; - margin-top: auto; -} -.provider-stats > span { - min-width: 0; - display: grid; - gap: 5px; - border: 1px solid var(--border); - border-radius: 11px; - padding: 10px; - background: color-mix(in srgb, var(--surface) 82%, transparent); -} -.provider-stats small { - overflow: hidden; - color: var(--text-faint); - font-size: 7px; - text-overflow: ellipsis; - text-transform: uppercase; - white-space: nowrap; -} -.provider-stats strong { - overflow: hidden; - font-size: 13px; - text-overflow: ellipsis; - white-space: nowrap; -} -.provider-stats .stat-visual { - grid-column: 1 / -1; - width: 100%; - height: 4px; - overflow: hidden; - border-radius: 999px; - background: color-mix(in srgb, var(--text-faint) 18%, transparent); -} -.provider-stats .stat-visual i { - display: block; - height: 100%; - border-radius: inherit; - background: var(--accent); -} -.provider-stats .metric-style-gauge .stat-visual { - height: 6px; - padding: 1px; - border: 1px solid color-mix(in srgb, var(--accent) 32%, var(--border)); - background: transparent; -} -.tailored-stats > span { - grid-template-columns: auto minmax(0, 1fr); - align-items: center; - column-gap: 5px; -} -.tailored-stats svg { - grid-row: span 2; - color: var(--text-faint); -} -.tailored-stats strong { - grid-column: 1 / -1; -} -.provider-note { - display: flex; - align-items: center; - gap: 5px; - margin-top: 10px; - color: var(--amber); - font-size: 8px; -} -.service-widget-downloads { - background: - radial-gradient( - circle at 100% 0, - color-mix(in srgb, #f6a623 14%, transparent), - transparent 46% - ), - color-mix(in srgb, var(--surface-raised) 92%, transparent); -} -.service-widget-dns { - background: - radial-gradient( - circle at 100% 0, - color-mix(in srgb, var(--green) 14%, transparent), - transparent 46% - ), - color-mix(in srgb, var(--surface-raised) 92%, transparent); -} -.service-widget-media { - background: - radial-gradient( - circle at 100% 0, - color-mix(in srgb, #9d7df2 15%, transparent), - transparent 46% - ), - color-mix(in srgb, var(--surface-raised) 92%, transparent); -} -.service-widget-ai { - background: - radial-gradient( - circle at 100% 0, - color-mix(in srgb, #4a9eff 14%, transparent), - transparent 46% - ), - color-mix(in srgb, var(--surface-raised) 92%, transparent); -} -.service-widget-documents { - background: - radial-gradient( - circle at 100% 0, - color-mix(in srgb, #d39a5f 13%, transparent), - transparent 46% - ), - color-mix(in srgb, var(--surface-raised) 92%, transparent); -} -.service-widget-security { - background: - radial-gradient( - circle at 100% 0, - color-mix(in srgb, #58b99d 13%, transparent), - transparent 46% - ), - color-mix(in srgb, var(--surface-raised) 92%, transparent); -} -.provider-stats .positive strong { - color: var(--green); -} -.provider-stats .warning strong { - color: var(--amber); -} -.widget-infra-line, -.server-footer { - display: flex; - align-items: center; - gap: 12px; - margin-top: 12px; - color: var(--text-faint); - font-size: 8px; -} -.widget-infra-line span, -.server-footer span { - display: flex; - align-items: center; - gap: 4px; - min-width: 0; -} -.server-widget { - background: - radial-gradient( - circle at 95% 5%, - color-mix(in srgb, var(--green) 18%, transparent), - transparent 48% - ), - color-mix(in srgb, var(--surface-raised) 92%, transparent); -} -.server-widget-icon { - width: 42px; - height: 42px; - display: grid; - place-items: center; - border-radius: 12px; - color: var(--green); - background: color-mix(in srgb, var(--green) 11%, var(--surface)); -} -.server-title-link { - min-width: 0; - display: flex; - align-items: center; - gap: 5px; - margin-top: 3px; - overflow: hidden; - text-decoration: none; -} -.server-title-link strong { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.server-title-link svg { - flex: 0 0 auto; - color: var(--green); -} -.server-health { - border-radius: 20px; - padding: 5px 8px; - color: var(--green); - background: color-mix(in srgb, var(--green) 10%, transparent); - font-size: 8px; - font-weight: 700; - text-transform: uppercase; -} -.server-health.unknown { - color: var(--amber); - background: color-mix(in srgb, var(--amber) 10%, transparent); -} -.server-summary { - display: flex; - align-items: center; - gap: 12px; - margin: 17px 0 14px; -} -.server-summary > strong { - font-size: 38px; - line-height: 1; - letter-spacing: -0.06em; -} -.server-summary > span { - display: grid; - color: var(--text-soft); - font-size: 11px; - font-weight: 600; -} -.server-summary small { - margin-top: 4px; - color: var(--text-faint); - font-size: 8px; - font-weight: 400; -} -.server-stats { - margin-top: 0; -} -.server-stats > span { - grid-template-columns: auto 1fr; - align-items: center; - column-gap: 5px; -} -.server-stats svg { - grid-row: span 2; - color: var(--text-faint); -} -.server-stats strong { - grid-column: 1 / -1; -} -.memory-track { - height: 3px; - margin-top: 11px; - overflow: hidden; - border-radius: 4px; - background: var(--surface-muted); -} -.memory-track span { - display: block; - height: 100%; - border-radius: inherit; - background: linear-gradient(90deg, var(--green), var(--accent)); -} -.widget-arrow { - position: absolute; - right: 17px; - bottom: 15px; - opacity: 0; - color: var(--accent); - transition: opacity 0.18s ease; -} -.service-widget:hover .widget-arrow { - opacity: 1; -} -.service-widget.offline { - filter: saturate(0.72); -} -.category-tabs button { - flex: 0 0 auto; - display: flex; - align-items: center; - gap: 7px; - border: 1px solid transparent; - border-radius: 20px; - padding: 8px 12px; - background: transparent; - color: var(--text-soft); - font-size: 11px; - font-weight: 600; - cursor: pointer; -} -.category-tabs button span { - margin-left: 1px; - color: var(--text-faint); -} -.category-tabs button:hover { - background: var(--surface-muted); -} -.category-tabs button.active { - background: var(--surface-raised); - border-color: var(--border); - color: var(--text); - box-shadow: var(--shadow-soft); -} - -.dashboard-groups { - margin-bottom: 0; -} -.app-section { - margin: 0 0 clamp(34px, 4vw, 54px); -} -.app-section > header { - display: flex; - justify-content: space-between; - align-items: end; - padding: 0 2px 14px; -} -.section-kicker { - margin: 0 0 5px; - color: var(--text-faint); - font-size: 9px; - font-weight: 700; - letter-spacing: 0.16em; -} -.app-section h2 { - margin: 0; - display: flex; - align-items: center; - gap: 8px; - font-size: clamp(20px, 1.5vw, 24px); - letter-spacing: -0.025em; -} -.app-section h2 svg { - color: var(--accent); -} -.section-count { - color: var(--text-faint); - font-size: 11px; -} -.app-grid { - display: grid; - grid-template-columns: repeat( - auto-fit, - minmax(min(100%, var(--tile-min)), var(--tile-max)) - ); - gap: clamp(10px, 1vw, 15px); -} -.app-tile { - min-width: 0; - min-height: var(--tile-height); - display: flex; - align-items: center; - gap: 13px; - border: 1px solid var(--border); - border-radius: var(--radius); - padding: var(--tile-padding); - color: var(--text); - background: color-mix(in srgb, var(--surface-raised) 92%, transparent); - box-shadow: var(--shadow-soft); - backdrop-filter: blur(14px); - text-decoration: none; - transition: - transform 0.18s ease, - box-shadow 0.18s ease, - border-color 0.18s ease; -} -.app-tile:hover { - transform: translateY(-4px) scale(1.008); - border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); - box-shadow: 0 18px 42px color-mix(in srgb, var(--accent) 9%, transparent); -} -.app-tile.disabled { - opacity: 0.65; -} -.app-monogram, -.app-icon-image { - width: var(--tile-icon); - height: var(--tile-icon); - flex: 0 0 var(--tile-icon); - border-radius: calc(var(--radius-sm) + 2px); -} -.app-monogram { - display: grid; - place-items: center; - color: #fff; - font-size: 16px; - font-weight: 700; -} -.app-icon-image { - object-fit: contain; - background: var(--surface-muted); - padding: 5px; -} -.app-monogram.coral { - background: #dc6a4f; -} -.app-monogram.violet { - background: #7669ad; -} -.app-monogram.cyan { - background: #4598a0; -} -.app-monogram.lime { - background: #6e9b62; -} -.app-monogram.amber { - background: #c38c45; -} -.tile-copy { - min-width: 0; - display: grid; - flex: 1; -} -.tile-copy strong { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 13px; - letter-spacing: -0.01em; -} -.tile-copy small { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - color: var(--text-faint); - font-size: 10px; - margin-top: 5px; -} -.status-dot { - width: 7px; - height: 7px; - flex: 0 0 7px; - border: 2px solid var(--surface-raised); - border-radius: 50%; - box-shadow: 0 0 0 1px var(--border); -} -.status-dot.online { - background: var(--green); -} -.status-dot.offline { - background: var(--text-faint); -} -.status-dot.unknown { - background: var(--amber); -} -.status-icon { - color: var(--text-faint); -} -.tile-arrow { - opacity: 0; - transform: translate(-3px, 3px); - color: var(--text-faint); - transition: 0.18s ease; -} -.app-tile:hover .tile-arrow { - opacity: 1; - transform: translate(0, 0); -} - -.empty-state { - min-height: min(460px, 52vh); - margin-bottom: 30px; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - border: 1px dashed var(--border-strong); - border-radius: var(--radius-lg); - text-align: center; - background: - radial-gradient( - circle at 50% 0%, - color-mix(in srgb, var(--accent) 10%, transparent), - transparent 52% - ), - color-mix(in srgb, var(--surface) 78%, transparent); -} -.empty-icon { - width: 65px; - height: 65px; - display: grid; - place-items: center; - margin-bottom: 27px; - border: 1px solid var(--border); - border-radius: 20px; - color: var(--accent); - background: var(--surface-raised); - box-shadow: var(--shadow-soft); - transform: rotate(-3deg); -} -.empty-state h2 { - margin: 0 0 11px; - font-size: 26px; - letter-spacing: -0.04em; -} -.empty-state > p:not(.eyebrow) { - max-width: 440px; - margin: 0 20px 23px; - color: var(--text-soft); - font-size: 12px; - line-height: 1.7; -} -.primary-button, -.secondary-button { - min-height: 39px; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 8px; - border-radius: 10px; - padding: 0 15px; - font-size: 11px; - font-weight: 700; - cursor: pointer; - text-decoration: none; - transition: 0.16s ease; -} -.primary-button { - border: 1px solid var(--accent); - color: #fff; - background: var(--accent); - box-shadow: 0 5px 15px color-mix(in srgb, var(--accent) 22%, transparent); -} -.primary-button:hover { - filter: brightness(1.04); - transform: translateY(-1px); -} -.secondary-button { - border: 1px solid var(--border); - color: var(--text); - background: var(--surface-raised); -} -.secondary-button:hover { - border-color: var(--border-strong); - background: var(--surface-muted); -} -.dashboard-footer { - margin: 18px auto 0; - padding: 17px 2px 0; - border-top: 1px solid var(--border); - display: flex; - align-items: center; - justify-content: space-between; - color: var(--text-faint); - font-size: 10px; -} -.dashboard-footer > span { - display: flex; - align-items: center; - gap: 9px; -} -.dashboard-footer button { - display: flex; - align-items: center; - gap: 3px; - border: 0; - background: none; - color: var(--text-soft); - font-size: 10px; - cursor: pointer; -} - -.settings-page { - margin-left: var(--rail); - min-height: 100vh; - width: calc(100% - var(--rail)); -} -.settings-topbar { - height: 64px; - border-bottom: 1px solid var(--border); - display: flex; - justify-content: space-between; - align-items: center; - padding: 0 36px; - background: color-mix(in srgb, var(--surface) 88%, transparent); - backdrop-filter: blur(16px); -} -.back-button { - display: flex; - align-items: center; - gap: 5px; - border: 0; - background: transparent; - color: var(--text-soft); - font-size: 11px; - font-weight: 600; - cursor: pointer; -} -.back-button:hover { - color: var(--text); -} -.saving { - display: flex; - align-items: center; - gap: 6px; - opacity: 0; - color: var(--text-faint); - font-size: 10px; - transition: opacity 0.15s; -} -.saving.visible { - opacity: 1; -} -.settings-layout { - display: grid; - grid-template-columns: 250px minmax(0, 1fr); - width: min(calc(100% - 72px), 1760px); - max-width: 1760px; - margin: 0 auto; - min-height: calc(100vh - 64px); -} -.settings-sidebar { - border-right: 1px solid var(--border); - padding: 50px 32px 30px 0; -} -.settings-sidebar h1 { - margin: 0 0 34px; - font-size: 28px; - letter-spacing: -0.04em; -} -.settings-sidebar nav { - display: grid; - gap: 5px; -} -.settings-sidebar nav button { - display: flex; - align-items: center; - gap: 10px; - border: 0; - border-radius: 10px; - min-height: 44px; - padding: 10px 13px; - background: transparent; - color: var(--text-soft); - font-size: 12px; - font-weight: 600; - text-align: left; - cursor: pointer; -} -.settings-sidebar nav button:hover { - background: var(--surface-muted); - color: var(--text); -} -.settings-sidebar nav button.active { - background: var(--surface-raised); - color: var(--text); - box-shadow: var(--shadow-soft); -} -.settings-sidebar nav button.active svg { - color: var(--accent); -} -.local-note { - display: flex; - align-items: center; - gap: 8px; - margin-top: 34px; - color: var(--text-faint); - font-size: 9px; - line-height: 1.5; -} -.local-note > span { - width: 6px; - height: 6px; - flex: 0 0 6px; - border-radius: 50%; - background: var(--green); -} -.settings-content { - min-width: 0; - padding: 52px 0 80px 60px; -} -.settings-heading { - max-width: 650px; - margin-bottom: 30px; -} -.settings-heading h2 { - margin: 0 0 10px; - font-size: clamp(30px, 3vw, 38px); - letter-spacing: -0.045em; -} -.settings-heading > p:last-child { - margin: 0; - color: var(--text-soft); - font-size: 13px; - line-height: 1.7; -} -.settings-summary { - display: flex; - gap: 10px; - margin-bottom: 14px; -} -.settings-summary span { - min-width: 120px; - display: flex; - align-items: center; - gap: 8px; - padding: 10px 13px; - border: 1px solid var(--border); - border-radius: 10px; - background: var(--surface); - color: var(--text-soft); - font-size: 10px; -} -.settings-summary svg { - color: var(--accent); -} -.management-toolbar { - display: flex; - align-items: center; - gap: 10px; - margin-bottom: 7px; -} -.settings-search { - position: relative; - min-width: 220px; - flex: 1; - display: flex; - align-items: center; -} -.settings-search > svg { - position: absolute; - left: 12px; - color: var(--text-faint); -} -.settings-search input { - height: 42px; - padding-left: 38px; - background: var(--surface-raised); -} -.filter-pills { - display: flex; - gap: 4px; - border: 1px solid var(--border); - border-radius: 12px; - padding: 4px; - background: var(--surface); -} -.filter-pills button { - min-height: 32px; - border: 0; - border-radius: 8px; - padding: 0 10px; - background: transparent; - color: var(--text-faint); - font-size: 9px; - font-weight: 700; - text-transform: capitalize; - cursor: pointer; -} -.filter-pills button.active { - background: var(--surface-raised); - color: var(--text); - box-shadow: var(--shadow-soft); -} -.results-note { - margin: 0 2px 12px; - color: var(--text-faint); - font-size: 9px; -} -.management-list { - display: grid; - gap: 10px; -} -.management-card { - border: 1px solid var(--border); - border-radius: var(--radius); - background: var(--surface-raised); - box-shadow: 0 1px 1px rgba(0, 0, 0, 0.015); -} -.app-management { - padding: 16px; -} -.favorite-management { - padding: 16px; -} -.management-title { - display: flex; - align-items: center; - gap: 12px; -} -.mini-monogram { - width: 35px; - height: 35px; - flex: 0 0 35px; - display: grid; - place-items: center; - border-radius: 9px; - background: var(--surface-muted); - color: var(--accent); - font-size: 12px; - font-weight: 700; -} -.management-title > span:nth-child(2) { - min-width: 0; - flex: 1; - display: grid; -} -.management-title strong { - font-size: 12px; -} -.management-title small { - max-width: 400px; - margin-top: 3px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - color: var(--text-faint); - font-size: 9px; -} -.visibility-toggle { - min-width: 85px; - display: flex; - justify-content: center; - align-items: center; - gap: 7px; - border: 1px solid var(--border); - border-radius: 20px; - padding: 7px 10px; - background: var(--surface-muted); - color: var(--text-soft); - font-size: 9px; - font-weight: 700; - cursor: pointer; -} -.widget-toggle { - min-width: 86px; - display: flex; - align-items: center; - justify-content: center; - gap: 6px; - border: 1px solid var(--border); - border-radius: 20px; - padding: 7px 10px; - background: transparent; - color: var(--text-faint); - font-size: 9px; - font-weight: 700; - cursor: pointer; -} -.widget-toggle.on { - border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); - color: var(--accent); - background: var(--accent-soft); -} -.mini-service-icon { - width: 35px; - height: 35px; - flex-basis: 35px; - border-radius: 9px; -} -.visibility-toggle.on { - border-color: color-mix(in srgb, var(--green) 30%, var(--border)); - background: color-mix(in srgb, var(--green) 10%, var(--surface)); - color: var(--green); -} -.field-grid { - display: grid; - grid-template-columns: 1.3fr 1fr 90px; - gap: 12px; - margin-top: 16px; - padding-top: 15px; - border-top: 1px solid var(--border); -} -.field-grid .wide { - grid-column: span 2; -} -label { - min-width: 0; - display: grid; - gap: 6px; -} -label > span:first-child, -.setting-label { - color: var(--text-faint); - font-size: 8px; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; -} -input, -select { - width: 100%; - height: 36px; - border: 1px solid var(--border); - border-radius: 9px; - padding: 0 10px; - outline: 0; - color: var(--text); - background: var(--surface); - font-size: 10px; - transition: border 0.15s; -} -input:hover, -select:hover { - border-color: var(--border-strong); -} -input:focus, -select:focus { - border-color: var(--accent); -} -.input-with-icon { - position: relative; -} -.input-with-icon svg { - position: absolute; - left: 10px; - top: 10px; - color: var(--text-faint); -} -.input-with-icon input { - padding-left: 32px; -} -.inline-status { - height: 36px; - display: flex; - align-items: center; - gap: 7px; - color: var(--text-soft); - font-size: 10px; - text-transform: capitalize; -} -.inline-status > span { - width: 7px; - height: 7px; - border-radius: 50%; - background: var(--text-faint); -} -.inline-status.online > span { - background: var(--green); -} -.inline-status.unknown > span { - background: var(--amber); -} -.add-button { - margin: -5px 0 16px; -} -.inline-form { - display: grid; - grid-template-columns: 1fr 1.5fr 1fr auto; - gap: 10px; - align-items: end; - margin-bottom: 17px; - padding: 16px; - border: 1px solid var(--border); - border-radius: var(--radius); - background: var(--surface); - animation: appear 0.15s ease; -} -.favorite-form { - grid-template-columns: 1fr 1.4fr 1fr 1.2fr auto; -} -.compact-list { - margin-top: 10px; -} -.row-card { - min-height: 69px; - display: flex; - align-items: center; - gap: 13px; - padding: 13px; -} -.row-card label { - width: 165px; -} -.row-card label.grow { - flex: 1; - width: auto; -} -.row-card label.small-field { - width: 80px; -} -.row-card label.icon-field { - width: 130px; -} -.danger-icon { - width: 34px; - height: 34px; - display: grid; - place-items: center; - flex: 0 0 34px; - border: 0; - border-radius: 8px; - color: var(--text-faint); - background: transparent; - cursor: pointer; -} -.danger-icon:hover { - color: #d0574a; - background: color-mix(in srgb, #d0574a 10%, transparent); -} -.number-pill { - width: 32px; - height: 32px; - display: grid; - place-items: center; - border-radius: 9px; - background: var(--surface-muted); - color: var(--text-faint); - font-size: 9px; -} -.create-row { - display: flex; - gap: 9px; - margin-bottom: 14px; -} -.create-row input { - max-width: 300px; - height: 39px; -} -.soft-empty { - min-height: 180px; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - gap: 8px; - border: 1px dashed var(--border-strong); - border-radius: var(--radius); - color: var(--text-faint); - text-align: center; -} -.soft-empty strong { - color: var(--text); - font-size: 12px; -} -.soft-empty span { - max-width: 320px; - font-size: 10px; - line-height: 1.5; -} -.compact-empty { - min-height: 140px; -} - -.setting-label { - margin: 34px 0 12px; - font-size: 9px; -} -.theme-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 10px; -} -.theme-card { - position: relative; - min-width: 0; - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 10px; - background: var(--surface-raised); - text-align: left; - cursor: pointer; -} -.theme-card:hover, -.theme-card.selected { - border-color: var(--border-strong); - box-shadow: var(--shadow-soft); -} -.theme-card.selected { - box-shadow: 0 0 0 1px var(--accent); -} -.theme-card > svg { - position: absolute; - right: 12px; - bottom: 18px; - color: var(--accent); -} -.theme-card > span:nth-child(2) { - display: grid; - padding: 11px 3px 6px; -} -.theme-card strong { - font-size: 11px; -} -.theme-card small { - margin-top: 4px; - color: var(--text-faint); - font-size: 8px; - line-height: 1.4; -} -.theme-preview { - height: 85px; - display: flex; - align-items: end; - gap: 5px; - padding: 12px; - border-radius: 10px; - overflow: hidden; - background: #f1f2ed; -} -.theme-preview.dark { - background: #171916; -} -.theme-preview.harbor { - background: #11212b; -} -.theme-preview i { - display: block; - flex: 1; - height: 44px; - border-radius: 6px; - background: #fff; - border: 1px solid #dedfd8; -} -.theme-preview i:nth-child(2) { - height: 58px; -} -.theme-preview.light i:first-child { - background: #e66040; - border-color: #e66040; -} -.theme-preview.dark i { - background: #252824; - border-color: #353a33; -} -.theme-preview.dark i:first-child { - background: #f17a59; -} -.theme-preview.harbor i { - background: #20333e; - border-color: #36515c; -} -.theme-preview.harbor i:first-child { - background: #e58963; -} -.view-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 10px; -} -.view-card { - display: flex; - align-items: center; - gap: 12px; - min-height: 79px; - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 14px; - background: var(--surface-raised); - text-align: left; - cursor: pointer; -} -.view-card:hover, -.view-card.selected { - border-color: var(--border-strong); -} -.view-card.selected { - box-shadow: 0 0 0 1px var(--accent); -} -.view-card > svg:first-child { - color: var(--accent); -} -.view-card > span { - flex: 1; - display: grid; -} -.view-card strong { - font-size: 11px; -} -.view-card small { - margin-top: 4px; - color: var(--text-faint); - font-size: 8px; -} -.view-card > svg:last-child { - color: var(--accent); -} -.appearance-preview { - position: relative; - min-height: 112px; - display: flex; - align-items: center; - gap: 16px; - overflow: hidden; - border: 1px solid color-mix(in srgb, var(--accent) 22%, var(--border)); - border-radius: var(--radius-lg); - padding: 22px; - background: - radial-gradient( - circle at 12% 0%, - color-mix(in srgb, var(--accent) 20%, transparent), - transparent 45% - ), - var(--surface-raised); - box-shadow: var(--shadow-soft); -} -.appearance-preview > div { - min-width: 0; - flex: 1; - display: grid; -} -.appearance-preview > div small { - color: var(--accent); - font-size: 8px; - font-weight: 800; - letter-spacing: 0.16em; -} -.appearance-preview > div strong { - margin-top: 5px; - font-size: 18px; - letter-spacing: -0.025em; -} -.appearance-preview > div span { - margin-top: 5px; - color: var(--text-soft); - font-size: 10px; -} -.appearance-preview > svg { - color: var(--accent); -} -.preview-orb { - width: 48px; - height: 48px; - flex: 0 0 48px; - border-radius: 16px; - background: linear-gradient(145deg, var(--accent), var(--green)); - box-shadow: 0 12px 26px color-mix(in srgb, var(--accent) 25%, transparent); - transform: rotate(-7deg); -} -.accent-grid { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 9px; -} -.accent-choice { - min-width: 0; - min-height: 49px; - display: flex; - align-items: center; - gap: 9px; - border: 1px solid var(--border); - border-radius: 12px; - padding: 8px 10px; - background: var(--surface-raised); - color: var(--text-soft); - cursor: pointer; - font-size: 10px; - font-weight: 700; -} -.accent-choice.selected { - border-color: var(--accent); - color: var(--text); - box-shadow: 0 0 0 1px var(--accent); -} -.accent-choice > span:nth-child(2) { - min-width: 0; - flex: 1; - overflow: hidden; - text-overflow: ellipsis; -} -.accent-choice > svg { - color: var(--accent); -} -.accent-swatch { - width: 22px; - height: 22px; - flex: 0 0 22px; - border-radius: 8px; - box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.28); -} -.accent-swatch.coral { - background: #e66040; -} -.accent-swatch.ocean { - background: #3188c7; -} -.accent-swatch.violet { - background: #8068d7; -} -.accent-swatch.lime { - background: #65a84b; -} -.choice-grid { - display: grid; - gap: 10px; -} -.choice-grid.three-up, -.background-grid { - grid-template-columns: repeat(3, minmax(0, 1fr)); -} -.option-card { - position: relative; - min-width: 0; - min-height: 114px; - display: flex; - flex-direction: column; - align-items: flex-start; - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 16px; - background: var(--surface-raised); - text-align: left; - cursor: pointer; -} -.option-card:hover, -.option-card.selected { - border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); - box-shadow: var(--shadow-soft); -} -.option-card > svg:first-child { - margin-bottom: 17px; - color: var(--accent); -} -.option-card > svg:last-child { - position: absolute; - right: 13px; - top: 13px; - color: var(--accent); -} -.option-card strong { - font-size: 11px; -} -.option-card small { - margin-top: 5px; - color: var(--text-faint); - font-size: 9px; - line-height: 1.45; -} -.background-grid { - display: grid; - gap: 10px; -} -.background-card { - position: relative; - min-width: 0; - display: grid; - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 9px; - background: var(--surface-raised); - text-align: left; - cursor: pointer; -} -.background-card.selected { - border-color: var(--accent); - box-shadow: 0 0 0 1px var(--accent); -} -.background-card > span:first-child { - height: 64px; - margin-bottom: 10px; - border: 1px solid var(--border); - border-radius: 10px; - background-color: var(--surface); -} -.background-card.aurora > span:first-child { - background: - radial-gradient(circle at 70% 0, var(--accent-soft), transparent 55%), - var(--surface); -} -.background-card.grid > span:first-child { - background-image: - linear-gradient(var(--border) 1px, transparent 1px), - linear-gradient(90deg, var(--border) 1px, transparent 1px); - background-size: 12px 12px; -} -.background-card strong, -.background-card small { - padding-inline: 3px; -} -.background-card strong { - font-size: 11px; -} -.background-card small { - margin: 4px 0 4px; - color: var(--text-faint); - font-size: 8px; - line-height: 1.4; -} -.background-card > svg { - position: absolute; - right: 13px; - bottom: 18px; - color: var(--accent); -} -.toggle-list { - display: grid; - gap: 9px; -} -.toggle-list article { - min-height: 70px; - display: flex; - align-items: center; - gap: 12px; - border: 1px solid var(--border); - border-radius: 14px; - padding: 13px; - background: var(--surface-raised); -} -.toggle-icon { - width: 38px; - height: 38px; - display: grid; - place-items: center; - border-radius: 11px; - color: var(--accent); - background: var(--accent-soft); -} -.toggle-list article > span:nth-child(2) { - min-width: 0; - flex: 1; - display: grid; -} -.toggle-list strong { - font-size: 11px; -} -.toggle-list small { - margin-top: 4px; - color: var(--text-faint); - font-size: 9px; -} -.widget-studio { - margin-bottom: 16px; - border: 1px solid color-mix(in srgb, var(--accent) 20%, var(--border)); - border-radius: 17px; - padding: 16px; - background: - linear-gradient( - 145deg, - color-mix(in srgb, var(--accent) 6%, transparent), - transparent 38% - ), - var(--surface-raised); -} -.widget-studio-heading { - display: flex; - align-items: center; - gap: 11px; -} -.studio-mark { - width: 38px; - height: 38px; - flex: 0 0 38px; - display: grid; - place-items: center; - border-radius: 11px; - color: var(--accent); - background: var(--accent-soft); -} -.widget-studio-heading > span:nth-child(2) { - min-width: 0; - flex: 1; - display: grid; -} -.widget-studio-heading strong { - font-size: 12px; -} -.widget-studio-heading small { - margin-top: 3px; - color: var(--text-soft); - font-size: 9px; - line-height: 1.45; -} -.studio-capacity { - border: 1px solid var(--border); - border-radius: 20px; - padding: 6px 9px; - color: var(--text-soft); - background: var(--surface); - font-size: 8px; - font-weight: 700; - white-space: nowrap; -} -.widget-studio-toolbar { - display: flex; - gap: 8px; - margin: 14px 0 11px; -} -.widget-studio-toolbar > label { - position: relative; - flex: 1; - display: flex; - align-items: center; -} -.widget-studio-toolbar > label svg { - position: absolute; - left: 11px; - color: var(--text-faint); -} -.widget-studio-toolbar input { - height: 38px; - padding-left: 34px; - background: var(--surface); - font-size: 9px; -} -.studio-filters { - display: flex; - gap: 3px; - border: 1px solid var(--border); - border-radius: 11px; - padding: 3px; - background: var(--surface); -} -.studio-filters button { - border: 0; - border-radius: 8px; - padding: 0 9px; - color: var(--text-faint); - background: transparent; - font-size: 8px; - font-weight: 700; - text-transform: capitalize; - cursor: pointer; -} -.studio-filters button.active { - color: var(--text); - background: var(--surface-muted); -} -.widget-catalog { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px; -} -.widget-option { - min-width: 0; - display: grid; - grid-template-rows: auto auto auto 1fr; - border: 1px solid var(--border); - border-radius: 13px; - padding: 12px; - background: color-mix(in srgb, var(--surface) 84%, transparent); -} -.widget-option.active { - border-color: color-mix(in srgb, var(--accent) 38%, var(--border)); - box-shadow: inset 0 0 0 1px var(--accent-soft); -} -.widget-option > header { - min-width: 0; - display: flex; - align-items: center; - gap: 8px; -} -.widget-option-icon { - width: 31px; - height: 31px; - flex: 0 0 31px; - border-radius: 8px; -} -.widget-option > header > span:nth-child(2) { - min-width: 0; - flex: 1; - display: grid; -} -.widget-option > header strong { - overflow: hidden; - text-overflow: ellipsis; - color: var(--text); - font-size: 10px; - white-space: nowrap; -} -.widget-option > header small { - margin-top: 2px; - overflow: hidden; - text-overflow: ellipsis; - color: var(--text-faint); - font-size: 7px; - white-space: nowrap; -} -.capability-source { - border-radius: 5px; - padding: 4px 5px; - color: var(--text-faint); - background: var(--surface-muted); - font-size: 6px; - font-weight: 800; - letter-spacing: 0.04em; - text-transform: uppercase; - white-space: nowrap; -} -.capability-source.provider { - color: var(--accent); - background: var(--accent-soft); -} -.widget-option > p { - min-height: 31px; - margin: 9px 0 8px; - color: var(--text-soft); - font-size: 8px; - line-height: 1.5; -} -.capability-features { - display: flex; - flex-wrap: wrap; - gap: 4px; -} -.capability-features span { - border-radius: 5px; - padding: 3px 5px; - color: var(--text-faint); - background: var(--surface-muted); - font-size: 6px; - font-weight: 700; -} -.widget-option > footer { - align-self: end; - display: flex; - align-items: center; - justify-content: space-between; - gap: 6px; - margin-top: 11px; - padding-top: 9px; - border-top: 1px solid var(--border); -} -.credential-mode { - display: flex; - align-items: center; - gap: 4px; - color: var(--text-faint); - font-size: 7px; - font-weight: 700; -} -.credential-mode.none { - color: var(--green); -} -.widget-option .secondary-button { - min-height: 30px; - padding: 0 8px; - font-size: 7px; -} -.widget-option .secondary-button.active { - color: var(--accent); - background: var(--accent-soft); -} -.server-widget-config { - margin-bottom: 12px; - border: 1px solid color-mix(in srgb, var(--green) 24%, var(--border)); - border-radius: 17px; - padding: 16px; - background: - radial-gradient( - circle at 100% 0, - color-mix(in srgb, var(--green) 10%, transparent), - transparent 48% - ), - var(--surface-raised); -} -.server-widget-config > header { - display: flex; - align-items: center; - gap: 11px; -} -.server-widget-config > header > span:nth-child(2) { - min-width: 0; - flex: 1; - display: grid; -} -.server-widget-config > header strong { - font-size: 12px; -} -.server-widget-config > header small { - margin-top: 3px; - color: var(--text-soft); - font-size: 9px; -} -.widget-configurator { - grid-column: 1 / -1; - margin-top: 12px; - border-top: 1px solid var(--border); - padding-top: 12px; -} -.widget-config-heading { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - margin-bottom: 8px; -} -.widget-config-heading > span, -.widget-content-picker legend { - display: flex; - align-items: center; - gap: 5px; - color: var(--text-soft); - font-size: 8px; - font-weight: 800; - letter-spacing: 0.04em; - text-transform: uppercase; -} -.widget-config-heading small { - color: var(--text-faint); - font-size: 7px; -} -.widget-size-picker { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 5px; -} -.widget-size-picker button { - min-width: 0; - display: grid; - gap: 2px; - border: 1px solid var(--border); - border-radius: 9px; - padding: 8px; - color: var(--text-soft); - background: var(--surface); - text-align: left; - cursor: pointer; -} -.widget-size-picker button.active { - border-color: color-mix(in srgb, var(--accent) 48%, var(--border)); - color: var(--accent); - background: var(--accent-soft); -} -.widget-size-picker strong { - font-size: 8px; -} -.widget-size-picker small { - overflow: hidden; - color: var(--text-faint); - font-size: 6px; - text-overflow: ellipsis; - white-space: nowrap; -} -.widget-content-picker { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 6px; - margin: 12px 0 0; - border: 0; - padding: 0; -} -.widget-content-picker legend { - grid-column: 1 / -1; - margin-bottom: 2px; - padding: 0; -} -.widget-content-picker > button { - min-width: 0; - display: flex; - align-items: center; - gap: 7px; - border: 1px solid var(--border); - border-radius: 9px; - padding: 8px; - background: color-mix(in srgb, var(--surface) 82%, transparent); - color: var(--text); - text-align: left; - cursor: pointer; -} -.widget-content-check { - width: 17px; - height: 17px; - flex: 0 0 17px; - display: grid; - place-items: center; - border: 1px solid var(--border-strong); - border-radius: 5px; - color: var(--surface-raised); - background: var(--surface-raised); -} -.widget-content-picker > button.active .widget-content-check { - border-color: var(--accent); - background: var(--accent); -} -.widget-content-picker > button > span:last-child { - min-width: 0; - display: grid; -} -.widget-content-picker strong { - font-size: 8px; -} -.widget-content-picker small { - margin-top: 2px; - overflow: hidden; - color: var(--text-faint); - font-size: 6px; - text-overflow: ellipsis; - white-space: nowrap; -} -.security-callout { - display: flex; - gap: 12px; - padding: 15px; - border: 1px solid color-mix(in srgb, var(--green) 22%, var(--border)); - border-radius: 13px; - background: color-mix(in srgb, var(--green) 7%, var(--surface)); -} -.security-callout > svg { - color: var(--green); -} -.security-callout span { - display: grid; -} -.security-callout strong { - font-size: 11px; -} -.security-callout small { - margin-top: 4px; - color: var(--text-soft); - font-size: 9px; - line-height: 1.5; -} -.integration-list { - display: grid; - gap: 8px; - margin-top: 14px; -} -.integration-list article { - min-height: 70px; - display: flex; - align-items: center; - gap: 12px; - border: 1px solid var(--border); - border-radius: 13px; - padding: 13px; - background: var(--surface-raised); -} -.integration-mark { - width: 38px; - height: 38px; - display: grid; - place-items: center; - border-radius: 10px; - color: var(--green); - background: color-mix(in srgb, var(--green) 10%, var(--surface)); -} -.integration-mark.degraded { - color: var(--amber); - background: color-mix(in srgb, var(--amber) 10%, var(--surface)); -} -.integration-mark.disabled { - color: var(--text-faint); - background: var(--surface-muted); -} -.integration-copy { - min-width: 0; - flex: 1; - display: grid; -} -.integration-list strong { - font-size: 11px; -} -.integration-list small { - margin-top: 4px; - color: var(--text-faint); - font-size: 9px; -} -.environment-keys { - display: flex; - flex-wrap: wrap; - gap: 4px; - margin-top: 7px; -} -.environment-keys code { - border-radius: 5px; - padding: 3px 5px; - color: var(--text-soft); - background: var(--surface-muted); - font-size: 7px; -} -.credential-form { - display: grid; - gap: 11px; - margin-top: 13px; - padding-top: 13px; - border-top: 1px solid var(--border); -} -.credential-reconfigure { - justify-self: start; - display: flex; - align-items: center; - gap: 5px; - margin-top: 8px; - border: 0; - padding: 0; - color: var(--accent); - background: transparent; - font-size: 8px; - font-weight: 700; - cursor: pointer; -} -.credential-heading { - display: flex; - align-items: flex-start; - gap: 7px; - color: var(--accent); -} -.credential-heading > span { - display: grid; -} -.credential-heading strong { - color: var(--text); - font-size: 9px; -} -.credential-heading small { - margin-top: 2px; - font-size: 8px; -} -.credential-fields { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px; -} -.credential-fields label { - min-width: 0; - display: grid; - gap: 5px; -} -.credential-fields label:only-child { - grid-column: 1 / -1; -} -.credential-fields label > span { - color: var(--text-faint); - font-size: 8px; - font-weight: 700; -} -.credential-fields input { - width: 100%; - min-width: 0; - height: 38px; - border: 1px solid var(--border); - border-radius: 9px; - padding: 0 10px; - color: var(--text); - background: var(--surface); - font: inherit; - font-size: 10px; -} -.credential-fields input:focus { - outline: 2px solid var(--accent-soft); - border-color: var(--accent); -} -.credential-save { - justify-self: start; - min-height: 35px; - padding: 0 12px; - font-size: 9px; -} -.state-badge { - border-radius: 20px; - padding: 5px 8px; - color: var(--green); - background: color-mix(in srgb, var(--green) 9%, transparent); - font-size: 8px; - font-weight: 700; - text-transform: uppercase; -} -.state-badge.degraded { - color: var(--amber); - background: color-mix(in srgb, var(--amber) 10%, transparent); -} -.state-badge.disabled { - color: var(--text-faint); - background: var(--surface-muted); -} -.external-toggle { - display: flex; - align-items: center; - gap: 15px; - margin-top: 19px; - padding: 17px 2px; - border-top: 1px solid var(--border); -} -.external-toggle > span { - flex: 1; - display: grid; -} -.external-toggle strong { - font-size: 11px; -} -.external-toggle small { - margin-top: 5px; - color: var(--text-faint); - font-size: 9px; - line-height: 1.45; -} -.switch { - width: 39px; - height: 22px; - border: 0; - border-radius: 20px; - padding: 3px; - background: var(--surface-muted); - cursor: pointer; - transition: 0.15s; -} -.switch span { - display: block; - width: 16px; - height: 16px; - border-radius: 50%; - background: var(--surface-raised); - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15); - transition: transform 0.15s; -} -.switch.on { - background: var(--accent); -} -.switch.on span { - transform: translateX(17px); -} -.backup-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 12px; -} -.backup-grid article { - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 20px; - background: var(--surface-raised); -} -.backup-icon { - width: 43px; - height: 43px; - display: grid; - place-items: center; - border-radius: 12px; - color: var(--accent); - background: var(--accent-soft); -} -.backup-grid h3 { - margin: 20px 0 7px; - font-size: 14px; -} -.backup-grid p { - min-height: 48px; - margin: 0 0 18px; - color: var(--text-soft); - font-size: 9px; - line-height: 1.6; -} -.privacy-note { - display: flex; - align-items: center; - gap: 11px; - margin-top: 15px; - padding: 14px; - color: var(--text-soft); -} -.privacy-note svg { - color: var(--green); -} -.privacy-note span { - display: grid; -} -.privacy-note strong { - color: var(--text); - font-size: 10px; -} -.privacy-note small { - margin-top: 3px; - font-size: 8px; -} -.privacy-note code { - color: var(--accent); -} - -.toast { - position: fixed; - z-index: 100; - right: 24px; - bottom: 24px; - display: flex; - align-items: center; - gap: 9px; - border: 1px solid var(--border); - border-radius: 11px; - padding: 11px 14px; - background: var(--text); - color: var(--bg); - box-shadow: var(--shadow); - font-size: 10px; - font-weight: 700; - animation: toast-in 0.2s ease both; -} -.toast svg { - color: var(--green); -} -.fatal-state { - min-height: 100vh; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - padding: 30px; - text-align: center; -} -.fatal-state .logo-mark { - margin-bottom: 35px; -} -.fatal-state h1 { - margin: 0 0 10px; - font-size: 34px; - letter-spacing: -0.05em; -} -.fatal-state > p:not(.eyebrow) { - max-width: 430px; - margin: 0 0 23px; - color: var(--text-soft); - font-size: 12px; -} -.loading-screen { - max-width: none; -} -.skeleton { - border-radius: 10px; - background: linear-gradient( - 90deg, - var(--surface-muted) 25%, - var(--surface-raised) 50%, - var(--surface-muted) 75% - ); - background-size: 200% 100%; - animation: shimmer 1.5s infinite; -} -.skeleton.small { - width: 130px; - height: 9px; -} -.skeleton.title { - width: min(510px, 80%); - height: 104px; - margin: 17px 0 37px; -} -.skeleton.search { - max-width: var(--content-max); - height: 61px; - margin: 0 auto 45px; -} -.skeleton.section { - width: 150px; - height: 24px; - margin-bottom: 15px; -} -.skeleton-grid { - display: grid; - grid-template-columns: repeat( - auto-fill, - minmax(min(100%, var(--tile-min)), 1fr) - ); - gap: 11px; -} -.skeleton.card { - height: 91px; -} -.spin { - animation: spin 0.8s linear infinite; -} -@keyframes appear { - from { - opacity: 0; - transform: translateY(-5px); - } -} -@keyframes toast-in { - from { - opacity: 0; - transform: translateY(8px); - } -} -@keyframes shimmer { - to { - background-position: -200% 0; - } -} -@keyframes spin { - to { - transform: rotate(360deg); - } -} - -.widget-shell { - position: relative; - min-width: 0; - display: flex; -} -.widget-shell.widget-size-wide { - grid-column: span 2; -} -.widget-shell > .service-widget { - width: 100%; - height: 100%; -} -.widget-shell > .service-widget.widget-size-wide { - grid-column: auto; -} -.widgets-actions { - display: flex; - align-items: center; - gap: 5px; -} -.widgets-edit { - display: flex; - align-items: center; - gap: 5px; - border: 1px solid var(--border); - border-radius: 9px; - padding: 7px 9px; - color: var(--text-soft); - background: var(--surface); - font-size: 9px; - cursor: pointer; -} -.widgets-edit.active { - border-color: color-mix(in srgb, var(--accent) 42%, var(--border)); - color: var(--accent); - background: var(--accent-soft); -} -.widgets-grid.editing .widget-shell { - padding-top: 36px; -} -.widgets-grid.editing .service-widget { - pointer-events: none; -} -.widget-edit-error { - margin: -4px 0 9px; - color: var(--red); - font-size: 9px; -} -.widget-edit-toolbar { - position: absolute; - z-index: 3; - inset: 0 0 auto; - height: 31px; - display: flex; - align-items: center; - justify-content: flex-end; - gap: 4px; -} -.widget-edit-toolbar button { - height: 28px; - display: flex; - align-items: center; - gap: 4px; - border: 1px solid var(--border); - border-radius: 8px; - padding: 0 8px; - color: var(--text-soft); - background: var(--surface-raised); - font-size: 8px; - cursor: pointer; -} -.widget-edit-toolbar button:disabled { - opacity: 0.4; - cursor: not-allowed; -} -.widget-freshness { - border: 1px solid var(--border); - border-radius: 999px; - padding: 4px 6px; - color: var(--text-faint); - font-size: 6px; - font-weight: 800; - letter-spacing: 0.06em; - text-transform: uppercase; -} -.widget-freshness.fresh { - border-color: color-mix(in srgb, var(--green) 35%, var(--border)); - color: var(--green); -} -.widget-freshness.stale, -.widget-warning { - border-color: color-mix(in srgb, var(--amber) 55%, var(--border)); -} -.widget-freshness.stale { - color: var(--amber); -} -.widget-sparkline { - display: grid; - gap: 4px; - margin: 10px 0 0; -} -.widget-sparkline svg { - width: 100%; - height: 30px; - overflow: visible; -} -.widget-sparkline polyline { - stroke: var(--accent); - stroke-width: 1.7; - stroke-linecap: round; - stroke-linejoin: round; - filter: drop-shadow( - 0 3px 5px color-mix(in srgb, var(--accent) 25%, transparent) - ); -} -.widget-sparkline polygon { - fill: color-mix(in srgb, var(--accent) 18%, transparent); -} -.widget-sparkline rect { - fill: color-mix(in srgb, var(--accent) 72%, transparent); -} -.widget-sparkline figcaption { - display: flex; - justify-content: space-between; - gap: 10px; - color: var(--text-faint); - font-size: 7px; - white-space: nowrap; -} -.unraid-native-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 6px; - margin-top: 10px; -} -.unraid-native-grid > span { - min-width: 0; - display: grid; - grid-template-columns: auto 1fr; - column-gap: 6px; - border: 1px solid var(--border); - border-radius: 10px; - padding: 8px; - background: color-mix(in srgb, var(--surface) 78%, transparent); -} -.unraid-native-grid svg { - grid-row: 1 / 3; - color: var(--green); -} -.unraid-native-grid small, -.unraid-native-grid em { - overflow: hidden; - color: var(--text-faint); - font-size: 7px; - font-style: normal; - text-overflow: ellipsis; - white-space: nowrap; -} -.unraid-native-grid strong { - font-size: 10px; -} -.unraid-native-grid em { - grid-column: 2; - margin-top: 2px; -} -.server-widget-order { - width: 150px; - display: grid; - gap: 5px; - margin-top: 12px; - color: var(--text-soft); - font-size: 8px; - font-weight: 700; -} - -.server-identity-fields { - display: grid; - grid-template-columns: minmax(160px, 0.7fr) minmax(260px, 1.3fr); - gap: 12px; - margin: 16px 0; - padding: 14px; - border: 1px solid var(--line); - border-radius: 16px; - background: color-mix(in srgb, var(--surface) 72%, transparent); -} - -.server-identity-fields label, -.metric-visibility-list { - display: grid; - gap: 7px; -} - -.server-identity-fields label > span, -.metric-visibility-list > strong { - font-size: 0.72rem; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.server-identity-fields small, -.metric-visibility-list > small { - color: var(--muted); - font-size: 0.72rem; -} - -.metric-visibility-list { - margin-top: 14px; - padding-top: 14px; - border-top: 1px solid var(--line); -} - -.metric-visibility-list > div { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); - gap: 8px; -} - -.metric-config-row { - min-width: 0; - display: grid; - grid-template-columns: minmax(0, 1fr) 92px; - gap: 6px; - align-items: stretch; -} - -.metric-config-row > label { - display: flex; - align-items: stretch; -} - -.metric-config-row select { - width: 100%; - min-height: 100%; - padding-inline: 8px; -} - -.metric-visibility-list button { - display: grid; - gap: 3px; - min-height: 62px; - padding: 10px 12px; - color: var(--muted); - text-align: left; - border: 1px solid var(--line); - border-radius: 12px; - background: transparent; -} - -.metric-visibility-list button.active { - color: var(--text); - border-color: color-mix(in srgb, var(--accent) 52%, var(--line)); - background: color-mix(in srgb, var(--accent) 9%, transparent); -} - -.metric-visibility-list button span { - font-weight: 700; -} - -.metric-visibility-list button small { - color: inherit; - opacity: 0.72; -} - -.widget-inline-connection { - display: grid; - gap: 6px; - margin-top: 14px; - padding: 14px; - border: 1px dashed color-mix(in srgb, var(--accent) 40%, var(--line)); - border-radius: 14px; -} - -.widget-inline-connection > small { - color: var(--muted); - font-size: 0.76rem; -} - -.widget-data-contract { - display: flex; - align-items: flex-start; - gap: 12px; - margin: 12px 0 16px; - padding: 12px 14px; - color: var(--muted); - font-size: 0.78rem; - border: 1px solid var(--line); - border-radius: 14px; -} - -.widget-data-contract strong { - flex: 0 0 auto; - color: var(--text); -} - -.widget-data-contract code { - display: block; - margin-top: 5px; - color: var(--text); - white-space: normal; -} - -@media (max-width: 680px) { - .server-identity-fields { - grid-template-columns: 1fr; - } - - .metric-visibility-list > div { - grid-template-columns: 1fr; - } - - .widget-data-contract { - flex-direction: column; - } - - .widget-data-contract > span { - min-width: 0; - } - - .widget-data-contract code { - overflow-wrap: anywhere; - } -} -.server-widget-order input { - width: 100%; -} -.widget-advanced-config { - grid-column: 1 / -1; - margin-top: 9px; - border-top: 1px solid var(--border); - padding-top: 9px; -} -.widget-advanced-config summary { - display: flex; - align-items: center; - gap: 6px; - color: var(--text-soft); - font-size: 8px; - font-weight: 800; - cursor: pointer; -} -.widget-advanced-grid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 8px; - margin-top: 10px; -} -.widget-config-section { - display: grid; - gap: 8px; - margin-top: 12px; - padding-top: 12px; - border-top: 1px solid var(--border); -} -.widget-config-section > header { - display: flex; - align-items: flex-start; - gap: 7px; - color: var(--text-soft); -} -.widget-config-section > header > span { - display: grid; - gap: 2px; -} -.widget-config-section > header strong { - font-size: 9px; -} -.widget-config-section > header small { - color: var(--text-faint); - font-size: 7px; -} -.widget-advanced-grid label { - min-width: 0; - display: grid; - gap: 5px; -} -.widget-advanced-grid label > span, -.threshold-input > span { - display: flex; - align-items: center; - gap: 4px; - color: var(--text-faint); - font-size: 7px; - font-weight: 700; -} -.threshold-input { - position: relative; -} -.threshold-input input { - width: 100%; - padding-right: 24px; -} -.threshold-input > span { - position: absolute; - top: 50%; - right: 9px; - transform: translateY(-50%); -} -.sparkline-toggle { - min-width: 0; - display: flex; - align-items: center; - gap: 8px; - border: 1px solid var(--border); - border-radius: 10px; - padding: 8px; - color: var(--text-soft); - background: var(--surface); - text-align: left; - cursor: pointer; -} -.sparkline-toggle.active { - border-color: color-mix(in srgb, var(--accent) 42%, var(--border)); - color: var(--accent); - background: var(--accent-soft); -} -.sparkline-toggle > span { - min-width: 0; - display: grid; -} -.sparkline-toggle strong { - font-size: 8px; -} -.sparkline-toggle small { - margin-top: 2px; - color: var(--text-faint); - font-size: 6px; -} -.metric-order-list { - display: flex; - flex-wrap: wrap; - gap: 5px; - margin-top: 10px; -} -.metric-order-list > span { - display: flex; - align-items: center; - gap: 3px; - border: 1px solid var(--border); - border-radius: 8px; - padding: 4px 5px 4px 8px; - background: var(--surface); -} -.metric-order-list strong { - margin-right: 3px; - font-size: 7px; -} -.metric-order-list button { - width: 22px; - height: 22px; - display: grid; - place-items: center; - border: 0; - border-radius: 5px; - color: var(--text-soft); - background: var(--surface-muted); - cursor: pointer; -} -.metric-order-list button:disabled { - opacity: 0.35; -} -.widget-presets { - margin-top: 14px; - border: 1px solid var(--border); - border-radius: 17px; - padding: 16px; - background: var(--surface-raised); -} -.widget-presets > header { - display: flex; - align-items: center; - gap: 10px; -} -.widget-presets > header > span:last-child { - display: grid; -} -.widget-presets > header strong { - font-size: 12px; -} -.widget-presets > header small { - margin-top: 2px; - color: var(--text-faint); - font-size: 8px; -} -.preset-create-row { - display: grid; - grid-template-columns: minmax(140px, 1fr) auto auto; - gap: 7px; - margin-top: 12px; -} -.preset-create-row button { - min-height: 38px; -} -.preset-list { - display: grid; - gap: 6px; - margin-top: 10px; -} -.preset-list > p { - color: var(--text-faint); - font-size: 9px; -} -.preset-list article { - display: grid; - grid-template-columns: minmax(0, 1fr) auto auto auto; - align-items: center; - gap: 6px; - border-top: 1px solid var(--border); - padding-top: 8px; -} -.preset-list article > span { - min-width: 0; - display: grid; -} -.preset-list article strong { - font-size: 10px; -} -.preset-list article small { - color: var(--text-faint); - font-size: 7px; -} -.preset-list button, -.preset-list a, -.preset-create-row > button:not(.primary-button) { - min-height: 31px; - display: flex; - align-items: center; - gap: 5px; - border: 1px solid var(--border); - border-radius: 8px; - padding: 0 8px; - color: var(--text-soft); - background: var(--surface); - font-size: 8px; - text-decoration: none; - cursor: pointer; -} -.operations-overview { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 8px; -} -.operations-overview article { - display: flex; - align-items: center; - gap: 10px; - border: 1px solid var(--border); - border-radius: 13px; - padding: 13px; - background: var(--surface-raised); -} -.operations-overview article > span { - display: grid; -} -.operations-overview strong { - font-size: 18px; -} -.operations-overview small { - color: var(--text-faint); - font-size: 8px; -} -.diagnostics-run { - margin-top: 12px; -} -.diagnostics-error { - margin-top: 9px; - color: var(--red); - font-size: 9px; -} -.diagnostics-result { - margin-top: 12px; - border: 1px solid var(--border); - border-radius: 15px; - padding: 15px; - background: var(--surface-raised); -} -.diagnostics-result.healthy { - border-color: color-mix(in srgb, var(--green) 34%, var(--border)); -} -.diagnostics-result > header { - display: flex; - align-items: center; - gap: 9px; -} -.diagnostics-result > header > span { - display: grid; -} -.diagnostics-result > header strong { - text-transform: capitalize; -} -.diagnostics-result > header small { - color: var(--text-faint); - font-size: 8px; -} -.diagnostics-result dl { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 7px; - margin: 12px 0 0; -} -.diagnostics-result dl > div { - border: 1px solid var(--border); - border-radius: 9px; - padding: 8px; -} -.diagnostics-result dt { - color: var(--text-faint); - font-size: 7px; -} -.diagnostics-result dd { - margin: 4px 0 0; - font-size: 10px; - font-weight: 700; - text-transform: capitalize; -} -.operations-security { - margin-top: 12px; -} - -@media (max-width: 1180px) { - :root { - --rail: 78px; - } - .rail { - padding-inline: 13px; - align-items: center; - } - .brand { - padding-inline: 0; - } - .brand-copy, - .rail nav button span, - .rail-content, - .rail-status > span:last-child { - display: none; - } - .rail nav button { - width: 48px; - height: 44px; - justify-content: center; - padding: 0; - } - .rail-status { - padding-inline: 0; - } - .app-grid { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } - .settings-layout { - padding: 0 30px; - } -} - -@media (max-width: 800px) { - :root { - --rail: 0px; - } - .rail { - display: none; - } - .dashboard, - .settings-page { - margin-left: 0; - width: 100%; - padding-bottom: 90px; - } - .dashboard { - padding: 38px 25px 95px; - } - .dashboard-hero { - grid-template-columns: 1fr; - gap: 25px; - } - .hero-copy h1 { - max-width: 680px; - font-size: clamp(40px, 8vw, 58px); - } - .hero-overview { - max-width: 560px; - } - .widgets-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - .widget-advanced-grid, - .diagnostics-result dl { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - .dashboard-header h1 { - font-size: 42px; - } - .app-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - .settings-topbar { - padding: 0 22px; - } - .settings-layout { - display: block; - padding: 0 24px; - } - .settings-content { - max-width: 760px; - margin-inline: auto; - } - .settings-sidebar { - border: 0; - padding: 30px 0 5px; - } - .settings-sidebar h1 { - margin-bottom: 18px; - } - .settings-sidebar nav { - display: flex; - overflow-x: auto; - padding-bottom: 8px; - scrollbar-width: none; - scroll-snap-type: x proximity; - overscroll-behavior-inline: contain; - } - .settings-sidebar nav::-webkit-scrollbar { - display: none; - } - .settings-sidebar nav button { - flex: 0 0 auto; - scroll-snap-align: start; - } - .local-note { - display: none; - } - .settings-content { - padding: 32px 0 50px; - } - .mobile-nav { - position: fixed; - z-index: 30; - bottom: 13px; - left: 50%; - transform: translateX(-50%); - display: flex; - gap: 4px; - border: 1px solid var(--border); - border-radius: 16px; - padding: 5px; - background: color-mix(in srgb, var(--surface-raised) 90%, transparent); - box-shadow: var(--shadow); - backdrop-filter: blur(15px); - } - .mobile-nav button { - width: 100px; - height: 46px; - display: flex; - align-items: center; - justify-content: center; - gap: 7px; - border: 0; - border-radius: 11px; - background: transparent; - color: var(--text-soft); - font-size: 9px; - font-weight: 700; - } - .mobile-nav button.active { - background: var(--surface-muted); - color: var(--text); - } - .mobile-nav button.active svg { - color: var(--accent); - } - .field-grid { - grid-template-columns: 1fr 1fr; - } - .field-grid .wide { - grid-column: 1 / -1; - } - .inline-form { - grid-template-columns: 1fr 1fr; - } - .management-toolbar { - align-items: stretch; - flex-direction: column; - } - .filter-pills { - align-self: flex-start; - } -} - -@media (max-width: 520px) { - .dashboard { - padding: 29px 17px 94px; - } - .dashboard-header { - margin-bottom: 29px; - } - .dashboard-header h1 { - font-size: 35px; - } - .dashboard-header br { - display: none; - } - .dashboard-hero { - gap: 20px; - margin-bottom: 34px; - } - .dashboard-hero::after { - bottom: -17px; - } - .hero-copy h1 { - font-size: clamp(36px, 12vw, 48px); - } - .hero-intro { - margin-top: 16px; - font-size: 12px; - } - .hero-overview { - padding: 13px; - border-radius: 18px; - } - .hero-metrics > span { - padding: 9px; - } - .hero-metrics svg { - display: none; - } - .hero-metrics strong { - font-size: 16px; - } - .sync-button { - width: 37px; - height: 37px; - } - .search-bar { - height: 55px; - padding-inline: 15px; - } - .search-bar kbd { - display: none; - } - .degraded-banner { - align-items: flex-start; - } - .degraded-banner button { - display: none; - } - .app-grid { - grid-template-columns: 1fr; - } - .widgets-grid { - grid-template-columns: 1fr; - } - .widget-shell.widget-size-wide { - grid-column: auto; - } - .service-widget.widget-size-wide { - grid-column: auto; - } - .widgets-section > header { - align-items: flex-start; - } - .widgets-actions { - align-items: stretch; - flex-direction: column; - } - .app-shell:has(.widgets-grid.editing) .mobile-nav { - display: none; - } - .widget-advanced-grid, - .operations-overview, - .diagnostics-result dl { - grid-template-columns: 1fr; - } - .preset-create-row, - .preset-list article { - grid-template-columns: 1fr; - } - .preset-list article > button, - .preset-list article > a { - justify-content: center; - } - .service-widget { - min-height: 235px; - } - .app-tile { - min-height: 82px; - } - .empty-state { - min-height: 370px; - padding-inline: 15px; - } - .dashboard-footer { - margin-top: 0; - } - .dashboard-footer button { - display: none; - } - .settings-topbar { - height: 56px; - } - .settings-layout { - padding-inline: 16px; - } - .settings-sidebar { - padding-top: 25px; - } - .settings-sidebar h1 { - font-size: 23px; - } - .settings-sidebar nav button { - padding-inline: 10px; - } - .settings-sidebar nav button svg { - display: none; - } - .settings-content { - padding-top: 25px; - } - .settings-heading h2 { - font-size: 27px; - } - .settings-summary { - flex-wrap: wrap; - } - .settings-summary span { - flex: 1; - } - .management-title { - flex-wrap: wrap; - } - .visibility-toggle { - margin-left: 47px; - } - .widget-toggle { - margin-left: 47px; - } - .field-grid { - grid-template-columns: 1fr; - } - .field-grid .wide { - grid-column: auto; - } - .row-card { - flex-wrap: wrap; - } - .row-card label, - .row-card label.grow { - width: calc(100% - 50px); - flex: none; - } - .row-card label.small-field { - margin-left: 45px; - width: 80px; - } - .row-card label.icon-field { - margin-left: 45px; - width: calc(100% - 95px); - } - .row-card .danger-icon { - margin-left: auto; - } - .inline-form { - grid-template-columns: 1fr; - } - .create-row { - align-items: stretch; - } - .create-row input { - flex: 1; - min-width: 0; - } - .theme-grid, - .view-grid, - .accent-grid, - .choice-grid.three-up, - .background-grid, - .backup-grid { - grid-template-columns: 1fr; - } - .accent-grid { - grid-template-columns: 1fr 1fr; - } - .appearance-preview { - min-height: 96px; - padding: 16px; - } - .appearance-preview > svg { - display: none; - } - .preview-orb { - width: 40px; - height: 40px; - flex-basis: 40px; - } - .toggle-list article { - align-items: flex-start; - } - .toggle-list .switch { - margin-top: 8px; - } - .widget-studio { - padding: 13px; - } - .widget-studio-heading { - flex-wrap: wrap; - } - .studio-capacity { - margin-left: 49px; - } - .widget-studio-toolbar { - align-items: stretch; - flex-direction: column; - } - .widget-studio-toolbar > label { - min-height: 38px; - } - .studio-filters button { - min-height: 31px; - flex: 1; - } - .widget-catalog { - grid-template-columns: 1fr; - } - .widget-content-picker { - grid-template-columns: 1fr; - } - .theme-preview { - height: 72px; - } - .backup-grid p { - min-height: auto; - } - .integration-list article { - flex-wrap: wrap; - } - .credential-fields { - grid-template-columns: 1fr; - } - .credential-fields label:only-child { - grid-column: auto; - } - .state-badge { - margin-left: 49px; - } - .toast { - right: 16px; - bottom: 82px; - left: 16px; - justify-content: center; - } - .skeleton-grid { - grid-template-columns: 1fr; - } -} - -@media (min-width: 1800px) { - :root { - --rail: 276px; - } - .rail { - padding-inline: 28px; - } - .dashboard { - padding-inline: clamp(48px, 2.8vw, 96px); - } - .settings-layout { - max-width: 1760px; - } -} - -@media (min-width: 2400px) { - .widgets-grid { - grid-template-columns: repeat(auto-fill, minmax(330px, 1fr)); - } - .app-grid { - gap: 16px; - } -} - -@media (max-height: 720px) and (min-width: 1181px) { - .rail-clock, - .rail-services:not(.rail-favorites) { - display: none; - } - .rail-content { - margin-top: 24px; - } -} - -@media (max-width: 370px) { - .dashboard { - padding-inline: 13px; - } - .mobile-nav { - right: 10px; - bottom: 10px; - left: 10px; - transform: none; - } - .mobile-nav button { - width: auto; - flex: 1; - } - .hero-metrics { - gap: 5px; - } - .hero-metrics small { - font-size: 8px; - } - .search-bar { - padding-inline: 13px; - } - .settings-layout { - padding-inline: 12px; - } -} - -@media (prefers-reduced-motion: reduce) { - *, - *::before, - *::after { - scroll-behavior: auto !important; - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - transition-duration: 0.01ms !important; - } -} diff --git a/src/server/app.ts b/src/server/app.ts deleted file mode 100644 index 810b0357..00000000 --- a/src/server/app.ts +++ /dev/null @@ -1,476 +0,0 @@ -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, - favoriteInputSchema, - importSchema, - integrationCredentialInputSchema, - layoutPresetImportSchema, - layoutPresetInputSchema, - preferencesPatchSchema, - widgetLayoutInputSchema, -} from "../shared/contracts.js"; -import { 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 { - 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(); - - 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", - integrations: integrationStates(), - })); - - app.post("/api/discovery/sync", 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() }; - }); - - 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(), - wallpaper: wallpaperStore.describe(), - }; - }); - - 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")); - if ( - patch.widgetEnabled === true && - !current.widgetEnabled && - repository.getApps().filter((item) => item.widgetEnabled).length >= 8 - ) - return reply - .status(400) - .send(errorBody("Er kunnen maximaal 8 servicewidgets actief zijn")); - 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/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: process.env.npm_package_version ?? "0.1.0", - 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(); - const iconMimeTypes: Record = { - ".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; -} diff --git a/src/server/database.ts b/src/server/database.ts deleted file mode 100644 index 0f96669f..00000000 --- a/src/server/database.ts +++ /dev/null @@ -1,966 +0,0 @@ -import Database from "better-sqlite3"; -import { mkdirSync } from "node:fs"; -import { dirname } from "node:path"; -import { and, eq, sql } from "drizzle-orm"; -import { drizzle } from "drizzle-orm/better-sqlite3"; -import type { - AppPatch, - CategoryInput, - DockApp, - DockDeckExport, - FavoriteInput, - LayoutConfiguration, - LayoutPreset, - LayoutPresetImport, - LayoutPresetInput, - Preferences, - PreferencesPatch, - WidgetLayoutInput, -} from "../shared/contracts.js"; -import { layoutConfigurationSchema } from "../shared/contracts.js"; -import { inferAppCategoryName } from "../shared/domain.js"; -import { - apps, - categories, - favorites, - layoutPresets, - preferences, - removedApps, -} from "./schema.js"; - -export type DiscoveredApp = Omit< - DockApp, - | "visible" - | "widgetEnabled" - | "widgetSize" - | "widgetShowSummary" - | "widgetShowMetrics" - | "widgetShowInfrastructure" - | "widgetShowProgress" - | "widgetShowSparkline" - | "widgetChartMetric" - | "widgetChartType" - | "widgetChartSamples" - | "widgetShowLegend" - | "widgetMetricStyles" - | "widgetStatLimit" - | "widgetOrder" - | "widgetMetricOrder" - | "widgetHiddenMetrics" - | "widgetPrimaryMetric" - | "widgetCustomLabel" - | "widgetWarningMetric" - | "widgetWarningDirection" - | "widgetWarningThreshold" - | "widgetRefreshIntervalMs" - | "categoryId" - | "sortOrder" - | "localUrlOverride" - | "externalUrlOverride" - | "discoveredAt" - | "isNew" ->; - -interface SyncDiscoveredOptions { - removeMissing?: boolean; -} - -const migration = ` -CREATE TABLE IF NOT EXISTS categories ( - id TEXT PRIMARY KEY, name TEXT NOT NULL, icon TEXT NOT NULL DEFAULT 'Layers3', sort_order INTEGER NOT NULL DEFAULT 0 -); -CREATE TABLE IF NOT EXISTS apps ( - id TEXT PRIMARY KEY, technical_name TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, image TEXT, icon TEXT, - status TEXT NOT NULL DEFAULT 'unknown', local_url TEXT, local_url_override TEXT, external_url TEXT, - external_url_override TEXT, visible INTEGER NOT NULL DEFAULT 0, widget_enabled INTEGER NOT NULL DEFAULT 0, - widget_size TEXT NOT NULL DEFAULT 'standard', widget_show_summary INTEGER NOT NULL DEFAULT 1, - widget_show_metrics INTEGER NOT NULL DEFAULT 1, widget_show_infrastructure INTEGER NOT NULL DEFAULT 1, - widget_show_progress INTEGER NOT NULL DEFAULT 1, - widget_show_sparkline INTEGER NOT NULL DEFAULT 1, widget_order INTEGER NOT NULL DEFAULT 0, - widget_chart_metric TEXT NOT NULL DEFAULT 'auto', widget_chart_type TEXT NOT NULL DEFAULT 'line', - widget_chart_samples INTEGER NOT NULL DEFAULT 20, widget_show_legend INTEGER NOT NULL DEFAULT 1, - widget_metric_styles TEXT NOT NULL DEFAULT '{}', widget_stat_limit INTEGER NOT NULL DEFAULT 6, - widget_metric_order TEXT NOT NULL DEFAULT '[]', widget_primary_metric TEXT, widget_custom_label TEXT, - widget_hidden_metrics TEXT NOT NULL DEFAULT '[]', - widget_warning_metric TEXT NOT NULL DEFAULT 'cpu', widget_warning_direction TEXT NOT NULL DEFAULT 'above', - widget_warning_threshold INTEGER, - widget_refresh_interval_ms INTEGER NOT NULL DEFAULT 30000, - category_id TEXT, sort_order INTEGER NOT NULL DEFAULT 0, - detection_source TEXT NOT NULL, last_checked TEXT, - discovered_at TEXT, is_new INTEGER NOT NULL DEFAULT 0 -); -CREATE TABLE IF NOT EXISTS removed_apps ( - technical_name TEXT PRIMARY KEY, removed_at TEXT NOT NULL -); -CREATE TABLE IF NOT EXISTS favorites ( - id TEXT PRIMARY KEY, name TEXT NOT NULL, url TEXT NOT NULL, icon TEXT, category_id TEXT, sort_order INTEGER NOT NULL DEFAULT 0 -); -CREATE TABLE IF NOT EXISTS layout_presets ( - id TEXT PRIMARY KEY, name TEXT NOT NULL, configuration TEXT NOT NULL, - created_at TEXT NOT NULL, updated_at TEXT NOT NULL -); -CREATE TABLE IF NOT EXISTS preferences ( - id INTEGER PRIMARY KEY CHECK(id = 1), theme TEXT NOT NULL DEFAULT 'light', view_mode TEXT NOT NULL DEFAULT 'overview', - accent TEXT NOT NULL DEFAULT 'coral', density TEXT NOT NULL DEFAULT 'comfortable', - content_width TEXT NOT NULL DEFAULT 'wide', background_style TEXT NOT NULL DEFAULT 'aurora', - wallpaper_fit TEXT NOT NULL DEFAULT 'contain', wallpaper_position_x INTEGER NOT NULL DEFAULT 50, - wallpaper_position_y INTEGER NOT NULL DEFAULT 50, wallpaper_zoom INTEGER NOT NULL DEFAULT 100, - weather_enabled INTEGER NOT NULL DEFAULT 1, weather_location TEXT NOT NULL DEFAULT 'Mol', - weather_latitude INTEGER NOT NULL DEFAULT 51191, weather_longitude INTEGER NOT NULL DEFAULT 5116, - reduced_motion INTEGER NOT NULL DEFAULT 0, - show_hero INTEGER NOT NULL DEFAULT 1, show_status INTEGER NOT NULL DEFAULT 1, - show_server_widget INTEGER NOT NULL DEFAULT 1, - server_widget_size TEXT NOT NULL DEFAULT 'standard', server_widget_show_stats INTEGER NOT NULL DEFAULT 1, - server_widget_show_details INTEGER NOT NULL DEFAULT 1, server_widget_show_progress INTEGER NOT NULL DEFAULT 1, - server_widget_show_native INTEGER NOT NULL DEFAULT 1, server_widget_show_chart INTEGER NOT NULL DEFAULT 1, - server_widget_chart_metric TEXT NOT NULL DEFAULT 'Running containers', - server_widget_chart_type TEXT NOT NULL DEFAULT 'area', server_widget_chart_samples INTEGER NOT NULL DEFAULT 20, - server_widget_show_legend INTEGER NOT NULL DEFAULT 1, server_widget_hidden_metrics TEXT NOT NULL DEFAULT '[]', - server_widget_metric_order TEXT NOT NULL DEFAULT '[]', server_widget_stat_limit INTEGER NOT NULL DEFAULT 8, - server_widget_order INTEGER NOT NULL DEFAULT 0, - server_name TEXT NOT NULL DEFAULT 'Tower', server_url TEXT NOT NULL DEFAULT '', - polling_interval_ms INTEGER NOT NULL DEFAULT 30000, external_access INTEGER NOT NULL DEFAULT 0 -); -INSERT OR IGNORE INTO preferences (id) VALUES (1); -INSERT OR IGNORE INTO categories (id, name, icon, sort_order) VALUES - ('cat-media', 'Media', 'Play', 10), - ('cat-tools', 'Tools', 'Wrench', 20), - ('cat-favorites', 'Favorites', 'Star', 30); -`; - -export class DockDeckRepository { - private readonly sqlite: Database.Database; - private readonly db; - - constructor(path = process.env.DATABASE_PATH ?? "./data/dockdeck.db") { - if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true }); - this.sqlite = new Database(path); - this.sqlite.pragma("journal_mode = WAL"); - this.sqlite.pragma("foreign_keys = ON"); - this.sqlite.exec(migration); - this.ensureAppColumns(); - this.ensurePreferenceColumns(); - this.db = drizzle(this.sqlite); - } - - private ensureAppColumns() { - const existing = new Set( - ( - this.sqlite.prepare("PRAGMA table_info(apps)").all() as Array<{ - name: string; - }> - ).map((column) => column.name), - ); - if (!existing.has("widget_enabled")) { - this.sqlite.exec( - "ALTER TABLE apps ADD COLUMN widget_enabled INTEGER NOT NULL DEFAULT 0", - ); - this.sqlite.exec("UPDATE apps SET widget_enabled = visible"); - } - const additions = [ - ["widget_size", "TEXT NOT NULL DEFAULT 'standard'"], - ["widget_show_summary", "INTEGER NOT NULL DEFAULT 1"], - ["widget_show_metrics", "INTEGER NOT NULL DEFAULT 1"], - ["widget_show_infrastructure", "INTEGER NOT NULL DEFAULT 1"], - ["widget_show_progress", "INTEGER NOT NULL DEFAULT 1"], - ["widget_show_sparkline", "INTEGER NOT NULL DEFAULT 1"], - ["widget_chart_metric", "TEXT NOT NULL DEFAULT 'auto'"], - ["widget_chart_type", "TEXT NOT NULL DEFAULT 'line'"], - ["widget_chart_samples", "INTEGER NOT NULL DEFAULT 20"], - ["widget_show_legend", "INTEGER NOT NULL DEFAULT 1"], - ["widget_metric_styles", "TEXT NOT NULL DEFAULT '{}'"], - ["widget_stat_limit", "INTEGER NOT NULL DEFAULT 6"], - ["widget_order", "INTEGER NOT NULL DEFAULT 0"], - ["widget_metric_order", "TEXT NOT NULL DEFAULT '[]'"], - ["widget_hidden_metrics", "TEXT NOT NULL DEFAULT '[]'"], - ["widget_primary_metric", "TEXT"], - ["widget_custom_label", "TEXT"], - ["widget_warning_metric", "TEXT NOT NULL DEFAULT 'cpu'"], - ["widget_warning_direction", "TEXT NOT NULL DEFAULT 'above'"], - ["widget_warning_threshold", "INTEGER"], - ["widget_refresh_interval_ms", "INTEGER NOT NULL DEFAULT 30000"], - ["discovered_at", "TEXT"], - ["is_new", "INTEGER NOT NULL DEFAULT 0"], - ] as const; - for (const [name, definition] of additions) { - if (!existing.has(name)) - this.sqlite.exec(`ALTER TABLE apps ADD COLUMN ${name} ${definition}`); - } - } - - private ensurePreferenceColumns() { - const existing = new Set( - ( - this.sqlite.prepare("PRAGMA table_info(preferences)").all() as Array<{ - name: string; - }> - ).map((column) => column.name), - ); - const additions = [ - ["accent", "TEXT NOT NULL DEFAULT 'coral'"], - ["density", "TEXT NOT NULL DEFAULT 'comfortable'"], - ["content_width", "TEXT NOT NULL DEFAULT 'wide'"], - ["background_style", "TEXT NOT NULL DEFAULT 'aurora'"], - ["wallpaper_fit", "TEXT NOT NULL DEFAULT 'contain'"], - ["wallpaper_position_x", "INTEGER NOT NULL DEFAULT 50"], - ["wallpaper_position_y", "INTEGER NOT NULL DEFAULT 50"], - ["wallpaper_zoom", "INTEGER NOT NULL DEFAULT 100"], - ["weather_enabled", "INTEGER NOT NULL DEFAULT 1"], - ["weather_location", "TEXT NOT NULL DEFAULT 'Mol'"], - ["weather_latitude", "INTEGER NOT NULL DEFAULT 51191"], - ["weather_longitude", "INTEGER NOT NULL DEFAULT 5116"], - ["reduced_motion", "INTEGER NOT NULL DEFAULT 0"], - ["show_hero", "INTEGER NOT NULL DEFAULT 1"], - ["show_status", "INTEGER NOT NULL DEFAULT 1"], - ["show_server_widget", "INTEGER NOT NULL DEFAULT 1"], - ["server_widget_size", "TEXT NOT NULL DEFAULT 'standard'"], - ["server_widget_show_stats", "INTEGER NOT NULL DEFAULT 1"], - ["server_widget_show_details", "INTEGER NOT NULL DEFAULT 1"], - ["server_widget_show_progress", "INTEGER NOT NULL DEFAULT 1"], - ["server_widget_show_native", "INTEGER NOT NULL DEFAULT 1"], - ["server_widget_show_chart", "INTEGER NOT NULL DEFAULT 1"], - [ - "server_widget_chart_metric", - "TEXT NOT NULL DEFAULT 'Running containers'", - ], - ["server_widget_chart_type", "TEXT NOT NULL DEFAULT 'area'"], - ["server_widget_chart_samples", "INTEGER NOT NULL DEFAULT 20"], - ["server_widget_show_legend", "INTEGER NOT NULL DEFAULT 1"], - ["server_widget_hidden_metrics", "TEXT NOT NULL DEFAULT '[]'"], - ["server_widget_metric_order", "TEXT NOT NULL DEFAULT '[]'"], - ["server_widget_stat_limit", "INTEGER NOT NULL DEFAULT 8"], - ["server_widget_order", "INTEGER NOT NULL DEFAULT 0"], - ["server_name", "TEXT NOT NULL DEFAULT 'Tower'"], - ["server_url", "TEXT NOT NULL DEFAULT ''"], - ] as const; - for (const [name, definition] of additions) { - if (!existing.has(name)) - this.sqlite.exec( - `ALTER TABLE preferences ADD COLUMN ${name} ${definition}`, - ); - } - } - - close() { - this.sqlite.close(); - } - - getApps(): DockApp[] { - return this.db - .select() - .from(apps) - .orderBy(apps.sortOrder, apps.displayName) - .all() - .map((app) => ({ - ...app, - widgetMetricOrder: this.parseMetricOrder(app.widgetMetricOrder), - widgetHiddenMetrics: this.parseMetricOrder(app.widgetHiddenMetrics), - widgetMetricStyles: this.parseMetricStyles(app.widgetMetricStyles), - })); - } - - private parseMetricOrder(value: string): string[] { - try { - const parsed = JSON.parse(value) as unknown; - return Array.isArray(parsed) - ? parsed.filter((item): item is string => typeof item === "string") - : []; - } catch { - return []; - } - } - - private parseMetricStyles(value: string) { - try { - const parsed = JSON.parse(value) as unknown; - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) - return {}; - return Object.fromEntries( - Object.entries(parsed).filter( - (entry): entry is [string, "value" | "gauge" | "progress"] => - ["value", "gauge", "progress"].includes(String(entry[1])), - ), - ); - } catch { - return {}; - } - } - - getRemovedApps(): string[] { - return this.db - .select({ technicalName: removedApps.technicalName }) - .from(removedApps) - .all() - .map((item) => item.technicalName); - } - - getCategories() { - return this.db - .select() - .from(categories) - .orderBy(categories.sortOrder, categories.name) - .all(); - } - - getFavorites() { - return this.db - .select() - .from(favorites) - .orderBy(favorites.sortOrder, favorites.name) - .all(); - } - - getPreferences(): Preferences { - const row = this.db - .select() - .from(preferences) - .where(eq(preferences.id, 1)) - .get(); - if (!row) throw new Error("Preferences were not initialized"); - return { - theme: row.theme, - viewMode: row.viewMode, - accent: row.accent, - density: row.density, - contentWidth: row.contentWidth, - backgroundStyle: row.backgroundStyle, - wallpaperFit: row.wallpaperFit, - wallpaperPositionX: row.wallpaperPositionX, - wallpaperPositionY: row.wallpaperPositionY, - wallpaperZoom: row.wallpaperZoom, - weatherEnabled: row.weatherEnabled, - weatherLocation: row.weatherLocation, - weatherLatitude: row.weatherLatitude / 1000, - weatherLongitude: row.weatherLongitude / 1000, - reducedMotion: row.reducedMotion, - showHero: row.showHero, - showStatus: row.showStatus, - showServerWidget: row.showServerWidget, - serverWidgetSize: row.serverWidgetSize, - serverWidgetShowStats: row.serverWidgetShowStats, - serverWidgetShowDetails: row.serverWidgetShowDetails, - serverWidgetShowProgress: row.serverWidgetShowProgress, - serverWidgetShowNative: row.serverWidgetShowNative, - serverWidgetShowChart: row.serverWidgetShowChart, - serverWidgetChartMetric: row.serverWidgetChartMetric, - serverWidgetChartType: row.serverWidgetChartType, - serverWidgetChartSamples: row.serverWidgetChartSamples, - serverWidgetShowLegend: row.serverWidgetShowLegend, - serverWidgetHiddenMetrics: this.parseMetricOrder( - row.serverWidgetHiddenMetrics, - ), - serverWidgetMetricOrder: this.parseMetricOrder( - row.serverWidgetMetricOrder, - ), - serverWidgetStatLimit: row.serverWidgetStatLimit, - serverWidgetOrder: row.serverWidgetOrder, - serverName: row.serverName, - serverUrl: row.serverUrl || this.defaultServerUrl(), - pollingIntervalMs: row.pollingIntervalMs, - externalAccess: row.externalAccess, - }; - } - - private defaultServerUrl(): string { - const configured = process.env.UNRAID_URL?.trim(); - const fallback = - configured || `http://${process.env.UNRAID_HOST || "tower.local"}`; - try { - const url = new URL(fallback); - if (!url.port) url.port = "5000"; - return url.toString().replace(/\/$/, ""); - } catch { - return "http://tower.local:5000"; - } - } - - syncDiscovered( - discovered: DiscoveredApp[], - { removeMissing = true }: SyncDiscoveredOptions = {}, - ) { - const transaction = this.sqlite.transaction(() => { - const removed = new Set( - this.getRemovedApps().map((name) => name.toLocaleLowerCase()), - ); - const existingApps = this.getApps(); - const seenIds = new Set(); - for (const item of discovered) { - if (removed.has(item.technicalName.toLocaleLowerCase())) continue; - const normalizedName = item.technicalName.toLocaleLowerCase(); - const candidates = existingApps.filter( - (app) => app.technicalName.toLocaleLowerCase() === normalizedName, - ); - let current = [...candidates] - .sort((left, right) => { - const score = (app: DockApp) => - Number(app.visible) * 1_000 + - Number(app.widgetEnabled) * 500 + - Number(Boolean(app.localUrlOverride || app.externalUrlOverride)) * - 100 + - Number(Boolean(app.categoryId)) * 50 + - Number(app.technicalName === item.technicalName) * 10; - return score(right) - score(left); - }) - .at(0); - if ( - !current && - item.technicalName.toLocaleLowerCase() === "dockdeck" && - item.image?.startsWith("dockdeck:") - ) { - current = existingApps.find( - (app) => - app.image === item.image && - app.technicalName.toLocaleLowerCase().startsWith("dockdeck-"), - ); - } - if (current) { - for (const duplicate of candidates) { - if (duplicate.id !== current.id) - this.db.delete(apps).where(eq(apps.id, duplicate.id)).run(); - } - seenIds.add(current.id); - this.db - .update(apps) - .set({ - technicalName: item.technicalName, - image: item.image, - icon: - current.technicalName.toLocaleLowerCase() === "dockdeck" && - item.icon - ? item.icon - : /\[(?:IP|HOST|PORT(?::\d+)?)\]|^https?:\/\/boot\/|^\/boot\/config\/plugins\/dockerMan\/images\//i.test( - current.icon ?? "", - ) - ? item.icon - : (current.icon ?? item.icon), - status: item.status, - localUrl: item.localUrl, - externalUrl: item.externalUrl, - detectionSource: item.detectionSource, - lastChecked: item.lastChecked, - }) - .where(eq(apps.id, current.id)) - .run(); - } else { - seenIds.add(item.id); - this.db - .insert(apps) - .values({ - ...item, - visible: false, - categoryId: null, - sortOrder: 0, - discoveredAt: new Date().toISOString(), - isNew: true, - }) - .run(); - } - } - if (removeMissing) - for (const current of this.getApps()) { - if (!seenIds.has(current.id)) - this.db.delete(apps).where(eq(apps.id, current.id)).run(); - } - }); - transaction(); - } - - updateApp(id: string, patch: AppPatch) { - const { - widgetMetricOrder, - widgetHiddenMetrics, - widgetMetricStyles, - ...rest - } = patch; - this.db - .update(apps) - .set({ - ...rest, - ...(rest.visible !== undefined ? { isNew: false } : {}), - ...(widgetMetricOrder - ? { widgetMetricOrder: JSON.stringify(widgetMetricOrder) } - : {}), - ...(widgetHiddenMetrics - ? { widgetHiddenMetrics: JSON.stringify(widgetHiddenMetrics) } - : {}), - ...(widgetMetricStyles - ? { widgetMetricStyles: JSON.stringify(widgetMetricStyles) } - : {}), - }) - .where(eq(apps.id, id)) - .run(); - return this.getApps().find((app) => app.id === id); - } - - removeApp(id: string): boolean { - const current = this.db.select().from(apps).where(eq(apps.id, id)).get(); - if (!current) return false; - const transaction = this.sqlite.transaction(() => { - this.db - .insert(removedApps) - .values({ - technicalName: current.technicalName, - removedAt: new Date().toISOString(), - }) - .onConflictDoUpdate({ - target: removedApps.technicalName, - set: { removedAt: new Date().toISOString() }, - }) - .run(); - this.db.delete(apps).where(eq(apps.id, id)).run(); - }); - transaction(); - return true; - } - - createCategory(input: CategoryInput) { - const value = { id: crypto.randomUUID(), ...input }; - this.db.insert(categories).values(value).run(); - return value; - } - - updateCategory(id: string, input: Partial) { - this.db.update(categories).set(input).where(eq(categories.id, id)).run(); - return this.db.select().from(categories).where(eq(categories.id, id)).get(); - } - - deleteCategory(id: string) { - const transaction = this.sqlite.transaction(() => { - this.db - .update(apps) - .set({ categoryId: null }) - .where(eq(apps.categoryId, id)) - .run(); - this.db - .update(favorites) - .set({ categoryId: null }) - .where(eq(favorites.categoryId, id)) - .run(); - this.db.delete(categories).where(eq(categories.id, id)).run(); - }); - transaction(); - } - - organizeAppCategories() { - let updated = 0; - let unchanged = 0; - const unmatched: string[] = []; - const categoriesByName = new Map( - this.getCategories().map((category) => [ - category.name.toLocaleLowerCase(), - category, - ]), - ); - const transaction = this.sqlite.transaction(() => { - for (const app of this.getApps()) { - const suggestedName = inferAppCategoryName(app); - const suggested = suggestedName - ? categoriesByName.get(suggestedName.toLocaleLowerCase()) - : undefined; - if (!suggested) { - unmatched.push(app.displayName); - continue; - } - if (app.categoryId === suggested.id) { - unchanged += 1; - continue; - } - this.db - .update(apps) - .set({ categoryId: suggested.id }) - .where(eq(apps.id, app.id)) - .run(); - updated += 1; - } - }); - transaction(); - return { updated, unchanged, unmatched }; - } - - createFavorite(input: FavoriteInput) { - const value = { id: crypto.randomUUID(), ...input }; - this.db.insert(favorites).values(value).run(); - return value; - } - - updateFavorite(id: string, input: Partial) { - this.db.update(favorites).set(input).where(eq(favorites.id, id)).run(); - return this.db.select().from(favorites).where(eq(favorites.id, id)).get(); - } - - deleteFavorite(id: string) { - this.db.delete(favorites).where(eq(favorites.id, id)).run(); - } - - updatePreferences(patch: PreferencesPatch) { - const { - serverWidgetHiddenMetrics, - serverWidgetMetricOrder, - weatherLatitude, - weatherLongitude, - ...rest - } = patch; - this.db - .update(preferences) - .set({ - ...rest, - ...(weatherLatitude !== undefined - ? { weatherLatitude: Math.round(weatherLatitude * 1000) } - : {}), - ...(weatherLongitude !== undefined - ? { weatherLongitude: Math.round(weatherLongitude * 1000) } - : {}), - ...(serverWidgetHiddenMetrics - ? { - serverWidgetHiddenMetrics: JSON.stringify( - serverWidgetHiddenMetrics, - ), - } - : {}), - ...(serverWidgetMetricOrder - ? { serverWidgetMetricOrder: JSON.stringify(serverWidgetMetricOrder) } - : {}), - }) - .where(eq(preferences.id, 1)) - .run(); - return this.getPreferences(); - } - - updateWidgetLayout(input: WidgetLayoutInput) { - const transaction = this.sqlite.transaction(() => { - for (const item of input.items) { - if (item.kind === "server") { - this.db - .update(preferences) - .set({ serverWidgetOrder: item.order }) - .where(eq(preferences.id, 1)) - .run(); - } else { - this.db - .update(apps) - .set({ widgetOrder: item.order }) - .where(eq(apps.id, item.id)) - .run(); - } - } - }); - transaction(); - } - - private currentLayout(): LayoutConfiguration { - const preferences = this.getPreferences(); - return { - apps: this.getApps() - .filter((app) => app.widgetEnabled) - .slice(0, 8) - .map((app) => ({ - appId: app.id, - widgetEnabled: app.widgetEnabled, - widgetOrder: app.widgetOrder, - widgetSize: app.widgetSize, - widgetShowSummary: app.widgetShowSummary, - widgetShowMetrics: app.widgetShowMetrics, - widgetShowInfrastructure: app.widgetShowInfrastructure, - widgetShowProgress: app.widgetShowProgress, - widgetShowSparkline: app.widgetShowSparkline, - widgetChartMetric: app.widgetChartMetric, - widgetChartType: app.widgetChartType, - widgetChartSamples: app.widgetChartSamples, - widgetShowLegend: app.widgetShowLegend, - widgetMetricStyles: app.widgetMetricStyles, - widgetStatLimit: app.widgetStatLimit, - widgetMetricOrder: app.widgetMetricOrder, - widgetHiddenMetrics: app.widgetHiddenMetrics, - widgetPrimaryMetric: app.widgetPrimaryMetric, - widgetCustomLabel: app.widgetCustomLabel, - widgetWarningMetric: app.widgetWarningMetric, - widgetWarningDirection: app.widgetWarningDirection, - widgetWarningThreshold: app.widgetWarningThreshold, - widgetRefreshIntervalMs: app.widgetRefreshIntervalMs, - })), - server: { - enabled: preferences.showServerWidget, - order: preferences.serverWidgetOrder, - size: preferences.serverWidgetSize, - showStats: preferences.serverWidgetShowStats, - showDetails: preferences.serverWidgetShowDetails, - showProgress: preferences.serverWidgetShowProgress, - showNative: preferences.serverWidgetShowNative, - showChart: preferences.serverWidgetShowChart, - chartMetric: preferences.serverWidgetChartMetric, - chartType: preferences.serverWidgetChartType, - chartSamples: preferences.serverWidgetChartSamples, - showLegend: preferences.serverWidgetShowLegend, - hiddenMetrics: preferences.serverWidgetHiddenMetrics, - metricOrder: preferences.serverWidgetMetricOrder, - statLimit: preferences.serverWidgetStatLimit, - }, - }; - } - - getLayoutPresets(): LayoutPreset[] { - return this.db - .select() - .from(layoutPresets) - .orderBy(layoutPresets.name) - .all() - .flatMap((preset) => { - try { - const parsed = layoutConfigurationSchema.safeParse( - JSON.parse(preset.configuration) as unknown, - ); - return parsed.success - ? [{ ...preset, configuration: parsed.data }] - : []; - } catch { - return []; - } - }); - } - - createLayoutPreset(input: LayoutPresetInput): LayoutPreset { - const timestamp = new Date().toISOString(); - const preset: LayoutPreset = { - id: crypto.randomUUID(), - name: input.name, - configuration: this.currentLayout(), - createdAt: timestamp, - updatedAt: timestamp, - }; - this.db - .insert(layoutPresets) - .values({ - ...preset, - configuration: JSON.stringify(preset.configuration), - }) - .run(); - return preset; - } - - importLayoutPreset(input: LayoutPresetImport): LayoutPreset { - const timestamp = new Date().toISOString(); - const preset: LayoutPreset = { - id: crypto.randomUUID(), - name: input.name, - configuration: input.configuration, - createdAt: timestamp, - updatedAt: timestamp, - }; - this.db - .insert(layoutPresets) - .values({ ...preset, configuration: JSON.stringify(input.configuration) }) - .run(); - return preset; - } - - deleteLayoutPreset(id: string): boolean { - return ( - this.db.delete(layoutPresets).where(eq(layoutPresets.id, id)).run() - .changes > 0 - ); - } - - applyLayoutPreset(id: string): boolean { - const preset = this.getLayoutPresets().find((item) => item.id === id); - if (!preset) return false; - const transaction = this.sqlite.transaction(() => { - this.db.update(apps).set({ widgetEnabled: false }).run(); - for (const item of preset.configuration.apps) { - this.updateApp(item.appId, { - widgetEnabled: item.widgetEnabled, - widgetOrder: item.widgetOrder, - widgetSize: item.widgetSize, - widgetShowSummary: item.widgetShowSummary, - widgetShowMetrics: item.widgetShowMetrics, - widgetShowInfrastructure: item.widgetShowInfrastructure, - widgetShowProgress: item.widgetShowProgress, - widgetShowSparkline: item.widgetShowSparkline, - widgetChartMetric: item.widgetChartMetric, - widgetChartType: item.widgetChartType, - widgetChartSamples: item.widgetChartSamples, - widgetShowLegend: item.widgetShowLegend, - widgetMetricStyles: item.widgetMetricStyles, - widgetStatLimit: item.widgetStatLimit, - widgetMetricOrder: item.widgetMetricOrder, - widgetHiddenMetrics: item.widgetHiddenMetrics, - widgetPrimaryMetric: item.widgetPrimaryMetric, - widgetCustomLabel: item.widgetCustomLabel, - widgetWarningMetric: item.widgetWarningMetric, - widgetWarningDirection: item.widgetWarningDirection, - widgetWarningThreshold: item.widgetWarningThreshold, - widgetRefreshIntervalMs: item.widgetRefreshIntervalMs, - }); - } - this.updatePreferences({ - showServerWidget: preset.configuration.server.enabled, - serverWidgetOrder: preset.configuration.server.order, - serverWidgetSize: preset.configuration.server.size, - serverWidgetShowStats: preset.configuration.server.showStats, - serverWidgetShowDetails: preset.configuration.server.showDetails, - serverWidgetShowProgress: preset.configuration.server.showProgress, - serverWidgetShowNative: preset.configuration.server.showNative, - serverWidgetShowChart: preset.configuration.server.showChart, - serverWidgetChartMetric: preset.configuration.server.chartMetric, - serverWidgetChartType: preset.configuration.server.chartType, - serverWidgetChartSamples: preset.configuration.server.chartSamples, - serverWidgetShowLegend: preset.configuration.server.showLegend, - serverWidgetHiddenMetrics: preset.configuration.server.hiddenMetrics, - serverWidgetMetricOrder: preset.configuration.server.metricOrder, - serverWidgetStatLimit: preset.configuration.server.statLimit, - }); - }); - transaction(); - return true; - } - - export(): DockDeckExport { - return { - version: 1, - exportedAt: new Date().toISOString(), - categories: this.getCategories(), - apps: this.getApps().map( - ({ - id, - displayName, - visible, - widgetEnabled, - widgetSize, - widgetShowSummary, - widgetShowMetrics, - widgetShowInfrastructure, - widgetShowProgress, - widgetShowSparkline, - widgetChartMetric, - widgetChartType, - widgetChartSamples, - widgetShowLegend, - widgetMetricStyles, - widgetStatLimit, - widgetOrder, - widgetMetricOrder, - widgetHiddenMetrics, - widgetPrimaryMetric, - widgetCustomLabel, - widgetWarningMetric, - widgetWarningDirection, - widgetWarningThreshold, - widgetRefreshIntervalMs, - categoryId, - sortOrder, - icon, - localUrlOverride, - externalUrlOverride, - }) => ({ - id, - displayName, - visible, - widgetEnabled, - widgetSize, - widgetShowSummary, - widgetShowMetrics, - widgetShowInfrastructure, - widgetShowProgress, - widgetShowSparkline, - widgetChartMetric, - widgetChartType, - widgetChartSamples, - widgetShowLegend, - widgetMetricStyles, - widgetStatLimit, - widgetOrder, - widgetMetricOrder, - widgetHiddenMetrics, - widgetPrimaryMetric, - widgetCustomLabel, - widgetWarningMetric, - widgetWarningDirection, - widgetWarningThreshold, - widgetRefreshIntervalMs, - categoryId, - sortOrder, - icon, - localUrlOverride, - externalUrlOverride, - }), - ), - favorites: this.getFavorites(), - removedApps: this.getRemovedApps(), - preferences: this.getPreferences(), - layoutPresets: this.getLayoutPresets(), - }; - } - - import(payload: DockDeckExport) { - const transaction = this.sqlite.transaction(() => { - this.db.delete(favorites).run(); - this.db.delete(categories).run(); - this.db.delete(removedApps).run(); - this.db.delete(layoutPresets).run(); - for (const category of payload.categories) - this.db.insert(categories).values(category).run(); - for (const favorite of payload.favorites) - this.db.insert(favorites).values(favorite).run(); - for (const technicalName of payload.removedApps ?? []) { - this.db - .insert(removedApps) - .values({ technicalName, removedAt: new Date().toISOString() }) - .run(); - this.db.delete(apps).where(eq(apps.technicalName, technicalName)).run(); - } - for (const app of payload.apps) { - this.db - .update(apps) - .set({ - displayName: app.displayName, - visible: app.visible, - widgetEnabled: app.widgetEnabled, - widgetSize: app.widgetSize, - widgetShowSummary: app.widgetShowSummary, - widgetShowMetrics: app.widgetShowMetrics, - widgetShowInfrastructure: app.widgetShowInfrastructure, - widgetShowProgress: app.widgetShowProgress, - widgetShowSparkline: app.widgetShowSparkline, - widgetChartMetric: app.widgetChartMetric, - widgetChartType: app.widgetChartType, - widgetChartSamples: app.widgetChartSamples, - widgetShowLegend: app.widgetShowLegend, - widgetMetricStyles: app.widgetMetricStyles - ? JSON.stringify(app.widgetMetricStyles) - : undefined, - widgetStatLimit: app.widgetStatLimit, - widgetOrder: app.widgetOrder, - widgetMetricOrder: app.widgetMetricOrder - ? JSON.stringify(app.widgetMetricOrder) - : undefined, - widgetHiddenMetrics: app.widgetHiddenMetrics - ? JSON.stringify(app.widgetHiddenMetrics) - : undefined, - widgetPrimaryMetric: app.widgetPrimaryMetric, - widgetCustomLabel: app.widgetCustomLabel, - widgetWarningMetric: app.widgetWarningMetric, - widgetWarningDirection: app.widgetWarningDirection, - widgetWarningThreshold: app.widgetWarningThreshold, - widgetRefreshIntervalMs: app.widgetRefreshIntervalMs, - categoryId: app.categoryId, - sortOrder: app.sortOrder, - icon: app.icon, - localUrlOverride: app.localUrlOverride, - externalUrlOverride: app.externalUrlOverride, - }) - .where(and(eq(apps.id, app.id), sql`1 = 1`)) - .run(); - } - this.updatePreferences(payload.preferences); - for (const preset of payload.layoutPresets ?? []) { - this.db - .insert(layoutPresets) - .values({ - ...preset, - configuration: JSON.stringify(preset.configuration), - }) - .run(); - } - }); - transaction(); - } -} diff --git a/src/server/index.ts b/src/server/index.ts deleted file mode 100644 index 9745bb58..00000000 --- a/src/server/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -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); -} diff --git a/src/server/integration-environment.ts b/src/server/integration-environment.ts deleted file mode 100644 index 2be18ced..00000000 --- a/src/server/integration-environment.ts +++ /dev/null @@ -1,67 +0,0 @@ -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(); - - 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) { - 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(); - } - - 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); - } -} diff --git a/src/server/integrations/discovery.ts b/src/server/integrations/discovery.ts deleted file mode 100644 index 41a9bf33..00000000 --- a/src/server/integrations/discovery.ts +++ /dev/null @@ -1,868 +0,0 @@ -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 type { DiscoveredApp } from "../database.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; - NetworkSettings?: { - Networks?: Record; - }; -} - -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; - 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; -} - -interface TemplateInfo { - name: string; - webUi: string | null; - icon: string | null; -} - -export interface DiscoveryResult { - apps: DiscoveredApp[]; - integrations: IntegrationState[]; -} - -export interface WidgetRequest { - technicalName: string; - refreshIntervalMs: number; -} - -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}[^>]*>(?:)?`, - "i", - ), - ); - return match?.[1]?.trim() || null; - }; - return { name: pick("Name") ?? "", webUi: pick("WebUI"), icon: pick("Icon") }; -} - -function readTemplates(path: string | undefined): Map { - const templates = new Map(); - 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( - url: string, - init?: RequestInit, - timeoutMs = 4_000, -): Promise { - const response = await fetch(url, { - ...init, - signal: AbortSignal.timeout(timeoutMs), - }); - if (!response.ok) - throw new Error(`Integration returned HTTP ${response.status}`); - return (await response.json()) as T; -} - -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 { - 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>( - `${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 = {}; - private latestServerWidget: ServerWidgetMetric | null = null; - private networkSamples = new Map< - string, - { rx: number; tx: number; sampledAt: number } - >(); - private metricSamples = new Map(); - private metricHistory = new Map(); - private serverHistory: NonNullable = {}; - private appOpsMetrics = new Map(); - private appOpsMetricsUpdatedAt = 0; - private appOpsMetricsRefresh: Promise | null = null; - - constructor(private readonly unraidStatus = new UnraidStatusService()) {} - - getStates() { - return this.latestStates; - } - - getWidgetMetrics() { - return this.latestWidgetMetrics; - } - - getServerWidget() { - return this.latestServerWidget; - } - - private setServerWidget(widget: Omit) { - 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 = { - "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 = [], - ): Promise { - 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://tower.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( - `${appOpsBase}/api/containers`, - ); - containers = inventory.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 || - "tower.local", - ) ?? "http://tower.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( - `${dockerBase}/containers/json?all=1`, - ); - try { - const info = await fetchJson(`${dockerBase}/info`); - this.setServerWidget({ - url: - normalizeUrl( - process.env.UNRAID_URL?.trim() || - process.env.UNRAID_HOST || - "tower.local", - ) ?? "http://tower.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 ?? "tower.local"; - const now = checkedAt(); - const apps = containers.map((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), - detectionSource: useMocks - ? "mock" - : template - ? "docker+unraid" - : "docker", - lastChecked: now, - }; - }); - - const normalizedRequests = widgetRequests - .slice(0, 8) - .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 Promise.all( - widgetContainers.map(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( - `${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( - `${baseUrl}/api/containers?include_stats=true&stats_limit=500`, - undefined, - 15_000, - ) - .then((inventory) => { - this.appOpsMetrics = new Map( - inventory.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; - }); - } -} diff --git a/src/server/integrations/provider-widgets.ts b/src/server/integrations/provider-widgets.ts deleted file mode 100644 index f6f864b2..00000000 --- a/src/server/integrations/provider-widgets.ts +++ /dev/null @@ -1,2885 +0,0 @@ -import type { - DockApp, - IntegrationState, - ProviderKind, - ProviderWidget, - WidgetCapability, - WidgetMetricDefinition, - WidgetValueHistoryPoint, -} from "../../shared/contracts.js"; -import { inferServiceKind } from "../../shared/domain.js"; -import { IntegrationEnvironmentStore } from "../integration-environment.js"; - -interface ProviderConfig { - kind: ProviderKind; - label: string; - url?: string; - headers: Record; - requiredEnvironment: string[]; - credentialFields: NonNullable; - configured: boolean; - apiVersion?: "v1" | "v3"; - customMetrics?: boolean; -} - -interface ProviderDetails { - label: string; - description: string; - features: string[]; - credentialMode: WidgetCapability["credentialMode"]; - recommended: boolean; -} - -type JsonObject = Record; - -const now = () => new Date().toISOString(); -const cleanUrl = (value: string | undefined) => - value?.trim().replace(/\/$/, "") || undefined; -const inferredUrl = (app: DockApp) => { - const value = app.localUrlOverride ?? app.localUrl; - if (!value) return undefined; - try { - return new URL(value).origin; - } catch { - return undefined; - } -}; -const providerUrl = (app: DockApp, environmentKey: string) => - cleanUrl(process.env[environmentKey]) ?? inferredUrl(app); -const number = (value: unknown) => { - const parsed = typeof value === "string" ? Number(value) : value; - return typeof parsed === "number" && Number.isFinite(parsed) ? parsed : 0; -}; -const compact = (value: number) => - new Intl.NumberFormat("en", { - notation: "compact", - maximumFractionDigits: 1, - }).format(value); -const bytes = (value: number) => { - if (value < 1024 ** 3) return `${Math.round(value / 1024 ** 2)} MB`; - return `${(value / 1024 ** 3).toFixed(1)} GB`; -}; - -const providerDetails: Record = { - 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, - }, -}; - -function fields( - keys: string[], -): NonNullable { - return keys.map((key) => { - const isUrl = key.endsWith("_URL"); - const isSecret = - key.endsWith("_TOKEN") || - key.endsWith("_PASSWORD") || - key.endsWith("_API_KEY"); - const label = key - .replace(/^[A-Z]+_/, "") - .replaceAll("_", " ") - .toLowerCase() - .replace(/^./, (value) => value.toUpperCase()); - return { - key, - label, - type: isUrl - ? ("url" as const) - : isSecret - ? ("password" as const) - : ("text" as const), - placeholder: isUrl - ? "http://192.168.x.x:port" - : `Enter ${label.toLowerCase()}`, - required: true, - }; - }); -} - -async function fetchJson( - url: string, - headers: Record, -): Promise { - const response = await fetch(url, { - method: "GET", - headers: { accept: "application/json", ...headers }, - signal: AbortSignal.timeout(4_000), - }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - return (await response.json()) as T; -} - -async function fetchOk(url: string, headers: Record) { - const response = await fetch(url, { - method: "GET", - headers, - redirect: "follow", - signal: AbortSignal.timeout(4_000), - }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); -} - -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; -} - -function configFor(app: DockApp): ProviderConfig | null { - const kind = detectProvider(`${app.technicalName} ${app.image ?? ""}`); - if (!kind) { - const prefix = `CUSTOM_WIDGET_${app.technicalName - .replace(/[^a-z0-9]/gi, "_") - .toUpperCase() - .slice(0, 48)}`; - const urlKey = `${prefix}_URL`; - const tokenKey = `${prefix}_TOKEN`; - const url = cleanUrl(process.env[urlKey]); - const token = process.env[tokenKey]; - return { - kind: "custom", - label: app.displayName, - url, - headers: token ? { Authorization: `Bearer ${token}` } : {}, - requiredEnvironment: [urlKey], - credentialFields: [ - { - key: urlKey, - label: "GET metrics URL", - type: "url", - placeholder: "http://app:port/api/dockdeck-metrics", - required: true, - }, - { - key: tokenKey, - label: "Bearer token (optional)", - type: "password", - placeholder: "Optional read-only token", - required: false, - }, - ], - configured: Boolean(url), - customMetrics: true, - }; - } - const customPrefixes: Partial> = { - deluge: "DELUGE", - qbittorrent: "QBITTORRENT", - jdownloader: "JDOWNLOADER", - tdarr: "TDARR", - bazarr: "BAZARR", - }; - const customPrefix = customPrefixes[kind]; - if (customPrefix) { - const urlKey = `${customPrefix}_METRICS_URL`; - const tokenKey = `${customPrefix}_METRICS_TOKEN`; - const url = cleanUrl(process.env[urlKey]); - const token = process.env[tokenKey]; - return { - kind, - label: providerDetails[kind].label, - url, - headers: token ? { Authorization: `Bearer ${token}` } : {}, - requiredEnvironment: [urlKey], - credentialFields: fields([urlKey, tokenKey]), - configured: Boolean(url), - customMetrics: true, - }; - } - if (kind === "gitea") { - const url = providerUrl(app, "GITEA_WIDGET_URL"); - const token = process.env.GITEA_WIDGET_TOKEN; - return { - kind, - label: "Gitea", - url, - headers: token ? { Authorization: `token ${token}` } : {}, - requiredEnvironment: ["GITEA_WIDGET_URL", "GITEA_WIDGET_TOKEN"], - credentialFields: fields(["GITEA_WIDGET_URL", "GITEA_WIDGET_TOKEN"]), - configured: Boolean(url && token), - }; - } - if (kind === "jellyfin") { - const url = providerUrl(app, "JELLYFIN_URL"); - const token = process.env.JELLYFIN_API_KEY; - return { - kind, - label: "Jellyfin", - url, - headers: token ? { "X-Emby-Token": token } : {}, - requiredEnvironment: ["JELLYFIN_URL", "JELLYFIN_API_KEY"], - credentialFields: fields(["JELLYFIN_URL", "JELLYFIN_API_KEY"]), - configured: Boolean(url && token), - }; - } - if (kind === "seerr") { - const url = providerUrl(app, "SEERR_URL"); - const token = process.env.SEERR_API_KEY; - return { - kind, - label: "Seerr", - url, - headers: token ? { "X-Api-Key": token } : {}, - requiredEnvironment: ["SEERR_URL", "SEERR_API_KEY"], - credentialFields: fields(["SEERR_URL", "SEERR_API_KEY"]), - configured: Boolean(url && token), - }; - } - if (kind === "prowlarr") { - const url = providerUrl(app, "PROWLARR_URL"); - const token = process.env.PROWLARR_API_KEY; - return { - kind, - label: "Prowlarr", - url, - headers: token ? { "X-Api-Key": token } : {}, - requiredEnvironment: ["PROWLARR_URL", "PROWLARR_API_KEY"], - credentialFields: fields(["PROWLARR_URL", "PROWLARR_API_KEY"]), - configured: Boolean(url && token), - }; - } - if (kind === "authentik") { - const url = providerUrl(app, "AUTHENTIK_URL"); - const token = process.env.AUTHENTIK_TOKEN; - return { - kind, - label: "Authentik", - url, - headers: token ? { Authorization: `Bearer ${token}` } : {}, - requiredEnvironment: ["AUTHENTIK_URL"], - credentialFields: fields(["AUTHENTIK_URL", "AUTHENTIK_TOKEN"]).map( - (field) => ({ ...field, required: field.key === "AUTHENTIK_URL" }), - ), - configured: Boolean(url), - }; - } - if (kind === "npm") { - const url = providerUrl(app, "NPM_URL"); - const token = process.env.NPM_TOKEN; - return { - kind, - label: "Nginx Proxy Manager", - url, - headers: token ? { Authorization: `Bearer ${token}` } : {}, - requiredEnvironment: ["NPM_URL"], - credentialFields: fields(["NPM_URL", "NPM_TOKEN"]).map((field) => ({ - ...field, - required: field.key === "NPM_URL", - })), - configured: Boolean(url), - }; - } - if (kind === "vaultwarden") { - const url = providerUrl(app, "VAULTWARDEN_URL"); - return { - kind, - label: "Vaultwarden", - url, - headers: {}, - requiredEnvironment: ["VAULTWARDEN_URL"], - credentialFields: fields(["VAULTWARDEN_URL"]), - configured: Boolean(url), - }; - } - if (kind === "portainer") { - const url = providerUrl(app, "PORTAINER_URL"); - const token = process.env.PORTAINER_API_KEY; - return { - kind, - label: "Portainer", - url, - headers: token ? { "X-API-Key": token } : {}, - requiredEnvironment: ["PORTAINER_URL", "PORTAINER_API_KEY"], - credentialFields: fields(["PORTAINER_URL", "PORTAINER_API_KEY"]), - configured: Boolean(url && token), - }; - } - if (kind === "grafana") { - const url = providerUrl(app, "GRAFANA_URL"); - const token = process.env.GRAFANA_TOKEN; - return { - kind, - label: "Grafana", - url, - headers: token ? { Authorization: `Bearer ${token}` } : {}, - requiredEnvironment: ["GRAFANA_URL", "GRAFANA_TOKEN"], - credentialFields: fields(["GRAFANA_URL", "GRAFANA_TOKEN"]), - configured: Boolean(url && token), - }; - } - if (kind === "prometheus") { - const url = providerUrl(app, "PROMETHEUS_URL"); - return { - kind, - label: "Prometheus", - url, - headers: {}, - requiredEnvironment: ["PROMETHEUS_URL"], - credentialFields: fields(["PROMETHEUS_URL"]), - configured: Boolean(url), - }; - } - if (kind === "nextcloud") { - const url = providerUrl(app, "NEXTCLOUD_URL"); - const username = process.env.NEXTCLOUD_USERNAME; - const password = process.env.NEXTCLOUD_APP_PASSWORD; - return { - kind, - label: "Nextcloud", - url, - headers: - username && password - ? { - Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`, - "OCS-APIRequest": "true", - } - : {}, - requiredEnvironment: [ - "NEXTCLOUD_URL", - "NEXTCLOUD_USERNAME", - "NEXTCLOUD_APP_PASSWORD", - ], - credentialFields: fields([ - "NEXTCLOUD_URL", - "NEXTCLOUD_USERNAME", - "NEXTCLOUD_APP_PASSWORD", - ]), - configured: Boolean(url && username && password), - }; - } - if (kind === "audiobookshelf") { - const url = providerUrl(app, "AUDIOBOOKSHELF_URL"); - const token = process.env.AUDIOBOOKSHELF_TOKEN; - return { - kind, - label: "Audiobookshelf", - url, - headers: token ? { Authorization: `Bearer ${token}` } : {}, - requiredEnvironment: ["AUDIOBOOKSHELF_URL", "AUDIOBOOKSHELF_TOKEN"], - credentialFields: fields(["AUDIOBOOKSHELF_URL", "AUDIOBOOKSHELF_TOKEN"]), - configured: Boolean(url && token), - }; - } - if (kind === "netdata" || kind === "peertube") { - const key = kind === "netdata" ? "NETDATA_URL" : "PEERTUBE_URL"; - const url = providerUrl(app, key); - return { - kind, - label: providerDetails[kind].label, - url, - headers: {}, - requiredEnvironment: [key], - credentialFields: fields([key]), - configured: Boolean(url), - }; - } - if (kind === "adguard") { - const url = providerUrl(app, "ADGUARD_URL"); - const username = process.env.ADGUARD_USERNAME; - const password = process.env.ADGUARD_PASSWORD; - return { - kind, - label: "AdGuard Home", - url, - headers: - username && password - ? { - Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`, - } - : {}, - requiredEnvironment: [ - "ADGUARD_URL", - "ADGUARD_USERNAME", - "ADGUARD_PASSWORD", - ], - credentialFields: fields([ - "ADGUARD_URL", - "ADGUARD_USERNAME", - "ADGUARD_PASSWORD", - ]), - configured: Boolean(url && username && password), - }; - } - if (kind === "plex") { - const url = providerUrl(app, "PLEX_URL"); - const token = process.env.PLEX_TOKEN; - return { - kind, - label: "Plex", - url, - headers: token - ? { - "X-Plex-Token": token, - "X-Plex-Product": "DockDeck", - "X-Plex-Client-Identifier": "dockdeck-readonly-widgets", - } - : {}, - requiredEnvironment: ["PLEX_URL", "PLEX_TOKEN"], - credentialFields: fields(["PLEX_URL", "PLEX_TOKEN"]), - configured: Boolean(url && token), - }; - } - if (kind === "immich") { - const url = providerUrl(app, "IMMICH_URL"); - const key = process.env.IMMICH_API_KEY; - return { - kind, - label: "Immich", - url, - headers: key ? { "x-api-key": key } : {}, - requiredEnvironment: ["IMMICH_URL", "IMMICH_API_KEY"], - credentialFields: fields(["IMMICH_URL", "IMMICH_API_KEY"]), - configured: Boolean(url && key), - }; - } - if (kind === "homeAssistant") { - const url = providerUrl(app, "HOME_ASSISTANT_URL"); - const token = process.env.HOME_ASSISTANT_TOKEN; - return { - kind, - label: "Home Assistant", - url, - headers: token ? { Authorization: `Bearer ${token}` } : {}, - requiredEnvironment: ["HOME_ASSISTANT_URL"], - credentialFields: fields([ - "HOME_ASSISTANT_URL", - "HOME_ASSISTANT_TOKEN", - ]).map((field) => ({ - ...field, - required: field.key === "HOME_ASSISTANT_URL", - })), - configured: Boolean(url), - }; - } - if (kind === "ollama") { - const url = providerUrl(app, "OLLAMA_URL"); - return { - kind, - label: "Ollama", - url, - headers: {}, - requiredEnvironment: ["OLLAMA_URL"], - credentialFields: fields(["OLLAMA_URL"]), - configured: Boolean(url), - }; - } - if (kind === "glances") { - const url = providerUrl(app, "GLANCES_URL"); - return { - kind, - label: "Glances", - url, - headers: {}, - requiredEnvironment: ["GLANCES_URL"], - credentialFields: fields(["GLANCES_URL"]), - configured: Boolean(url), - }; - } - if (kind === "tautulli") { - const url = providerUrl(app, "TAUTULLI_URL"); - const key = process.env.TAUTULLI_API_KEY; - return { - kind, - label: "Tautulli", - url, - headers: {}, - requiredEnvironment: ["TAUTULLI_URL", "TAUTULLI_API_KEY"], - credentialFields: fields(["TAUTULLI_URL", "TAUTULLI_API_KEY"]), - configured: Boolean(url && key), - }; - } - if (kind === "paperless") { - const url = providerUrl(app, "PAPERLESS_URL"); - const token = process.env.PAPERLESS_TOKEN; - return { - kind, - label: "Paperless-ngx", - url, - headers: token ? { Authorization: `Token ${token}` } : {}, - requiredEnvironment: ["PAPERLESS_URL", "PAPERLESS_TOKEN"], - credentialFields: fields(["PAPERLESS_URL", "PAPERLESS_TOKEN"]), - configured: Boolean(url && token), - }; - } - const prefix = ["SONARR", "RADARR", "LIDARR"].find((item) => - app.technicalName.toUpperCase().includes(item), - ); - if (!prefix) return null; - const url = providerUrl(app, `${prefix}_URL`); - const key = process.env[`${prefix}_API_KEY`]; - return { - kind, - label: prefix[0] + prefix.slice(1).toLowerCase(), - url, - headers: key ? { "X-Api-Key": key } : {}, - requiredEnvironment: [`${prefix}_URL`, `${prefix}_API_KEY`], - credentialFields: fields([`${prefix}_URL`, `${prefix}_API_KEY`]), - configured: Boolean(url && key), - apiVersion: "v3", - }; -} - -async function readWidget(config: ProviderConfig): Promise { - const base = config.url as string; - if (config.customMetrics) { - const data = await fetchJson<{ - summary?: unknown; - stats?: Array<{ label?: unknown; value?: unknown; tone?: unknown }>; - }>(base, config.headers); - const stats = (data.stats ?? []).slice(0, 12).flatMap((stat) => { - if (typeof stat.label !== "string") return []; - if (!["string", "number"].includes(typeof stat.value)) return []; - const tone = ["default", "positive", "warning"].includes( - String(stat.tone), - ) - ? (stat.tone as "default" | "positive" | "warning") - : undefined; - return [ - { - label: stat.label.slice(0, 40), - value: String(stat.value).slice(0, 40), - ...(tone ? { tone } : {}), - }, - ]; - }); - if (!stats.length) throw new Error("Metrics bridge returned no stats"); - return { - kind: config.kind, - label: config.label, - summary: - typeof data.summary === "string" - ? data.summary.slice(0, 120) - : `${config.label} metrics are available`, - stats, - updatedAt: now(), - freshness: "fresh", - }; - } - if (config.kind === "audiobookshelf") { - const payload = await fetchJson<{ - libraries?: Array<{ - mediaType?: string; - stats?: { totalItems?: number }; - }>; - }>(`${base}/api/libraries`, config.headers); - const libraries = payload.libraries ?? []; - const books = libraries.filter((item) => item.mediaType === "book"); - const podcasts = libraries.filter((item) => item.mediaType === "podcast"); - const totalItems = libraries.reduce( - (total, item) => total + number(item.stats?.totalItems), - 0, - ); - return { - kind: config.kind, - label: config.label, - summary: `${libraries.length} media libraries available`, - stats: [ - { label: "Libraries", value: String(libraries.length) }, - { label: "Books", value: String(books.length) }, - { label: "Podcasts", value: String(podcasts.length) }, - { label: "Library items", value: compact(totalItems) }, - ], - updatedAt: now(), - freshness: "fresh", - }; - } - if (config.kind === "netdata") { - const [alarms, info] = await Promise.all([ - fetchJson(`${base}/api/v1/alarms`, config.headers), - fetchJson(`${base}/api/v1/info`, config.headers), - ]); - const alarmValues = Object.values( - (alarms.alarms as JsonObject | undefined) ?? {}, - ); - const critical = alarmValues.filter( - (item) => (item as JsonObject).status === "CRITICAL", - ).length; - const warning = alarmValues.filter( - (item) => (item as JsonObject).status === "WARNING", - ).length; - return { - kind: config.kind, - label: config.label, - summary: - critical || warning - ? `${critical + warning} raised health alerts` - : "All Netdata health checks are clear", - stats: [ - { - label: "Critical", - value: String(critical), - tone: critical ? "warning" : "positive", - }, - { - label: "Warnings", - value: String(warning), - tone: warning ? "warning" : "positive", - }, - { label: "Charts", value: compact(number(info.charts)) }, - { - label: "Version", - value: typeof info.version === "string" ? info.version : "—", - }, - ], - updatedAt: now(), - freshness: "fresh", - }; - } - if (config.kind === "peertube") { - const [videos, configData] = await Promise.all([ - fetchJson<{ - total?: number; - data?: Array<{ isLive?: boolean; views?: number }>; - }>(`${base}/api/v1/videos?count=25&isLocal=true`, config.headers), - fetchJson(`${base}/api/v1/config`, config.headers), - ]); - const items = videos.data ?? []; - return { - kind: config.kind, - label: config.label, - summary: `${compact(number(videos.total))} local videos published`, - stats: [ - { label: "Local videos", value: compact(number(videos.total)) }, - { - label: "Live now", - value: String(items.filter((item) => item.isLive).length), - }, - { - label: "Recent views", - value: compact( - items.reduce((sum, item) => sum + number(item.views), 0), - ), - }, - { - label: "Version", - value: - typeof configData.serverVersion === "string" - ? configData.serverVersion - : "—", - }, - ], - updatedAt: now(), - freshness: "fresh", - }; - } - if (config.kind === "gitea") { - const repositories = await fetchJson< - Array<{ stars_count?: number; open_issues_count?: number }> - >(`${base}/api/v1/user/repos?limit=50`, config.headers); - const stars = repositories.reduce( - (total, repository) => total + number(repository.stars_count), - 0, - ); - const issues = repositories.reduce( - (total, repository) => total + number(repository.open_issues_count), - 0, - ); - return { - kind: config.kind, - label: config.label, - summary: `${repositories.length} repositories in your workspace`, - stats: [ - { label: "Repositories", value: String(repositories.length) }, - { - label: "Open issues", - value: String(issues), - tone: issues ? "warning" : "positive", - }, - { label: "Stars", value: String(stars) }, - ], - updatedAt: now(), - freshness: "fresh", - }; - } - if (config.kind === "jellyfin") { - const [sessions, counts] = await Promise.all([ - fetchJson>( - `${base}/Sessions`, - config.headers, - ), - fetchJson(`${base}/Items/Counts`, config.headers), - ]); - const playing = sessions.filter((session) => session.NowPlayingItem).length; - return { - kind: config.kind, - label: config.label, - summary: playing - ? `${playing} active playback sessions` - : "No active playback", - stats: [ - { - label: "Sessions", - value: String(playing), - tone: playing ? "positive" : "default", - }, - { label: "Movies", value: compact(number(counts.MovieCount)) }, - { label: "Episodes", value: compact(number(counts.EpisodeCount)) }, - ], - updatedAt: now(), - freshness: "fresh", - }; - } - if (config.kind === "seerr") { - const [status, pending, available] = await Promise.all([ - fetchJson(`${base}/api/v1/status`, config.headers), - fetchJson( - `${base}/api/v1/request?take=1&skip=0&filter=pending`, - config.headers, - ), - fetchJson( - `${base}/api/v1/media?take=1&skip=0&filter=available`, - config.headers, - ), - ]); - const pendingCount = number( - pending.pageInfo && (pending.pageInfo as JsonObject).results, - ); - const availableCount = number( - available.pageInfo && (available.pageInfo as JsonObject).results, - ); - return { - kind: config.kind, - label: config.label, - summary: pendingCount - ? `${pendingCount} media requests awaiting review` - : "Request queue is clear", - stats: [ - { - label: "Pending", - value: String(pendingCount), - tone: pendingCount ? "warning" : "positive", - }, - { label: "Available", value: compact(availableCount) }, - { - label: "Version", - value: typeof status.version === "string" ? status.version : "—", - }, - ], - updatedAt: now(), - freshness: "fresh", - }; - } - if (config.kind === "prowlarr") { - const [indexers, health, status] = await Promise.all([ - fetchJson(`${base}/api/v1/indexer`, config.headers), - fetchJson(`${base}/api/v1/health`, config.headers), - fetchJson(`${base}/api/v1/system/status`, config.headers), - ]); - return { - kind: config.kind, - label: config.label, - summary: health.length - ? `${health.length} indexer warnings need attention` - : "All indexers report healthy", - stats: [ - { label: "Indexers", value: String(indexers.length) }, - { - label: "Warnings", - value: String(health.length), - tone: health.length ? "warning" : "positive", - }, - { - label: "Version", - value: typeof status.version === "string" ? status.version : "—", - }, - ], - updatedAt: now(), - freshness: "fresh", - }; - } - if (config.kind === "vaultwarden") { - await fetchJson(`${base}/alive`, {}); - return { - kind: config.kind, - label: config.label, - summary: "Vaultwarden is bereikbaar", - stats: [ - { label: "Status", value: "Alive", tone: "positive" }, - { label: "Endpoint", value: "Read-only" }, - ], - updatedAt: now(), - freshness: "fresh", - }; - } - if (config.kind === "authentik") { - if (!config.headers.Authorization) { - await fetchOk(`${base}/-/health/ready/`, {}); - return { - kind: config.kind, - label: config.label, - summary: "Authentik is klaar en bereikbaar", - stats: [ - { label: "Status", value: "Ready", tone: "positive" }, - { label: "API-detail", value: "Token optioneel" }, - ], - updatedAt: now(), - freshness: "fresh", - }; - } - const [applications, users, events] = await Promise.all([ - fetchJson( - `${base}/api/v3/core/applications/?page_size=1`, - config.headers, - ), - fetchJson( - `${base}/api/v3/core/users/?page_size=1`, - config.headers, - ), - fetchJson( - `${base}/api/v3/events/events/?page_size=1&ordering=-created`, - config.headers, - ), - ]); - const count = (payload: JsonObject) => - number(payload.pagination && (payload.pagination as JsonObject).count); - return { - kind: config.kind, - label: config.label, - summary: `${count(applications)} applications protected`, - stats: [ - { label: "Applications", value: compact(count(applications)) }, - { label: "Users", value: compact(count(users)) }, - { label: "Events", value: compact(count(events)) }, - ], - updatedAt: now(), - freshness: "fresh", - }; - } - if (config.kind === "npm") { - if (!config.headers.Authorization) { - await fetchOk(base, {}); - return { - kind: config.kind, - label: config.label, - summary: "Nginx Proxy Manager is bereikbaar", - stats: [ - { label: "Status", value: "Online", tone: "positive" }, - { label: "API-detail", value: "Token optioneel" }, - ], - updatedAt: now(), - freshness: "fresh", - }; - } - const [proxies, redirects, dead] = await Promise.all([ - fetchJson(`${base}/api/nginx/proxy-hosts`, config.headers), - fetchJson( - `${base}/api/nginx/redirection-hosts`, - config.headers, - ), - fetchJson(`${base}/api/nginx/dead-hosts`, config.headers), - ]); - return { - kind: config.kind, - label: config.label, - summary: `${proxies.length} proxy hosts published`, - stats: [ - { - label: "Proxy hosts", - value: String(proxies.length), - tone: "positive", - }, - { label: "Redirects", value: String(redirects.length) }, - { - label: "Dead hosts", - value: String(dead.length), - tone: dead.length ? "warning" : "default", - }, - ], - updatedAt: now(), - freshness: "fresh", - }; - } - if (config.kind === "portainer") { - const endpoints = await fetchJson>( - `${base}/api/endpoints`, - config.headers, - ); - const online = endpoints.filter( - (endpoint) => number(endpoint.Status) === 1, - ).length; - const containers = endpoints.reduce((total, endpoint) => { - const snapshots = Array.isArray(endpoint.Snapshots) - ? endpoint.Snapshots - : []; - return ( - total + - snapshots.reduce( - (sum, snapshot) => - sum + - number( - (snapshot as JsonObject).DockerSnapshotRaw && - ((snapshot as JsonObject).DockerSnapshotRaw as JsonObject) - .Containers, - ), - 0, - ) - ); - }, 0); - return { - kind: config.kind, - label: config.label, - summary: `${online} of ${endpoints.length} endpoints online`, - stats: [ - { label: "Endpoints", value: String(endpoints.length) }, - { - label: "Online", - value: String(online), - tone: online === endpoints.length ? "positive" : "warning", - }, - { label: "Containers", value: compact(containers) }, - ], - updatedAt: now(), - freshness: "fresh", - }; - } - if (config.kind === "grafana") { - const [health, dashboards, alerts] = await Promise.all([ - fetchJson(`${base}/api/health`, config.headers), - fetchJson( - `${base}/api/search?type=dash-db&limit=100`, - config.headers, - ), - fetchJson( - `${base}/api/alerts?state=alerting`, - config.headers, - ).catch(() => []), - ]); - return { - kind: config.kind, - label: config.label, - summary: `${dashboards.length} dashboards ready`, - stats: [ - { label: "Dashboards", value: String(dashboards.length) }, - { - label: "Alerting", - value: String(alerts.length), - tone: alerts.length ? "warning" : "positive", - }, - { - label: "Database", - value: - typeof health.database === "string" ? health.database : "unknown", - }, - ], - updatedAt: now(), - freshness: "fresh", - }; - } - if (config.kind === "prometheus") { - const data = await fetchJson( - `${base}/api/v1/query?query=${encodeURIComponent("up")}`, - config.headers, - ); - const result = - data.data && Array.isArray((data.data as JsonObject).result) - ? ((data.data as JsonObject).result as Array) - : []; - const up = result.filter( - (item) => Array.isArray(item.value) && String(item.value[1]) === "1", - ).length; - const down = Math.max(0, result.length - up); - return { - kind: config.kind, - label: config.label, - summary: `${up} of ${result.length} targets up`, - stats: [ - { label: "Targets up", value: String(up), tone: "positive" }, - { - label: "Targets down", - value: String(down), - tone: down ? "warning" : "default", - }, - { - label: "Health", - value: data.status === "success" ? "Healthy" : "Degraded", - }, - ], - updatedAt: now(), - freshness: "fresh", - }; - } - if (config.kind === "nextcloud") { - const [status, capabilities] = await Promise.all([ - fetchJson(`${base}/status.php`, {}), - fetchJson( - `${base}/ocs/v1.php/cloud/capabilities?format=json`, - config.headers, - ), - ]); - const version = - typeof status.versionstring === "string" ? status.versionstring : "—"; - const installed = status.installed === true; - const data = capabilities.ocs && (capabilities.ocs as JsonObject).data; - const capabilityCount = - data && typeof data === "object" - ? Object.keys(data as JsonObject).length - : 0; - return { - kind: config.kind, - label: config.label, - summary: installed - ? "Nextcloud is installed and responding" - : "Nextcloud setup incomplete", - stats: [ - { - label: "Status", - value: installed ? "Online" : "Setup", - tone: installed ? "positive" : "warning", - }, - { label: "Version", value: version }, - { label: "Capabilities", value: String(capabilityCount) }, - ], - updatedAt: now(), - freshness: "fresh", - }; - } - if (config.kind === "adguard") { - const data = await fetchJson( - `${base}/control/stats`, - config.headers, - ); - const queries = number(data.num_dns_queries); - const blocked = number(data.num_blocked_filtering); - const blockedPercent = - queries > 0 ? Math.round((blocked / queries) * 100) : 0; - return { - kind: config.kind, - label: config.label, - summary: `${blockedPercent}% of DNS traffic blocked`, - stats: [ - { label: "Queries", value: compact(queries) }, - { label: "Blocked", value: compact(blocked), tone: "positive" }, - { - label: "Avg response", - value: `${Math.round(number(data.avg_processing_time) * 1000)} ms`, - }, - ], - updatedAt: now(), - freshness: "fresh", - }; - } - if (config.kind === "plex") { - const [sessions, libraries] = await Promise.all([ - fetchJson<{ - MediaContainer?: { size?: number; Metadata?: JsonObject[] }; - }>(`${base}/status/sessions`, config.headers), - fetchJson<{ MediaContainer?: { size?: number } }>( - `${base}/library/sections`, - config.headers, - ), - ]); - const items = sessions.MediaContainer?.Metadata ?? []; - const transcodes = items.filter((item) => - Boolean(item.TranscodeSession), - ).length; - return { - kind: config.kind, - label: config.label, - summary: items.length - ? `${items.length} active stream${items.length === 1 ? "" : "s"}` - : "No active streams", - stats: [ - { - label: "Streams", - value: String(sessions.MediaContainer?.size ?? items.length), - tone: items.length ? "positive" : "default", - }, - { label: "Transcodes", value: String(transcodes) }, - { - label: "Libraries", - value: String(libraries.MediaContainer?.size ?? 0), - }, - ], - updatedAt: now(), - freshness: "fresh", - }; - } - if (config.kind === "immich") { - const apiBase = base.endsWith("/api") ? base : `${base}/api`; - const data = await fetchJson( - `${apiBase}/server/statistics`, - config.headers, - ); - const photos = number(data.photos); - const videos = number(data.videos); - return { - kind: config.kind, - label: config.label, - summary: `${compact(photos + videos)} memories protected`, - stats: [ - { label: "Photos", value: compact(photos) }, - { label: "Videos", value: compact(videos) }, - { label: "Storage", value: bytes(number(data.usage)) }, - ], - updatedAt: now(), - freshness: "fresh", - }; - } - if (config.kind === "homeAssistant") { - if (!config.headers.Authorization) { - await fetchOk(base, {}); - return { - kind: config.kind, - label: config.label, - summary: "Home Assistant is bereikbaar", - stats: [ - { label: "Status", value: "Online", tone: "positive" }, - { label: "Entiteiten", value: "Token optioneel" }, - ], - updatedAt: now(), - freshness: "fresh", - }; - } - const states = await fetchJson< - Array<{ entity_id?: string; state?: string }> - >(`${base}/api/states`, config.headers); - const unavailable = states.filter((item) => - ["unavailable", "unknown"].includes(item.state ?? ""), - ).length; - const automations = states.filter((item) => - item.entity_id?.startsWith("automation."), - ).length; - const lightsOn = states.filter( - (item) => item.entity_id?.startsWith("light.") && item.state === "on", - ).length; - return { - kind: config.kind, - label: config.label, - summary: `${states.length - unavailable} entities responding`, - stats: [ - { label: "Automations", value: String(automations) }, - { - label: "Lights on", - value: String(lightsOn), - tone: lightsOn ? "positive" : "default", - }, - { - label: "Unavailable", - value: String(unavailable), - tone: unavailable ? "warning" : "positive", - }, - ], - updatedAt: now(), - freshness: "fresh", - }; - } - if (config.kind === "ollama") { - const [tags, running] = await Promise.all([ - fetchJson<{ models?: Array<{ size?: number }> }>( - `${base}/api/tags`, - config.headers, - ), - fetchJson<{ models?: JsonObject[] }>(`${base}/api/ps`, config.headers), - ]); - const models = tags.models ?? []; - const loaded = running.models ?? []; - const storage = models.reduce( - (total, model) => total + number(model.size), - 0, - ); - return { - kind: config.kind, - label: config.label, - summary: loaded.length - ? `${loaded.length} model${loaded.length === 1 ? "" : "s"} loaded` - : "No model currently loaded", - stats: [ - { label: "Installed", value: String(models.length) }, - { - label: "Loaded", - value: String(loaded.length), - tone: loaded.length ? "positive" : "default", - }, - { label: "Model storage", value: bytes(storage) }, - ], - updatedAt: now(), - freshness: "fresh", - }; - } - if (config.kind === "glances") { - const [cpu, memory, load] = await Promise.all([ - fetchJson(`${base}/api/4/cpu`, config.headers), - fetchJson(`${base}/api/4/mem`, config.headers), - fetchJson(`${base}/api/4/load`, config.headers), - ]); - const cpuPercent = number(cpu.total); - const memoryPercent = number(memory.percent); - return { - kind: config.kind, - label: config.label, - summary: `${Math.round(memoryPercent)}% host memory in use`, - stats: [ - { label: "CPU", value: `${cpuPercent.toFixed(1)}%` }, - { label: "Memory", value: `${memoryPercent.toFixed(1)}%` }, - { label: "Load 1m", value: number(load.min1).toFixed(2) }, - ], - updatedAt: now(), - freshness: "fresh", - }; - } - if (config.kind === "tautulli") { - const key = process.env.TAUTULLI_API_KEY as string; - const result = await fetchJson<{ - response?: { data?: JsonObject; result?: string }; - }>( - `${base}/api/v2?apikey=${encodeURIComponent(key)}&cmd=get_activity`, - config.headers, - ); - const data = result.response?.data ?? {}; - if (result.response?.result !== "success") - throw new Error("Tautulli API did not return success"); - const streams = number(data.stream_count); - const transcodes = number(data.stream_count_transcode); - return { - kind: config.kind, - label: config.label, - summary: streams - ? `${streams} active Plex stream${streams === 1 ? "" : "s"}` - : "No active Plex streams", - stats: [ - { - label: "Streams", - value: String(streams), - tone: streams ? "positive" : "default", - }, - { label: "Transcodes", value: String(transcodes) }, - { - label: "Bandwidth", - value: `${compact(number(data.total_bandwidth))} Kbps`, - }, - ], - updatedAt: now(), - freshness: "fresh", - }; - } - if (config.kind === "paperless") { - const data = await fetchJson( - `${base}/api/statistics/`, - config.headers, - ); - const documents = number(data.document_count); - const inbox = number(data.inbox_count); - return { - kind: config.kind, - label: config.label, - summary: `${compact(documents)} documents organized`, - stats: [ - { label: "Documents", value: compact(documents) }, - { - label: "Inbox", - value: compact(inbox), - tone: inbox ? "warning" : "positive", - }, - { - label: "Archive size", - value: bytes(number(data.document_file_size)), - }, - ], - updatedAt: now(), - freshness: "fresh", - }; - } - const api = `${base}/api/${config.apiVersion}`; - const [queue, status] = await Promise.all([ - fetchJson(`${api}/queue/status`, config.headers), - fetchJson(`${api}/system/status`, config.headers), - ]); - const total = number(queue.totalCount); - return { - kind: config.kind, - label: config.label, - summary: total - ? `${total} item${total === 1 ? "" : "s"} in download queue` - : "Download queue is clear", - stats: [ - { - label: "Queued", - value: String(total), - tone: total ? "warning" : "positive", - }, - { label: "Warnings", value: String(number(queue.warningCount)) }, - { - label: "Version", - value: typeof status.version === "string" ? status.version : "—", - }, - ], - updatedAt: now(), - freshness: "fresh", - }; -} - -function numericStatValue(label: string, value: string): number | null { - if ( - /version|status|health|last |recent |image|release/i.test(label) || - /^v?\d+\.\d+\.\d+(?:[-+].+)?$/i.test(value.trim()) - ) - return null; - const normalized = value.trim().replace(/,/g, ""); - const match = normalized.match(/^(-?\d+(?:\.\d+)?)\s*([kmgt])?/i); - if (!match) return null; - const parsed = Number(match[1]); - if (!Number.isFinite(parsed)) return null; - const multiplier = - match[2]?.toLowerCase() === "t" - ? 1e12 - : match[2]?.toLowerCase() === "g" - ? 1e9 - : match[2]?.toLowerCase() === "m" - ? 1e6 - : match[2]?.toLowerCase() === "k" - ? 1e3 - : 1; - return parsed * multiplier; -} - -function metricDefinitions( - features: string[], - context: string, -): WidgetMetricDefinition[] { - return features.map((label, index) => { - const unit: WidgetMetricDefinition["unit"] = - /version|status|health|last |recent |array state|parity status|image/i.test( - label, - ) - ? "text" - : /storage|memory|vram|database size|disk|archive size/i.test(label) - ? "bytes" - : /speed|traffic|bandwidth|throughput|ingress|egress|rate/i.test( - label, - ) - ? "rate" - : /latency|response time|processing time|duration|eta|tick time|uptime/i.test( - label, - ) - ? "duration" - : /percent|utilization|usage|progress|temperature|load|daily change/i.test( - label, - ) - ? "percent" - : "count"; - const group: WidgetMetricDefinition["group"] = - /version|status|health|error|warning|failed|temperature|unavailable/i.test( - label, - ) - ? "health" - : /cpu|memory|traffic|speed|load|bandwidth|storage|latency|throughput|duration|time|vram|disk/i.test( - label, - ) - ? "performance" - : /library|book|video|game|document|repository|platform|portfolio|position|world|page|model/i.test( - label, - ) - ? "library" - : "activity"; - const description = /error|failed|warning|unavailable/i.test(label) - ? `Current failure or warning signal reported by ${context}; useful for alerting and trend review.` - : /queue|pending|wanted|backlog/i.test(label) - ? `Work currently waiting in ${context}, so bottlenecks are visible before opening the app.` - : /active|session|stream|player|connection|worker/i.test(label) - ? `Current concurrent activity reported by ${context}.` - : /speed|rate|traffic|bandwidth|throughput|ingress|egress/i.test( - label, - ) - ? `Current data throughput reported by ${context}; chart it to spot sustained activity.` - : /storage|memory|vram|database size|disk|archive size/i.test(label) - ? `Capacity currently consumed by ${context}; suitable for a progress presentation.` - : /latency|response time|processing time|duration|eta|tick time/i.test( - label, - ) - ? `Current timing signal from ${context}; lower values generally indicate healthier performance.` - : /version/i.test(label) - ? `The running ${context} release reported by its read-only endpoint.` - : /last |recent /i.test(label) - ? `Latest lifecycle or activity marker reported by ${context}.` - : `${label} reported by ${context}, available as a configurable widget statistic.`; - return { - label, - description, - group, - defaultVisible: index < 4, - defaultStyle: - unit === "percent" ? "gauge" : unit === "bytes" ? "progress" : "value", - chartable: unit !== "text", - unit, - }; - }); -} - -function appSpecificProfile(app: DockApp): ProviderDetails | null { - const value = - `${app.technicalName} ${app.displayName} ${app.image ?? ""}`.toLowerCase(); - const profiles: Array<[RegExp, ProviderDetails]> = [ - [ - /blockpilot-autonomous-agent-runtime|blockpilot[^ ]*agent-runtime/, - { - label: "BlockPilot agent runtime", - description: - "Autonomous agent execution, tool usage and run reliability.", - features: [ - "Active agents", - "Running tasks", - "Tool calls", - "Tokens", - "Failed runs", - "Average duration", - ], - credentialMode: "optional", - recommended: true, - }, - ], - [ - /blockpilot-autonomous-api|blockpilot[^ ]*api/, - { - label: "BlockPilot API", - description: "Agent API demand, queue pressure and response health.", - features: [ - "Requests", - "Active runs", - "Queued tasks", - "Errors", - "Response time", - "Version", - ], - credentialMode: "optional", - recommended: true, - }, - ], - [ - /blockpilot-autonomous-minecraft-sandbox|minecraft-sandbox|eclipse-temurin:25-jre-jammy/, - { - label: "BlockPilot Minecraft sandbox", - description: - "Sandbox players, worlds and Java server performance for agent experiments.", - features: [ - "Players", - "Worlds", - "Active sessions", - "Memory", - "Tick time", - "Uptime", - ], - credentialMode: "optional", - recommended: true, - }, - ], - [ - /blockpilot-autonomous-viaproxy|viaproxy/, - { - label: "BlockPilot ViaProxy", - description: - "Minecraft protocol proxy connections, routing and latency health.", - features: [ - "Connections", - "Players", - "Backend routes", - "Latency", - "Errors", - "Uptime", - ], - credentialMode: "optional", - recommended: true, - }, - ], - [ - /blockpilot-autonomous-web|blockpilot[^ ]*web/, - { - label: "BlockPilot web", - description: "Dashboard sessions, page demand and frontend health.", - features: [ - "Active sessions", - "Page requests", - "Client errors", - "Response time", - "Version", - "Uptime", - ], - credentialMode: "optional", - recommended: false, - }, - ], - [ - /(?:^|\s)blockpilot(?:\s|$)|blockpilot-autonomous:latest/, - { - label: "BlockPilot", - description: - "Autonomous orchestration, agent workload and run reliability.", - features: [ - "Active agents", - "Running tasks", - "Queued jobs", - "Tool calls", - "Failed runs", - "Last run", - ], - credentialMode: "optional", - recommended: true, - }, - ], - [ - /lumaops/, - { - label: "LumaOps", - description: - "Operations workflows, execution demand and automation health.", - features: [ - "Active workflows", - "Executions", - "Queued jobs", - "Automation rate", - "Failed runs", - "Last run", - ], - credentialMode: "optional", - recommended: true, - }, - ], - [ - /openrgb/, - { - label: "OpenRGB", - description: - "Detected lighting devices, profiles and controller health.", - features: [ - "Devices", - "Active profiles", - "LED zones", - "Brightness", - "Controller errors", - "Uptime", - ], - credentialMode: "optional", - recommended: false, - }, - ], - [ - /porkfolio/, - { - label: "Porkfolio", - description: - "Portfolio valuation, positions and market-data refresh health.", - features: [ - "Portfolios", - "Positions", - "Portfolio value", - "Daily change", - "Price updates", - "Errors", - ], - credentialMode: "optional", - recommended: true, - }, - ], - [ - /chimera/, - { - label: "Chimera", - description: "Processing workload, sources and worker reliability.", - features: [ - "Active workers", - "Queued jobs", - "Processed items", - "Sources", - "Errors", - "Last run", - ], - credentialMode: "optional", - recommended: false, - }, - ], - [ - /inspiring_carson|sha256:/, - { - label: "Legacy container", - description: - "Transparent health signals for an unnamed legacy container; rename it in DockDeck when identified.", - features: [ - "Status", - "Image", - "CPU", - "Memory", - "Restart count", - "Last seen", - ], - credentialMode: "optional", - recommended: false, - }, - ], - [ - /dockdeck/, - { - label: "DockDeck", - description: "Dashboard inventory, discovery and widget health.", - features: [ - "Discovered apps", - "Visible apps", - "Active widgets", - "Favorites", - "Integration errors", - "Version", - ], - credentialMode: "optional", - recommended: true, - }, - ], - [ - /evidencevault[^ ]*[-_ ]worker|evidencevault-worker/, - { - label: "EvidenceVault worker", - description: "Evidence processing queue and worker throughput.", - features: [ - "Queued jobs", - "Active jobs", - "Processed", - "Failed jobs", - "Processing time", - "Last completed", - ], - credentialMode: "optional", - recommended: true, - }, - ], - [ - /evidencevault[^ ]*[-_ ]frontend|evidencevault-frontend/, - { - label: "EvidenceVault frontend", - description: "Evidence portal sessions and client-side health.", - features: [ - "Active sessions", - "Page requests", - "Client errors", - "Response time", - "Deploy version", - "Uptime", - ], - credentialMode: "optional", - recommended: false, - }, - ], - [ - /evidencevault[^ ]*[-_ ]backend|evidencevault-backend/, - { - label: "EvidenceVault API", - description: "Case API activity and evidence ingestion health.", - features: [ - "Cases", - "Evidence items", - "API requests", - "Queued jobs", - "Errors", - "Version", - ], - credentialMode: "optional", - recommended: true, - }, - ], - [ - /postgres|mariadb/, - { - label: app.displayName, - description: "Database capacity, connections and query health.", - features: [ - "Connections", - "Database size", - "Transactions", - "Slow queries", - "Locks", - "Version", - ], - credentialMode: "optional", - recommended: false, - }, - ], - [ - /redis/, - { - label: app.displayName, - description: "Cache utilization, clients and hit efficiency.", - features: [ - "Keys", - "Memory used", - "Connected clients", - "Cache hit rate", - "Evictions", - "Uptime", - ], - credentialMode: "optional", - recommended: false, - }, - ], - [ - /adminer/, - { - label: "Adminer", - description: "Database administration portal availability and usage.", - features: [ - "Health", - "Active sessions", - "Requests", - "Response time", - "Errors", - "Version", - ], - credentialMode: "optional", - recommended: false, - }, - ], - [ - /audiodeck/, - { - label: "AudioDeck", - description: "Audio library, playback and download activity.", - features: [ - "Tracks", - "Playlists", - "Active streams", - "Downloads", - "Storage", - "Errors", - ], - credentialMode: "optional", - recommended: true, - }, - ], - [ - /authentik-worker/, - { - label: "Authentik worker", - description: "Identity background tasks and event processing.", - features: [ - "Queued tasks", - "Active tasks", - "Completed tasks", - "Failed tasks", - "Security events", - "Version", - ], - credentialMode: "optional", - recommended: false, - }, - ], - [ - /flaresolverr/, - { - label: "FlareSolverr", - description: "Browser challenge solver workload and reliability.", - features: [ - "Active sessions", - "Challenges solved", - "Failed challenges", - "Average duration", - "Browser status", - "Version", - ], - credentialMode: "optional", - recommended: false, - }, - ], - [ - /geointel/, - { - label: "GeoIntel", - description: "Geospatial ingestion, entities and alert activity.", - features: [ - "Sources", - "Entities", - "Locations", - "Active jobs", - "Alerts", - "Last ingest", - ], - credentialMode: "optional", - recommended: true, - }, - ], - [ - /infrabinder/, - { - label: "InfraBinder", - description: "Infrastructure inventory, drift and synchronization.", - features: [ - "Stacks", - "Services", - "Resources", - "Drift detected", - "Alerts", - "Last sync", - ], - credentialMode: "optional", - recommended: true, - }, - ], - [ - /launchpad/, - { - label: "Launchpad", - description: "Project delivery, build and deployment status.", - features: [ - "Projects", - "Active builds", - "Queued tasks", - "Last deployment", - "Failed builds", - "Version", - ], - credentialMode: "optional", - recommended: true, - }, - ], - [ - /netronome/, - { - label: "Netronome", - description: "Network measurement runs and endpoint performance.", - features: [ - "Targets", - "Checks", - "Latency", - "Packet loss", - "Failed checks", - "Last run", - ], - credentialMode: "optional", - recommended: true, - }, - ], - [ - /opscenter-it-tools/, - { - label: "OpsCenter IT Tools", - description: "Utility usage and operational service health.", - features: [ - "Tool runs", - "Active jobs", - "Completed jobs", - "Failed jobs", - "Response time", - "Version", - ], - credentialMode: "optional", - recommended: true, - }, - ], - [ - /postfix/, - { - label: "Postfix relay", - description: "Mail queue, delivery and bounce health.", - features: [ - "Queued mail", - "Deferred", - "Delivered", - "Bounced", - "Active connections", - "Last delivery", - ], - credentialMode: "optional", - recommended: false, - }, - ], - [ - /quizmonitor/, - { - label: "QuizMonitor", - description: "Live quiz participation and event processing.", - features: [ - "Active quizzes", - "Participants", - "Questions", - "Answer events", - "Errors", - "Uptime", - ], - credentialMode: "optional", - recommended: true, - }, - ], - [ - /readmeabook/, - { - label: "ReadMeABook", - description: "Book-to-audio conversion library and queue.", - features: [ - "Books", - "Chapters", - "Audio generated", - "Queued jobs", - "Failed jobs", - "Storage", - ], - credentialMode: "optional", - recommended: true, - }, - ], - [ - /spoortracker/, - { - label: "Spoortracker", - description: "Tracked journeys, positions and delay events.", - features: [ - "Tracked journeys", - "Live positions", - "Delay events", - "Active alerts", - "Last update", - "Errors", - ], - credentialMode: "optional", - recommended: true, - }, - ], - [ - /tdarr[_-]node/, - { - label: "Tdarr node", - description: "Transcode worker capacity and current throughput.", - features: [ - "Workers", - "Active transcodes", - "Queue", - "Transcode FPS", - "GPU load", - "Errors", - ], - credentialMode: "optional", - recommended: false, - }, - ], - [ - /unpackerr/, - { - label: "Unpackerr", - description: "Archive extraction queue and completion health.", - features: [ - "Queued archives", - "Extracting", - "Completed", - "Failed", - "Processing time", - "Last extraction", - ], - credentialMode: "optional", - recommended: false, - }, - ], - [ - /watchtower/, - { - label: "Watchtower", - description: "Container image scan and update reporting.", - features: [ - "Monitored containers", - "Updates available", - "Updated containers", - "Failed updates", - "Last scan", - "Version", - ], - credentialMode: "optional", - recommended: false, - }, - ], - [ - /unraid-appops-gateway/, - { - label: "Unraid AppOps gateway", - description: "Gateway traffic, routes and request health.", - features: [ - "Routes", - "Requests", - "Active requests", - "Response time", - "Errors", - "Uptime", - ], - credentialMode: "optional", - recommended: true, - }, - ], - [ - /projectbrain/, - { - label: "ProjectBrain", - description: "Knowledge items, projects and background processing.", - features: [ - "Projects", - "Knowledge items", - "Active jobs", - "Recent activity", - "Errors", - "Storage", - ], - credentialMode: "optional", - recommended: true, - }, - ], - [ - /psforge/, - { - label: "PSForge", - description: "Project build, artifact and release activity.", - features: [ - "Projects", - "Builds", - "Queued builds", - "Artifacts", - "Failed builds", - "Last release", - ], - credentialMode: "optional", - recommended: true, - }, - ], - [ - /unraid-progressive-web-app/, - { - label: "Unraid PWA", - description: "Mobile Unraid portal health and usage.", - features: [ - "Active sessions", - "Requests", - "Notifications", - "Response time", - "Errors", - "Version", - ], - credentialMode: "optional", - recommended: true, - }, - ], - [ - /rickroll/, - { - label: "Rickroll", - description: "Request and visitor activity for the redirect service.", - features: [ - "Visits", - "Unique clients", - "Requests", - "Response time", - "Errors", - "Uptime", - ], - credentialMode: "optional", - recommended: false, - }, - ], - [ - /widefrog/, - { - label: "Widefrog", - description: "Project portfolio and delivery activity.", - features: [ - "Projects", - "Deployments", - "Active work", - "Recent activity", - "Errors", - "Version", - ], - credentialMode: "optional", - recommended: true, - }, - ], - [ - /anythingllm|litellm/, - { - label: app.displayName, - description: - "Workspace, model and generation workload from a custom read endpoint.", - features: [ - "Workspaces", - "Chats", - "Models", - "Tokens", - "Errors", - "Latency", - ], - credentialMode: "optional", - recommended: true, - }, - ], - [ - /komga/, - { - label: "Komga", - description: - "Comic and ebook library depth, unread work and recent additions.", - features: [ - "Libraries", - "Series", - "Books", - "Unread", - "Recently added", - "Disk usage", - ], - credentialMode: "optional", - recommended: true, - }, - ], - [ - /romm/, - { - label: "RomM", - description: "ROM library coverage and platform activity.", - features: [ - "Games", - "Platforms", - "Saves", - "Firmware", - "Recent additions", - "Storage", - ], - credentialMode: "optional", - recommended: true, - }, - ], - [ - /metube/, - { - label: "MeTube", - description: "Download queue, active jobs and completed media.", - features: [ - "Queued", - "Active", - "Completed", - "Download speed", - "Errors", - "Storage", - ], - credentialMode: "optional", - recommended: true, - }, - ], - [ - /slskd/, - { - label: "slskd", - description: "Soulseek transfer queues and live throughput.", - features: [ - "Downloads", - "Uploads", - "Queued", - "Download speed", - "Upload speed", - "Connection", - ], - credentialMode: "optional", - recommended: true, - }, - ], - [ - /mylar/, - { - label: "Mylar3", - description: "Comic collection, wanted issues and download activity.", - features: [ - "Comics", - "Wanted issues", - "Snatched", - "Downloaded", - "Errors", - "Version", - ], - credentialMode: "optional", - recommended: true, - }, - ], - [ - /stirling/, - { - label: "Stirling PDF", - description: "PDF processing availability and workload.", - features: [ - "API status", - "Active jobs", - "Completed jobs", - "Failed jobs", - "Processing time", - "Version", - ], - credentialMode: "optional", - recommended: true, - }, - ], - [ - /gpu-hot/, - { - label: "GPU Hot", - description: "GPU thermal and utilization telemetry.", - features: [ - "GPU temperature", - "GPU utilization", - "VRAM used", - "Power draw", - "Fan speed", - "Encoder load", - ], - credentialMode: "optional", - recommended: true, - }, - ], - [ - /unraid-stats|system-monitor/, - { - label: app.displayName, - description: "Host sensors, load and storage telemetry.", - features: [ - "CPU", - "Memory", - "Temperature", - "Storage", - "Network", - "Uptime", - ], - credentialMode: "optional", - recommended: true, - }, - ], - [ - /grimmory|hivekeep/, - { - label: app.displayName, - description: "Library and collection activity for this service.", - features: [ - "Items", - "Collections", - "Recently added", - "Active users", - "Storage", - "Errors", - ], - credentialMode: "optional", - recommended: true, - }, - ], - [ - /leafwiki/, - { - label: "LeafWiki", - description: "Wiki content and editing activity.", - features: [ - "Pages", - "Edits", - "Users", - "Recent changes", - "Storage", - "Errors", - ], - credentialMode: "optional", - recommended: true, - }, - ], - [ - /listenarr/, - { - label: "Listenarr", - description: "Audio acquisition queue and library progress.", - features: [ - "Queue", - "Wanted", - "Grabbed", - "Imported", - "Warnings", - "Version", - ], - credentialMode: "optional", - recommended: true, - }, - ], - [ - /organizr/, - { - label: "Organizr", - description: "Portal availability, users and integrated services.", - features: [ - "Services", - "Users", - "Active sessions", - "Bookmarks", - "Errors", - "Version", - ], - credentialMode: "optional", - recommended: false, - }, - ], - [ - /retroarch|pokemon|ps4-http/, - { - label: app.displayName, - description: "Gaming library and runtime activity.", - features: [ - "Games", - "Platforms", - "Sessions", - "Recent activity", - "Storage", - "Health", - ], - credentialMode: "optional", - recommended: false, - }, - ], - [ - /evidencevault/, - { - label: app.displayName, - description: "Evidence ingestion and processing pipeline health.", - features: [ - "Cases", - "Evidence items", - "Queued jobs", - "Processed", - "Failed jobs", - "Storage", - ], - credentialMode: "optional", - recommended: true, - }, - ], - [ - /media-codec/, - { - label: "Media Codec Dashboard", - description: "Media analysis and codec inventory.", - features: [ - "Files scanned", - "Video codecs", - "Audio codecs", - "HDR items", - "Queued scans", - "Errors", - ], - credentialMode: "optional", - recommended: true, - }, - ], - ]; - return profiles.find(([pattern]) => pattern.test(value))?.[1] ?? null; -} - -export class ProviderWidgetService { - private widgets: Record = {}; - private states: IntegrationState[] = []; - private lastAttempts = new Map(); - private stateByName = new Map(); - private histories = new Map< - string, - Record - >(); - - constructor( - private readonly environmentStore = new IntegrationEnvironmentStore(), - ) {} - - getWidgets() { - return this.widgets; - } - - getStates() { - return this.states; - } - - getCapabilities(apps: DockApp[]): WidgetCapability[] { - return apps - .flatMap((app) => { - const provider = detectProvider( - `${app.technicalName} ${app.displayName} ${app.image ?? ""}`, - ); - if (provider) { - const details = providerDetails[provider]; - return [ - { - appId: app.id, - technicalName: app.technicalName, - label: - provider === "arr" - ? app.displayName.replace(/^binhex-/i, "") - : details.label, - description: details.description, - features: details.features, - metrics: metricDefinitions(details.features, details.label), - source: "provider", - credentialMode: details.credentialMode, - recommended: details.recommended, - }, - ]; - } - const kind = inferServiceKind( - `${app.technicalName} ${app.displayName} ${app.image ?? ""}`, - ); - const tailored: Record< - ReturnType, - Pick - > = { - dns: { - description: "Resolver traffic and resource pressure from Docker.", - features: ["DNS traffic", "Memory", "Availability"], - recommended: true, - }, - media: { - description: - "Streaming workload with traffic and transcode pressure.", - features: ["Load", "Traffic", "Memory"], - recommended: true, - }, - photos: { - description: "Library processing and upload activity from Docker.", - features: ["Index load", "Uploads", "Memory"], - recommended: true, - }, - automation: { - description: - "Queue or automation engine activity without control actions.", - features: ["Event load", "Traffic", "Memory"], - recommended: true, - }, - downloads: { - description: "Live transfer speed and download-engine workload.", - features: ["Download speed", "Upload speed", "Memory"], - recommended: true, - }, - monitoring: { - description: "A compact health panel backed by Docker telemetry.", - features: ["CPU", "Ingress", "Memory"], - recommended: false, - }, - code: { - description: "Repository-service health and live request traffic.", - features: ["CPU", "Ingress", "Memory"], - recommended: false, - }, - ai: { - description: - "Local inference load, model traffic and memory pressure.", - features: ["Inference load", "Model traffic", "Memory"], - recommended: true, - }, - documents: { - description: "Document ingestion and archive-worker pressure.", - features: ["Worker load", "Ingestion", "Memory"], - recommended: false, - }, - security: { - description: "Availability and traffic for a protected service.", - features: ["Request load", "Secure traffic", "Memory"], - recommended: false, - }, - service: { - description: - "Availability, workload and optional app-specific GET metrics.", - features: [ - "Status", - "CPU", - "Memory", - "Ingress", - "Egress", - "Processes", - "Uptime", - ], - recommended: false, - }, - }; - const profile = appSpecificProfile(app); - const details = profile ?? { - label: app.displayName, - ...tailored[kind], - credentialMode: "optional" as const, - }; - return [ - { - appId: app.id, - technicalName: app.technicalName, - label: details.label, - description: details.description, - features: details.features, - metrics: metricDefinitions(details.features, details.label), - recommended: details.recommended || app.widgetEnabled, - source: "custom-get", - credentialMode: "optional", - }, - ]; - }) - .sort( - (left, right) => - Number(right.recommended) - Number(left.recommended) || - Number(right.source === "provider") - - Number(left.source === "provider") || - left.label.localeCompare(right.label), - ); - } - - private withHistory( - technicalName: string, - widget: ProviderWidget, - ): ProviderWidget { - const history = { ...(this.histories.get(technicalName) ?? {}) }; - const stats = widget.stats.map((stat) => { - const numericValue = numericStatValue(stat.label, stat.value); - if (numericValue === null) return stat; - history[stat.label] = [ - ...(history[stat.label] ?? []), - { sampledAt: widget.updatedAt, value: numericValue }, - ].slice(-60); - return { ...stat, numericValue }; - }); - this.histories.set(technicalName, history); - return { ...widget, stats, history }; - } - - configure(app: DockApp, values: Record) { - const config = configFor(app); - if (!config) - throw new Error("This app has no configurable provider widget"); - const allowed = new Set(config.credentialFields.map((field) => field.key)); - for (const [key, value] of Object.entries(values)) { - if (!allowed.has(key)) throw new Error("Unexpected credential field"); - if (key.endsWith("_URL") && value) { - const url = new URL(value); - if (!["http:", "https:"].includes(url.protocol)) - throw new Error("Provider URL must use HTTP(S)"); - } - } - this.environmentStore.set(values); - } - - async refresh(apps: DockApp[]) { - const supported = apps - .filter((app) => app.widgetEnabled) - .map((app) => ({ app, config: configFor(app) })) - .filter((item): item is { app: DockApp; config: ProviderConfig } => - Boolean(item.config), - ); - const entries = await Promise.all( - supported.map(async ({ app, config }) => { - const missingEnvironment = config.requiredEnvironment.filter((key) => - key.endsWith("_URL") ? !config.url : !process.env[key], - ); - if (!config.configured) - return { - app, - config, - widget: null, - state: "disabled" as const, - message: `Add ${missingEnvironment.join(" + ")} for live ${config.label} data`, - requiredEnvironment: missingEnvironment, - }; - const lastAttempt = this.lastAttempts.get(app.technicalName) ?? 0; - const elapsed = Date.now() - lastAttempt; - const cachedWidget = this.widgets[app.technicalName]; - const cachedState = this.stateByName.get(app.technicalName); - if (cachedState && elapsed < app.widgetRefreshIntervalMs) { - return { - app, - config, - widget: cachedWidget - ? { - ...cachedWidget, - freshness: - elapsed > app.widgetRefreshIntervalMs * 2 - ? ("stale" as const) - : ("fresh" as const), - } - : null, - state: cachedState.state, - message: cachedState.message, - requiredEnvironment: cachedState.requiredEnvironment ?? [], - }; - } - this.lastAttempts.set(app.technicalName, Date.now()); - try { - return { - app, - config, - widget: this.withHistory( - app.technicalName, - await readWidget(config), - ), - state: "connected" as const, - message: `Live read-only ${config.label} metrics active`, - requiredEnvironment: [], - }; - } catch { - return { - app, - config, - widget: cachedWidget - ? { ...cachedWidget, freshness: "stale" as const } - : null, - state: "degraded" as const, - message: `${config.label} API is not reachable with the configured credential`, - requiredEnvironment: [], - }; - } - }), - ); - this.widgets = Object.fromEntries( - entries - .filter((entry) => entry.widget) - .map((entry) => [entry.app.technicalName, entry.widget]), - ) as Record; - this.states = entries.map((entry) => ({ - id: `widget-${entry.app.technicalName}`, - label: `${entry.config.label} widget`, - state: entry.state, - message: entry.message, - checkedAt: now(), - requiredEnvironment: entry.requiredEnvironment, - credentialFields: entry.config.credentialFields, - })); - this.stateByName = new Map( - entries.map((entry, index) => [ - entry.app.technicalName, - this.states[index] as IntegrationState, - ]), - ); - } -} diff --git a/src/server/integrations/unraid-status.ts b/src/server/integrations/unraid-status.ts deleted file mode 100644 index b5be1093..00000000 --- a/src/server/integrations/unraid-status.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { existsSync, readdirSync, readFileSync } from "node:fs"; -import { basename, join } from "node:path"; -import type { - IntegrationState, - UnraidNativeMetric, - UnraidStorageGroup, -} from "../../shared/contracts.js"; - -type IniSection = Record; - -const now = () => new Date().toISOString(); - -function unquote(value: string): string { - const trimmed = value.trim(); - return trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"') - ? trimmed.slice(1, -1) - : trimmed; -} - -export function parseIni(value: string): Map { - const sections = new Map(); - let current = "root"; - sections.set(current, {}); - for (const rawLine of value.split(/\r?\n/)) { - const line = rawLine.trim(); - if (!line || line.startsWith(";") || line.startsWith("#")) continue; - const section = line.match(/^\[([^\]]+)]$/); - if (section) { - current = section[1] ?? "root"; - if (!sections.has(current)) sections.set(current, {}); - continue; - } - const separator = line.indexOf("="); - if (separator < 1) continue; - const key = line.slice(0, separator).trim(); - sections.get(current)![key] = unquote(line.slice(separator + 1)); - } - return sections; -} - -function numeric(value: string | undefined): number { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : 0; -} - -function kib(value: string | undefined): number { - return Math.max(0, numeric(value)) * 1024; -} - -function normalizeTemperature(value: string | undefined): number | null { - const parsed = numeric(value); - if (!parsed || parsed < -30 || parsed > 150) return null; - return parsed; -} - -function poolName(name: string): string { - return name.replace(/\d+$/, "") || name; -} - -export function storageFromDisksIni(value: string): UnraidStorageGroup[] { - const groups = new Map< - string, - UnraidStorageGroup & { temperatures: number[] } - >(); - for (const [sectionName, section] of parseIni(value)) { - if (sectionName === "root") continue; - const name = section.name || sectionName; - const type = (section.type || "").toLocaleLowerCase(); - const isArray = type === "data" || /^disk\d+$/i.test(name); - const isPool = type === "cache" || /^cache\d*$/i.test(name); - if (!isArray && !isPool) continue; - const key = isArray ? "array" : `pool:${poolName(name)}`; - const existing = groups.get(key) ?? { - name: isArray ? "Array" : poolName(name), - kind: isArray ? "array" : "pool", - status: "unknown", - totalBytes: 0, - usedBytes: 0, - freeBytes: 0, - hottestTemperatureC: null, - deviceCount: 0, - temperatures: [], - }; - existing.totalBytes += kib(section.fsSize || section.size); - existing.usedBytes += kib(section.fsUsed); - existing.freeBytes += kib(section.fsFree); - existing.deviceCount += 1; - existing.status = section.status || section.state || existing.status; - const temperature = normalizeTemperature(section.temp); - if (temperature !== null) existing.temperatures.push(temperature); - groups.set(key, existing); - } - return [...groups.values()].map(({ temperatures, ...group }) => ({ - ...group, - hottestTemperatureC: temperatures.length ? Math.max(...temperatures) : null, - })); -} - -function readHwmonTemperatures(path: string): number[] { - if (!existsSync(path)) return []; - const values: number[] = []; - for (const directory of readdirSync(path, { withFileTypes: true }).filter( - (entry) => entry.isDirectory() || entry.isSymbolicLink(), - )) { - const target = join(path, directory.name); - let files: string[]; - try { - files = readdirSync(target).filter((name) => - /^temp\d+_input$/.test(name), - ); - } catch { - // A class-only sysfs mount can contain symlinks whose device target is - // intentionally outside the container. Disk temperatures still remain - // available through Unraid's disks.ini, so one broken link is non-fatal. - continue; - } - for (const file of files) { - try { - const parsed = numeric(readFileSync(join(target, file), "utf8")) / 1000; - if (parsed > -30 && parsed < 150) values.push(parsed); - } catch { - // Sensors can disappear between directory enumeration and reading. - } - } - } - return values; -} - -function readUps( - path: string | undefined, -): Pick< - UnraidNativeMetric, - "upsStatus" | "upsLoadPercent" | "upsBatteryPercent" -> { - if (!path || !existsSync(path)) - return { - upsStatus: null, - upsLoadPercent: null, - upsBatteryPercent: null, - }; - const values = new Map(); - for (const line of readFileSync(path, "utf8").split(/\r?\n/)) { - const match = line.match(/^\s*([A-Z.]+)\s*[:=]\s*(.*?)\s*$/i); - if (match?.[1] && match[2]) values.set(match[1].toUpperCase(), match[2]); - } - const percent = (key: string) => { - const match = values.get(key)?.match(/[\d.]+/); - return match ? numeric(match[0]) : null; - }; - return { - upsStatus: values.get("STATUS") ?? null, - upsLoadPercent: percent("LOADPCT"), - upsBatteryPercent: percent("BCHARGE"), - }; -} - -export class UnraidStatusService { - private metric: UnraidNativeMetric | null = null; - private state: IntegrationState = { - id: "unraid-native", - label: "Unraid runtime", - state: "disabled", - message: "Nog niet gecontroleerd", - checkedAt: now(), - }; - - getMetric() { - return this.metric; - } - - getState() { - return this.state; - } - - refresh() { - const runtimePath = process.env.UNRAID_RUNTIME_PATH ?? "/unraid-runtime"; - const varPath = join(runtimePath, "var.ini"); - const disksPath = join(runtimePath, "disks.ini"); - if (!existsSync(varPath) || !existsSync(disksPath)) { - this.metric = null; - this.state = { - id: "unraid-native", - label: "Unraid runtime", - state: "degraded", - message: "Read-only Unraid runtimebestanden zijn niet beschikbaar", - checkedAt: now(), - }; - return null; - } - try { - const root = parseIni(readFileSync(varPath, "utf8")).get("root") ?? {}; - const storage = storageFromDisksIni(readFileSync(disksPath, "utf8")); - const diskTemperatures = storage.flatMap((group) => - group.hottestTemperatureC === null ? [] : [group.hottestTemperatureC], - ); - const hwmonTemperatures = readHwmonTemperatures( - process.env.UNRAID_HWMON_PATH ?? "/host-hwmon", - ); - const resyncSize = numeric(root.mdResyncSize); - const resyncPosition = numeric(root.mdResyncPos); - const lastParity = numeric(root.sbSynced); - const ups = readUps( - process.env.UNRAID_UPS_STATUS_PATH ?? - join(runtimePath, basename("ups.status")), - ); - this.metric = { - arrayState: root.mdState || root.sbState || "unknown", - parityStatus: - numeric(root.mdResync) > 0 - ? root.mdResyncAction || "running" - : numeric(root.sbSyncErrs) > 0 - ? "errors" - : "healthy", - parityErrors: numeric(root.sbSyncErrs), - lastParityCheck: lastParity - ? new Date(lastParity * 1000).toISOString() - : null, - resyncPercent: - resyncSize > 0 - ? Math.min(100, (resyncPosition / resyncSize) * 100) - : null, - hottestTemperatureC: [...diskTemperatures, ...hwmonTemperatures].length - ? Math.max(...diskTemperatures, ...hwmonTemperatures) - : null, - storage, - ...ups, - updatedAt: now(), - }; - this.state = { - id: "unraid-native", - label: "Unraid runtime", - state: "connected", - message: `${storage.length} array-/poolgroepen read-only gelezen`, - checkedAt: now(), - }; - return this.metric; - } catch { - this.metric = null; - this.state = { - id: "unraid-native", - label: "Unraid runtime", - state: "degraded", - message: "Unraid runtimegegevens konden niet veilig worden verwerkt", - checkedAt: now(), - }; - return null; - } - } -} diff --git a/src/server/schema.ts b/src/server/schema.ts deleted file mode 100644 index d167e3c5..00000000 --- a/src/server/schema.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; - -export const apps = sqliteTable("apps", { - id: text("id").primaryKey(), - technicalName: text("technical_name").notNull().unique(), - displayName: text("display_name").notNull(), - image: text("image"), - icon: text("icon"), - status: text("status", { enum: ["online", "offline", "unknown"] }) - .notNull() - .default("unknown"), - localUrl: text("local_url"), - localUrlOverride: text("local_url_override"), - externalUrl: text("external_url"), - externalUrlOverride: text("external_url_override"), - visible: integer("visible", { mode: "boolean" }).notNull().default(false), - widgetEnabled: integer("widget_enabled", { mode: "boolean" }) - .notNull() - .default(false), - widgetSize: text("widget_size", { - enum: ["compact", "standard", "wide"], - }) - .notNull() - .default("standard"), - widgetShowSummary: integer("widget_show_summary", { mode: "boolean" }) - .notNull() - .default(true), - widgetShowMetrics: integer("widget_show_metrics", { mode: "boolean" }) - .notNull() - .default(true), - widgetShowInfrastructure: integer("widget_show_infrastructure", { - mode: "boolean", - }) - .notNull() - .default(true), - widgetShowProgress: integer("widget_show_progress", { mode: "boolean" }) - .notNull() - .default(true), - widgetShowSparkline: integer("widget_show_sparkline", { mode: "boolean" }) - .notNull() - .default(true), - widgetChartMetric: text("widget_chart_metric").notNull().default("auto"), - widgetChartType: text("widget_chart_type", { - enum: ["line", "area", "bar"], - }) - .notNull() - .default("line"), - widgetChartSamples: integer("widget_chart_samples").notNull().default(20), - widgetShowLegend: integer("widget_show_legend", { mode: "boolean" }) - .notNull() - .default(true), - widgetMetricStyles: text("widget_metric_styles").notNull().default("{}"), - widgetStatLimit: integer("widget_stat_limit").notNull().default(6), - widgetOrder: integer("widget_order").notNull().default(0), - widgetMetricOrder: text("widget_metric_order").notNull().default("[]"), - widgetHiddenMetrics: text("widget_hidden_metrics").notNull().default("[]"), - widgetPrimaryMetric: text("widget_primary_metric"), - widgetCustomLabel: text("widget_custom_label"), - widgetWarningMetric: text("widget_warning_metric").notNull().default("cpu"), - widgetWarningDirection: text("widget_warning_direction", { - enum: ["above", "below"], - }) - .notNull() - .default("above"), - widgetWarningThreshold: integer("widget_warning_threshold"), - widgetRefreshIntervalMs: integer("widget_refresh_interval_ms") - .notNull() - .default(30000), - categoryId: text("category_id"), - sortOrder: integer("sort_order").notNull().default(0), - detectionSource: text("detection_source").notNull(), - lastChecked: text("last_checked"), - discoveredAt: text("discovered_at"), - isNew: integer("is_new", { mode: "boolean" }).notNull().default(false), -}); - -export const removedApps = sqliteTable("removed_apps", { - technicalName: text("technical_name").primaryKey(), - removedAt: text("removed_at").notNull(), -}); - -export const categories = sqliteTable("categories", { - id: text("id").primaryKey(), - name: text("name").notNull(), - icon: text("icon").notNull().default("Layers3"), - sortOrder: integer("sort_order").notNull().default(0), -}); - -export const favorites = sqliteTable("favorites", { - id: text("id").primaryKey(), - name: text("name").notNull(), - url: text("url").notNull(), - icon: text("icon"), - categoryId: text("category_id"), - sortOrder: integer("sort_order").notNull().default(0), -}); - -export const layoutPresets = sqliteTable("layout_presets", { - id: text("id").primaryKey(), - name: text("name").notNull(), - configuration: text("configuration").notNull(), - createdAt: text("created_at").notNull(), - updatedAt: text("updated_at").notNull(), -}); - -export const preferences = sqliteTable("preferences", { - id: integer("id").primaryKey(), - theme: text("theme", { enum: ["light", "dark", "harbor"] }) - .notNull() - .default("light"), - viewMode: text("view_mode", { enum: ["overview", "categories"] }) - .notNull() - .default("overview"), - accent: text("accent", { enum: ["coral", "ocean", "violet", "lime"] }) - .notNull() - .default("coral"), - density: text("density", { - enum: ["compact", "comfortable", "spacious"], - }) - .notNull() - .default("comfortable"), - contentWidth: text("content_width", { - enum: ["focused", "wide", "fluid"], - }) - .notNull() - .default("wide"), - backgroundStyle: text("background_style", { - enum: ["aurora", "grid", "minimal"], - }) - .notNull() - .default("aurora"), - wallpaperFit: text("wallpaper_fit", { enum: ["contain", "cover"] }) - .notNull() - .default("contain"), - wallpaperPositionX: integer("wallpaper_position_x").notNull().default(50), - wallpaperPositionY: integer("wallpaper_position_y").notNull().default(50), - wallpaperZoom: integer("wallpaper_zoom").notNull().default(100), - weatherEnabled: integer("weather_enabled", { mode: "boolean" }) - .notNull() - .default(true), - weatherLocation: text("weather_location").notNull().default("Mol"), - weatherLatitude: integer("weather_latitude").notNull().default(51191), - weatherLongitude: integer("weather_longitude").notNull().default(5116), - reducedMotion: integer("reduced_motion", { mode: "boolean" }) - .notNull() - .default(false), - showHero: integer("show_hero", { mode: "boolean" }).notNull().default(true), - showStatus: integer("show_status", { mode: "boolean" }) - .notNull() - .default(true), - showServerWidget: integer("show_server_widget", { mode: "boolean" }) - .notNull() - .default(true), - serverWidgetSize: text("server_widget_size", { - enum: ["compact", "standard", "wide"], - }) - .notNull() - .default("standard"), - serverWidgetShowStats: integer("server_widget_show_stats", { - mode: "boolean", - }) - .notNull() - .default(true), - serverWidgetShowDetails: integer("server_widget_show_details", { - mode: "boolean", - }) - .notNull() - .default(true), - serverWidgetShowProgress: integer("server_widget_show_progress", { - mode: "boolean", - }) - .notNull() - .default(true), - serverWidgetShowNative: integer("server_widget_show_native", { - mode: "boolean", - }) - .notNull() - .default(true), - serverWidgetShowChart: integer("server_widget_show_chart", { - mode: "boolean", - }) - .notNull() - .default(true), - serverWidgetChartMetric: text("server_widget_chart_metric") - .notNull() - .default("Running containers"), - serverWidgetChartType: text("server_widget_chart_type", { - enum: ["line", "area", "bar"], - }) - .notNull() - .default("area"), - serverWidgetChartSamples: integer("server_widget_chart_samples") - .notNull() - .default(20), - serverWidgetShowLegend: integer("server_widget_show_legend", { - mode: "boolean", - }) - .notNull() - .default(true), - serverWidgetHiddenMetrics: text("server_widget_hidden_metrics") - .notNull() - .default("[]"), - serverWidgetMetricOrder: text("server_widget_metric_order") - .notNull() - .default("[]"), - serverWidgetStatLimit: integer("server_widget_stat_limit") - .notNull() - .default(8), - serverWidgetOrder: integer("server_widget_order").notNull().default(0), - serverName: text("server_name").notNull().default("Tower"), - serverUrl: text("server_url").notNull().default(""), - pollingIntervalMs: integer("polling_interval_ms").notNull().default(30000), - externalAccess: integer("external_access", { mode: "boolean" }) - .notNull() - .default(false), -}); diff --git a/src/server/wallpaper.ts b/src/server/wallpaper.ts deleted file mode 100644 index e8f10862..00000000 --- a/src/server/wallpaper.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { - mkdirSync, - readFileSync, - statSync, - unlinkSync, - writeFileSync, -} from "node:fs"; -import { join } from "node:path"; -import type { WallpaperDescriptor } from "../shared/contracts.js"; - -const variants = [ - { extension: "png", contentType: "image/png" }, - { extension: "jpg", contentType: "image/jpeg" }, - { extension: "webp", contentType: "image/webp" }, -] as const; - -export class WallpaperStore { - constructor(private readonly directory: string) {} - - private existing() { - for (const variant of variants) { - const path = join(this.directory, `wallpaper.${variant.extension}`); - try { - const stat = statSync(path); - return { ...variant, path, stat }; - } catch { - // No file for this supported format. - } - } - return null; - } - - describe(): WallpaperDescriptor | null { - const file = this.existing(); - if (!file) return null; - return { - url: `/api/wallpaper?v=${Math.floor(file.stat.mtimeMs)}`, - updatedAt: file.stat.mtime.toISOString(), - }; - } - - read() { - const file = this.existing(); - return file - ? { body: readFileSync(file.path), contentType: file.contentType } - : null; - } - - save(body: Buffer): WallpaperDescriptor { - if (body.length === 0 || body.length > 8 * 1024 * 1024) - throw new Error("Wallpaper moet tussen 1 byte en 8 MB groot zijn"); - const format = this.detect(body); - if (!format) - throw new Error("Gebruik een geldige PNG-, JPEG- of WebP-afbeelding"); - mkdirSync(this.directory, { recursive: true }); - this.remove(); - writeFileSync(join(this.directory, `wallpaper.${format.extension}`), body, { - mode: 0o600, - }); - return this.describe()!; - } - - remove() { - for (const variant of variants) { - try { - unlinkSync(join(this.directory, `wallpaper.${variant.extension}`)); - } catch { - // Removing a missing variant is intentionally idempotent. - } - } - } - - private detect(body: Buffer) { - if ( - body.length >= 8 && - body.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) - ) - return variants[0]; - if ( - body.length >= 3 && - body[0] === 0xff && - body[1] === 0xd8 && - body[2] === 0xff - ) - return variants[1]; - if ( - body.length >= 12 && - body.toString("ascii", 0, 4) === "RIFF" && - body.toString("ascii", 8, 12) === "WEBP" - ) - return variants[2]; - return null; - } -} diff --git a/src/server/weather.ts b/src/server/weather.ts deleted file mode 100644 index 836b40f0..00000000 --- a/src/server/weather.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { WeatherMetric } from "../shared/contracts.js"; - -interface OpenMeteoResponse { - current?: { - temperature_2m?: number; - weather_code?: number; - is_day?: number; - time?: string; - }; -} - -const weatherSummary = (code: number) => { - if (code === 0) return "Helder"; - if (code <= 3) return "Bewolkt"; - if (code === 45 || code === 48) return "Mist"; - if (code <= 67 || (code >= 80 && code <= 82)) return "Regen"; - if (code <= 77 || (code >= 85 && code <= 86)) return "Sneeuw"; - if (code >= 95) return "Onweer"; - return "Wisselvallig"; -}; - -export class WeatherService { - private cache = new Map< - string, - { expiresAt: number; value: WeatherMetric } - >(); - - constructor(private readonly fetcher: typeof fetch = fetch) {} - - async current( - location: string, - latitude: number, - longitude: number, - ): Promise { - const key = `${location}:${latitude}:${longitude}`; - const cached = this.cache.get(key); - if (cached && cached.expiresAt > Date.now()) return cached.value; - const params = new URLSearchParams({ - latitude: String(latitude), - longitude: String(longitude), - current: "temperature_2m,weather_code,is_day", - timezone: "Europe/Brussels", - forecast_days: "1", - }); - const response = await this.fetcher( - `https://api.open-meteo.com/v1/forecast?${params}`, - { signal: AbortSignal.timeout(5_000) }, - ); - if (!response.ok) throw new Error(`Weather HTTP ${response.status}`); - const payload = (await response.json()) as OpenMeteoResponse; - const temperature = payload.current?.temperature_2m; - const code = payload.current?.weather_code; - if (!Number.isFinite(temperature) || !Number.isFinite(code)) - throw new Error("Weather response was incomplete"); - const value: WeatherMetric = { - location, - temperatureC: Number(temperature), - weatherCode: Number(code), - isDay: payload.current?.is_day !== 0, - summary: weatherSummary(Number(code)), - updatedAt: payload.current?.time ?? new Date().toISOString(), - }; - this.cache.set(key, { value, expiresAt: Date.now() + 10 * 60_000 }); - return value; - } -} diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts deleted file mode 100644 index 89fa1de4..00000000 --- a/src/shared/contracts.ts +++ /dev/null @@ -1,626 +0,0 @@ -import { z } from "zod"; - -export const themeSchema = z.enum(["light", "dark", "harbor"]); -export const viewModeSchema = z.enum(["overview", "categories"]); -export const accentSchema = z.enum(["coral", "ocean", "violet", "lime"]); -export const densitySchema = z.enum(["compact", "comfortable", "spacious"]); -export const contentWidthSchema = z.enum(["focused", "wide", "fluid"]); -export const backgroundStyleSchema = z.enum(["aurora", "grid", "minimal"]); -export const wallpaperFitSchema = z.enum(["contain", "cover"]); -export const statusSchema = z.enum(["online", "offline", "unknown"]); -export const widgetSizeSchema = z.enum(["compact", "standard", "wide"]); -export const widgetFreshnessSchema = z.enum(["fresh", "stale", "unavailable"]); -export const widgetChartTypeSchema = z.enum(["line", "area", "bar"]); -export const widgetMetricStyleSchema = z.enum(["value", "gauge", "progress"]); -export const widgetWarningDirectionSchema = z.enum(["above", "below"]); -export const widgetThresholdMetricSchema = z.string().trim().min(1).max(64); -export const widgetMetricOrderSchema = z - .array(z.string().trim().min(1).max(64)) - .max(24); -export const widgetHiddenMetricsSchema = z - .array(z.string().trim().min(1).max(64)) - .max(24); -export const widgetMetricStylesSchema = z - .record(z.string().trim().min(1).max(64), widgetMetricStyleSchema) - .refine((value) => Object.keys(value).length <= 24, { - message: "At most 24 metric presentation styles are allowed", - }); -const webUrlSchema = z - .string() - .url() - .refine( - (value) => ["http:", "https:"].includes(new URL(value).protocol), - "Only HTTP(S) URLs are allowed", - ); - -export const appPatchSchema = z - .object({ - displayName: z.string().trim().min(1).max(80).optional(), - icon: z.string().trim().max(500).nullable().optional(), - visible: z.boolean().optional(), - widgetEnabled: z.boolean().optional(), - widgetSize: widgetSizeSchema.optional(), - widgetShowSummary: z.boolean().optional(), - widgetShowMetrics: z.boolean().optional(), - widgetShowInfrastructure: z.boolean().optional(), - widgetShowProgress: z.boolean().optional(), - widgetShowSparkline: z.boolean().optional(), - widgetChartMetric: z.string().trim().min(1).max(64).optional(), - widgetChartType: widgetChartTypeSchema.optional(), - widgetChartSamples: z.number().int().min(5).max(60).optional(), - widgetShowLegend: z.boolean().optional(), - widgetMetricStyles: widgetMetricStylesSchema.optional(), - widgetStatLimit: z.number().int().min(1).max(12).optional(), - widgetOrder: z.number().int().min(0).max(10_000).optional(), - widgetMetricOrder: widgetMetricOrderSchema.optional(), - widgetHiddenMetrics: widgetHiddenMetricsSchema.optional(), - widgetPrimaryMetric: z.string().trim().min(1).max(64).nullable().optional(), - widgetCustomLabel: z.string().trim().max(64).nullable().optional(), - widgetWarningMetric: widgetThresholdMetricSchema.optional(), - widgetWarningDirection: widgetWarningDirectionSchema.optional(), - widgetWarningThreshold: z - .number() - .min(0) - .max(1_000_000_000_000) - .nullable() - .optional(), - widgetRefreshIntervalMs: z - .number() - .int() - .min(10_000) - .max(300_000) - .optional(), - categoryId: z.string().nullable().optional(), - sortOrder: z.number().int().min(0).max(10_000).optional(), - localUrlOverride: webUrlSchema.nullable().optional(), - externalUrlOverride: webUrlSchema.nullable().optional(), - }) - .strict(); - -export const categoryInputSchema = z - .object({ - name: z.string().trim().min(1).max(48), - icon: z.string().trim().max(64).default("Layers3"), - sortOrder: z.number().int().min(0).max(10_000).default(0), - }) - .strict(); - -export const favoriteInputSchema = z - .object({ - name: z.string().trim().min(1).max(80), - url: webUrlSchema, - icon: z.string().trim().max(500).nullable().default(null), - categoryId: z.string().nullable().default(null), - sortOrder: z.number().int().min(0).max(10_000).default(0), - }) - .strict(); - -export const preferencesPatchSchema = z - .object({ - theme: themeSchema.optional(), - viewMode: viewModeSchema.optional(), - accent: accentSchema.optional(), - density: densitySchema.optional(), - contentWidth: contentWidthSchema.optional(), - backgroundStyle: backgroundStyleSchema.optional(), - wallpaperFit: wallpaperFitSchema.optional(), - wallpaperPositionX: z.number().int().min(0).max(100).optional(), - wallpaperPositionY: z.number().int().min(0).max(100).optional(), - wallpaperZoom: z.number().int().min(80).max(160).optional(), - weatherEnabled: z.boolean().optional(), - weatherLocation: z.string().trim().min(1).max(80).optional(), - weatherLatitude: z.number().min(-90).max(90).optional(), - weatherLongitude: z.number().min(-180).max(180).optional(), - reducedMotion: z.boolean().optional(), - showHero: z.boolean().optional(), - showStatus: z.boolean().optional(), - showServerWidget: z.boolean().optional(), - serverWidgetSize: widgetSizeSchema.optional(), - serverWidgetShowStats: z.boolean().optional(), - serverWidgetShowDetails: z.boolean().optional(), - serverWidgetShowProgress: z.boolean().optional(), - serverWidgetShowNative: z.boolean().optional(), - serverWidgetShowChart: z.boolean().optional(), - serverWidgetChartMetric: z.string().trim().min(1).max(64).optional(), - serverWidgetChartType: widgetChartTypeSchema.optional(), - serverWidgetChartSamples: z.number().int().min(5).max(60).optional(), - serverWidgetShowLegend: z.boolean().optional(), - serverWidgetHiddenMetrics: widgetHiddenMetricsSchema.optional(), - serverWidgetMetricOrder: widgetMetricOrderSchema.optional(), - serverWidgetStatLimit: z.number().int().min(1).max(12).optional(), - serverWidgetOrder: z.number().int().min(0).max(10_000).optional(), - serverName: z.string().trim().min(1).max(64).optional(), - serverUrl: webUrlSchema.optional(), - pollingIntervalMs: z.number().int().min(10_000).max(300_000).optional(), - externalAccess: z.boolean().optional(), - }) - .strict(); - -const backupPreferencesSchema = z.object({ - theme: themeSchema, - viewMode: viewModeSchema, - pollingIntervalMs: z.number().int().min(10_000).max(300_000), - externalAccess: z.boolean(), - accent: accentSchema.optional(), - density: densitySchema.optional(), - contentWidth: contentWidthSchema.optional(), - backgroundStyle: backgroundStyleSchema.optional(), - wallpaperFit: wallpaperFitSchema.optional(), - wallpaperPositionX: z.number().int().min(0).max(100).optional(), - wallpaperPositionY: z.number().int().min(0).max(100).optional(), - wallpaperZoom: z.number().int().min(80).max(160).optional(), - weatherEnabled: z.boolean().optional(), - weatherLocation: z.string().trim().min(1).max(80).optional(), - weatherLatitude: z.number().min(-90).max(90).optional(), - weatherLongitude: z.number().min(-180).max(180).optional(), - reducedMotion: z.boolean().optional(), - showHero: z.boolean().optional(), - showStatus: z.boolean().optional(), - showServerWidget: z.boolean().optional(), - serverWidgetSize: widgetSizeSchema.optional(), - serverWidgetShowStats: z.boolean().optional(), - serverWidgetShowDetails: z.boolean().optional(), - serverWidgetShowProgress: z.boolean().optional(), - serverWidgetShowNative: z.boolean().optional(), - serverWidgetShowChart: z.boolean().optional(), - serverWidgetChartMetric: z.string().trim().min(1).max(64).optional(), - serverWidgetChartType: widgetChartTypeSchema.optional(), - serverWidgetChartSamples: z.number().int().min(5).max(60).optional(), - serverWidgetShowLegend: z.boolean().optional(), - serverWidgetHiddenMetrics: widgetHiddenMetricsSchema.optional(), - serverWidgetMetricOrder: widgetMetricOrderSchema.optional(), - serverWidgetStatLimit: z.number().int().min(1).max(12).optional(), - serverWidgetOrder: z.number().int().min(0).max(10_000).optional(), - serverName: z.string().trim().min(1).max(64).optional(), - serverUrl: webUrlSchema.optional(), -}); - -const layoutAppSchema = z.object({ - appId: z.string(), - widgetEnabled: z.boolean(), - widgetOrder: z.number().int().min(0).max(10_000), - widgetSize: widgetSizeSchema, - widgetShowSummary: z.boolean(), - widgetShowMetrics: z.boolean(), - widgetShowInfrastructure: z.boolean(), - widgetShowProgress: z.boolean(), - widgetShowSparkline: z.boolean(), - widgetChartMetric: z.string().trim().min(1).max(64).default("auto"), - widgetChartType: widgetChartTypeSchema.default("line"), - widgetChartSamples: z.number().int().min(5).max(60).default(20), - widgetShowLegend: z.boolean().default(true), - widgetMetricStyles: widgetMetricStylesSchema.default({}), - widgetStatLimit: z.number().int().min(1).max(12).default(6), - widgetMetricOrder: widgetMetricOrderSchema, - widgetHiddenMetrics: widgetHiddenMetricsSchema.default([]), - widgetPrimaryMetric: z.string().max(64).nullable(), - widgetCustomLabel: z.string().max(64).nullable(), - widgetWarningMetric: widgetThresholdMetricSchema, - widgetWarningDirection: widgetWarningDirectionSchema.default("above"), - widgetWarningThreshold: z.number().min(0).max(1_000_000_000_000).nullable(), - widgetRefreshIntervalMs: z.number().int().min(10_000).max(300_000), -}); - -export const layoutConfigurationSchema = z.object({ - apps: z.array(layoutAppSchema).max(8), - server: z.object({ - enabled: z.boolean(), - order: z.number().int().min(0).max(10_000), - size: widgetSizeSchema, - showStats: z.boolean(), - showDetails: z.boolean(), - showProgress: z.boolean(), - showNative: z.boolean(), - showChart: z.boolean().default(true), - chartMetric: z.string().trim().min(1).max(64).default("Running containers"), - chartType: widgetChartTypeSchema.default("area"), - chartSamples: z.number().int().min(5).max(60).default(20), - showLegend: z.boolean().default(true), - hiddenMetrics: widgetHiddenMetricsSchema.default([]), - metricOrder: widgetMetricOrderSchema.default([]), - statLimit: z.number().int().min(1).max(12).default(8), - }), -}); - -export const layoutPresetInputSchema = z - .object({ name: z.string().trim().min(1).max(48) }) - .strict(); - -export const layoutPresetImportSchema = z - .object({ - version: z.literal(1), - name: z.string().trim().min(1).max(48), - configuration: layoutConfigurationSchema, - }) - .strict(); - -export const widgetLayoutInputSchema = z - .object({ - items: z - .array( - z.object({ - id: z.string(), - kind: z.enum(["server", "app"]), - order: z.number().int().min(0).max(10_000), - }), - ) - .min(1) - .max(9), - }) - .strict() - .refine( - (value) => - new Set(value.items.map((item) => item.id)).size === value.items.length, - "Layout items must be unique", - ); - -export const importSchema = z.object({ - version: z.literal(1), - exportedAt: z.string(), - categories: z.array(categoryInputSchema.extend({ id: z.string() })), - apps: z.array( - appPatchSchema.extend({ - id: z.string(), - displayName: z.string(), - visible: z.boolean(), - sortOrder: z.number(), - }), - ), - favorites: z.array(favoriteInputSchema.extend({ id: z.string() })), - removedApps: z.array(z.string().trim().min(1).max(255)).optional(), - preferences: backupPreferencesSchema, - layoutPresets: z - .array( - z.object({ - id: z.string(), - name: z.string().trim().min(1).max(48), - configuration: layoutConfigurationSchema, - createdAt: z.string(), - updatedAt: z.string(), - }), - ) - .optional(), -}); - -export type Theme = z.infer; -export type ViewMode = z.infer; -export type Accent = z.infer; -export type Density = z.infer; -export type ContentWidth = z.infer; -export type BackgroundStyle = z.infer; -export type WallpaperFit = z.infer; -export type WidgetSize = z.infer; -export type WidgetFreshness = z.infer; -export type WidgetChartType = z.infer; -export type WidgetMetricStyle = z.infer; -export type WidgetWarningDirection = z.infer< - typeof widgetWarningDirectionSchema ->; -export type WidgetThresholdMetric = z.infer; -export type AppPatch = z.infer; -export type CategoryInput = z.infer; -export type FavoriteInput = z.infer; -export type PreferencesPatch = z.infer; -export type DockDeckExport = z.infer; -export type LayoutConfiguration = z.infer; -export type LayoutPresetInput = z.infer; -export type LayoutPresetImport = z.infer; -export type WidgetLayoutInput = z.infer; - -export interface DockApp { - id: string; - technicalName: string; - displayName: string; - image: string | null; - icon: string | null; - status: z.infer; - localUrl: string | null; - localUrlOverride: string | null; - externalUrl: string | null; - externalUrlOverride: string | null; - visible: boolean; - widgetEnabled: boolean; - widgetSize: WidgetSize; - widgetShowSummary: boolean; - widgetShowMetrics: boolean; - widgetShowInfrastructure: boolean; - widgetShowProgress: boolean; - widgetShowSparkline: boolean; - widgetChartMetric: string; - widgetChartType: WidgetChartType; - widgetChartSamples: number; - widgetShowLegend: boolean; - widgetMetricStyles: Record; - widgetStatLimit: number; - widgetOrder: number; - widgetMetricOrder: string[]; - widgetHiddenMetrics: string[]; - widgetPrimaryMetric: string | null; - widgetCustomLabel: string | null; - widgetWarningMetric: WidgetThresholdMetric; - widgetWarningDirection: WidgetWarningDirection; - widgetWarningThreshold: number | null; - widgetRefreshIntervalMs: number; - categoryId: string | null; - sortOrder: number; - detectionSource: string; - lastChecked: string | null; - discoveredAt: string | null; - isNew: boolean; -} - -export interface WidgetMetric { - statusText: string; - cpuPercent: number | null; - memoryUsageBytes: number | null; - memoryLimitBytes: number | null; - networkRxBytes: number | null; - networkTxBytes: number | null; - networkRxBytesPerSecond: number | null; - networkTxBytesPerSecond: number | null; - pids: number | null; - updatedAt: string | null; - freshness: WidgetFreshness; - history: WidgetHistoryPoint[]; - provider?: ProviderWidget; -} - -export interface WidgetHistoryPoint { - sampledAt: string; - cpuPercent: number | null; - memoryPercent: number | null; - networkRxBytesPerSecond: number | null; -} - -export type ProviderKind = - | "adguard" - | "plex" - | "immich" - | "homeAssistant" - | "arr" - | "ollama" - | "glances" - | "tautulli" - | "paperless" - | "gitea" - | "jellyfin" - | "seerr" - | "prowlarr" - | "authentik" - | "npm" - | "portainer" - | "grafana" - | "prometheus" - | "deluge" - | "qbittorrent" - | "jdownloader" - | "tdarr" - | "bazarr" - | "vaultwarden" - | "nextcloud" - | "audiobookshelf" - | "netdata" - | "peertube" - | "custom"; - -export interface ProviderStat { - label: string; - value: string; - numericValue?: number; - tone?: "default" | "positive" | "warning"; -} - -export interface ProviderWidget { - kind: ProviderKind; - label: string; - summary: string; - stats: ProviderStat[]; - updatedAt: string; - freshness: WidgetFreshness; - history?: Record; -} - -export interface WidgetValueHistoryPoint { - sampledAt: string; - value: number; -} - -export interface WidgetCapability { - appId: string; - technicalName: string; - label: string; - description: string; - features: string[]; - metrics: WidgetMetricDefinition[]; - source: "provider" | "tailored-docker" | "custom-get"; - credentialMode: "none" | "optional" | "required"; - recommended: boolean; -} - -export interface WidgetMetricDefinition { - label: string; - description: string; - group: "activity" | "library" | "health" | "performance" | "system"; - defaultVisible: boolean; - defaultStyle: WidgetMetricStyle; - chartable: boolean; - unit: "count" | "percent" | "bytes" | "rate" | "duration" | "text"; -} - -export interface ServerWidgetMetric { - url: string; - hostName: string; - operatingSystem: string; - architecture: string; - dockerVersion: string; - cpuCount: number; - totalMemoryBytes: number; - containers: number; - containersRunning: number; - containersPaused: number; - containersStopped: number; - images: number; - updatedAt: string; - freshness: WidgetFreshness; - native: UnraidNativeMetric | null; - history?: Record; -} - -export interface UnraidStorageGroup { - name: string; - kind: "array" | "pool"; - status: string; - totalBytes: number; - usedBytes: number; - freeBytes: number; - hottestTemperatureC: number | null; - deviceCount: number; -} - -export interface UnraidNativeMetric { - arrayState: string; - parityStatus: string; - parityErrors: number; - lastParityCheck: string | null; - resyncPercent: number | null; - hottestTemperatureC: number | null; - storage: UnraidStorageGroup[]; - upsStatus: string | null; - upsLoadPercent: number | null; - upsBatteryPercent: number | null; - updatedAt: string; -} - -export interface Category { - id: string; - name: string; - icon: string; - sortOrder: number; -} - -export interface CategoryOrganizationResult { - updated: number; - unchanged: number; - unmatched: string[]; -} - -export interface Favorite extends FavoriteInput { - id: string; -} - -export interface Preferences { - theme: Theme; - viewMode: ViewMode; - accent: Accent; - density: Density; - contentWidth: ContentWidth; - backgroundStyle: BackgroundStyle; - wallpaperFit: WallpaperFit; - wallpaperPositionX: number; - wallpaperPositionY: number; - wallpaperZoom: number; - weatherEnabled: boolean; - weatherLocation: string; - weatherLatitude: number; - weatherLongitude: number; - reducedMotion: boolean; - showHero: boolean; - showStatus: boolean; - showServerWidget: boolean; - serverWidgetSize: WidgetSize; - serverWidgetShowStats: boolean; - serverWidgetShowDetails: boolean; - serverWidgetShowProgress: boolean; - serverWidgetShowNative: boolean; - serverWidgetShowChart: boolean; - serverWidgetChartMetric: string; - serverWidgetChartType: WidgetChartType; - serverWidgetChartSamples: number; - serverWidgetShowLegend: boolean; - serverWidgetHiddenMetrics: string[]; - serverWidgetMetricOrder: string[]; - serverWidgetStatLimit: number; - serverWidgetOrder: number; - serverName: string; - serverUrl: string; - pollingIntervalMs: number; - externalAccess: boolean; -} - -export interface IntegrationState { - id: string; - label: string; - state: "connected" | "degraded" | "disabled"; - message: string; - checkedAt: string; - requiredEnvironment?: string[]; - credentialFields?: Array<{ - key: string; - label: string; - type: "url" | "text" | "password"; - placeholder: string; - required?: boolean; - }>; -} - -export const integrationCredentialInputSchema = z - .object({ - values: z.record(z.string(), z.string().trim().max(2048)), - }) - .refine((input) => Object.keys(input.values).length <= 4, { - message: "Too many credential fields", - }); - -export type IntegrationCredentialInput = z.infer< - typeof integrationCredentialInputSchema ->; - -export interface DashboardPayload { - apps: DockApp[]; - categories: Category[]; - favorites: Favorite[]; - preferences: Preferences; - integrations: IntegrationState[]; - widgetMetrics: Record; - widgetCapabilities: WidgetCapability[]; - serverWidget: ServerWidgetMetric | null; - layoutPresets: LayoutPreset[]; - wallpaper: WallpaperDescriptor | null; -} - -export interface WallpaperDescriptor { - url: string; - updatedAt: string; -} - -export interface WeatherMetric { - location: string; - temperatureC: number; - weatherCode: number; - isDay: boolean; - summary: string; - updatedAt: string; -} - -export interface LayoutPreset { - id: string; - name: string; - configuration: LayoutConfiguration; - createdAt: string; - updatedAt: string; -} - -export interface SystemDiagnostics { - status: "healthy" | "degraded"; - generatedAt: string; - version: string; - database: "connected"; - enabledWidgets: number; - staleWidgets: number; - unavailableWidgets: number; - integrations: Array< - Pick - >; - unraidNative: "connected" | "unavailable"; - pwa: "enabled"; -} diff --git a/src/shared/domain.ts b/src/shared/domain.ts deleted file mode 100644 index 08459a63..00000000 --- a/src/shared/domain.ts +++ /dev/null @@ -1,396 +0,0 @@ -import type { - Category, - DockApp, - Favorite, - ServerWidgetMetric, -} from "./contracts.js"; - -export interface DockerPort { - PrivatePort: number; - PublicPort?: number; - Type?: string; -} - -export interface DockerNetwork { - IPAddress?: string; -} - -export function normalizeUrl(value: string | null | undefined): string | null { - if (!value?.trim()) return null; - const candidate = value.trim(); - if (!/^https?:\/\//i.test(candidate)) { - try { - return new URL(`http://${candidate}`).toString().replace(/\/$/, ""); - } catch { - return null; - } - } - try { - const url = new URL(candidate); - return ["http:", "https:"].includes(url.protocol) - ? url.toString().replace(/\/$/, "") - : null; - } catch { - return null; - } -} - -export function resolveUnraidWebUi( - template: string | null | undefined, - host: string, - ports: DockerPort[] = [], -): string | null { - if (!template) return null; - let resolved = template - .trim() - .replaceAll("[IP]", host) - .replaceAll("[HOST]", host); - resolved = resolved.replace(/\[PORT:(\d+)\]/g, (_, privatePort: string) => { - const binding = ports.find( - (port) => - port.PrivatePort === Number(privatePort) && - port.PublicPort !== undefined, - ); - const publishedPorts = new Set( - ports - .filter( - (port) => - port.PublicPort !== undefined && (port.Type ?? "tcp") === "tcp", - ) - .map((port) => port.PublicPort as number), - ); - return String( - binding?.PublicPort ?? - (publishedPorts.size === 1 - ? publishedPorts.values().next().value - : undefined) ?? - privatePort, - ); - }); - resolved = resolved.replace( - /\[PORT\]/g, - String(ports.find((port) => port.PublicPort)?.PublicPort ?? "80"), - ); - return normalizeUrl(resolved); -} - -export function resolveUnraidIcon( - template: string | null | undefined, - host: string, - ports: DockerPort[] = [], -): string | null { - if (!template?.trim()) return null; - const normalizedPath = template.trim().replaceAll("\\", "/"); - const dockerManIcon = normalizedPath.match( - /^\/boot\/config\/plugins\/dockerMan\/images\/([^/]+)$/i, - ); - if (dockerManIcon?.[1]) - return `/api/icons/${encodeURIComponent(dockerManIcon[1])}`; - if (normalizedPath.startsWith("/")) - return normalizeUrl(`http://${host}${normalizedPath}`); - return resolveUnraidWebUi(normalizedPath, host, ports); -} - -export function resolveContainerHost( - unraidHost: string, - networks: Record = {}, -): string { - const lanNetwork = Object.entries(networks).find( - ([name, network]) => /^br\d*$/i.test(name) && network.IPAddress?.trim(), - ); - return lanNetwork?.[1].IPAddress?.trim() ?? unraidHost; -} - -export function effectiveAppUrl( - app: DockApp, - externalAccess: boolean, -): string | null { - if (externalAccess) - return ( - app.externalUrlOverride ?? - app.externalUrl ?? - app.localUrlOverride ?? - app.localUrl - ); - return app.localUrlOverride ?? app.localUrl; -} - -export type SearchItem = { - id: string; - name: string; - url: string; - kind: "app" | "favorite" | "server"; - detail: string; - hidden?: boolean; -}; - -function searchable(value: string): string { - return value - .normalize("NFKD") - .replace(/[\u0300-\u036f]/g, "") - .toLocaleLowerCase(); -} - -export function searchItems( - query: string, - apps: DockApp[], - favorites: Favorite[], - externalAccess = false, - categories: Category[] = [], - server: ServerWidgetMetric | null = null, -): SearchItem[] { - const needle = searchable(query.trim()); - if (!needle) return []; - const categoriesById = new Map( - categories.map((category) => [category.id, category.name]), - ); - const appResults = apps.flatMap((app) => { - const url = effectiveAppUrl(app, externalAccess); - const category = app.categoryId - ? (categoriesById.get(app.categoryId) ?? "") - : ""; - const haystack = searchable( - [ - app.displayName, - app.technicalName, - app.image ?? "", - app.localUrl ?? "", - app.localUrlOverride ?? "", - app.externalUrl ?? "", - app.externalUrlOverride ?? "", - category, - ].join(" "), - ); - if (!url || !haystack.includes(needle)) return []; - return [ - { - id: app.id, - name: app.displayName, - url, - kind: "app", - detail: category || app.technicalName, - hidden: !app.visible, - }, - ]; - }); - const favoriteResults = favorites - .filter((favorite) => { - const category = favorite.categoryId - ? (categoriesById.get(favorite.categoryId) ?? "") - : ""; - return searchable( - `${favorite.name} ${favorite.url} ${category}`, - ).includes(needle); - }) - .map((favorite) => ({ - id: favorite.id, - name: favorite.name, - url: favorite.url, - kind: "favorite" as const, - detail: favorite.categoryId - ? (categoriesById.get(favorite.categoryId) ?? "Favorite") - : "Favorite", - })); - const serverResults: SearchItem[] = - server && - searchable( - `${server.hostName} ${server.operatingSystem} ${server.url} Unraid Tower server`, - ).includes(needle) - ? [ - { - id: "unraid-server", - name: server.hostName, - url: server.url, - kind: "server", - detail: server.operatingSystem, - }, - ] - : []; - return [...serverResults, ...appResults, ...favoriteResults] - .sort((a, b) => { - const aName = searchable(a.name); - const bName = searchable(b.name); - return ( - Number(bName === needle) - Number(aName === needle) || - Number(bName.startsWith(needle)) - Number(aName.startsWith(needle)) || - Number(a.hidden ?? false) - Number(b.hidden ?? false) || - a.name.localeCompare(b.name, undefined, { sensitivity: "base" }) - ); - }) - .slice(0, 12); -} - -export function googleSearchUrl(query: string): string { - return `https://www.google.com/search?q=${encodeURIComponent(query.trim())}`; -} - -export function favoriteIconUrl( - favorite: Pick, -): string | null { - if (favorite.icon?.trim()) return favorite.icon.trim(); - try { - return new URL("/favicon.ico", favorite.url).toString(); - } catch { - return null; - } -} - -const categoryRules: Array<{ name: string; pattern: RegExp }> = [ - { - name: "Databases & Storage", - pattern: - /adminer|postgres|mariadb|mysql|mongo(?:db)?|redis|valkey|database|storage|timescale|influxdb|elasticsearch/, - }, - { - name: "AI & ML", - pattern: - /anythingllm|ollama|open.?webui|litellm|stable.?diffusion|comfyui|invokeai|automatic1111|localai/, - }, - { - name: "Security & Identity", - pattern: - /authentik|authelia|vaultwarden|bitwarden|adguard|pihole|crowdsec|fail2ban|wazuh|security|identity/, - }, - { - name: "Downloads", - pattern: - /deluge|jdownloader|qbittorrent|transmission|sabnzbd|nzbget|slskd|flaresolverr|unpackerr|aria2|pyload/, - }, - { - name: "Books & Documents", - pattern: - /audiobookshelf|paperless|stirling|readmeabook|grimmory|komga|kavita|calibre|leafwiki|bookstack|wiki|document|pdf/, - }, - { - name: "Photos", - pattern: /immich|photoprism|librephotos|piwigo|photoshow|photo.?view/, - }, - { - name: "Home & Automation", - pattern: - /home.?assistant|node.?red|zigbee|zwave|esphome|mosquitto|homebridge|n8n|automation/, - }, - { - name: "Monitoring", - pattern: - /glances|netdata|grafana|prometheus|zabbix|uptime|monitor|gpu.?hot|unraid.?stats|scrutiny|healthchecks/, - }, - { - name: "Network & Admin", - pattern: - /nginx.?proxy|proxy.?manager|portainer|traefik|caddy|cloudflare|wireguard|tailscale|unraid.?appops|postfix|mail.?relay|network/, - }, - { - name: "Games & Emulation", - pattern: /retroarch|romm|emulat|pokemon|minecraft|steam|game.?server/, - }, - { - name: "Media", - pattern: - /plex|jellyfin|emby|tautulli|sonarr|radarr|lidarr|prowlarr|bazarr|seerr|audiodeck|listenarr|mylar|peertube|tdarr|metube|media.?codec/, - }, - { - name: "Tools & Productivity", - pattern: - /organizr|opscenter|it.?tools|nextcloud|syncthing|filebrowser|homepage|dashy|heimdall/, - }, - { - name: "Experiments & Archived", - pattern: /benchmark|legacy|archive|sandbox|prototype|experiment/, - }, - { - name: "Infrastructure", - pattern: - /watchtower|hivekeep|chimera|drop.?oss|infrastructure|infra.?service/, - }, - { - name: "Development & Projects", - pattern: - /dockdeck|gitea|gitlab|forgejo|blockpilot|geointel|evidencevault|infrabinder|lumaops|mcop|netronome|projectbrain|project.?launchpad|psforge|rickroll|spoortracker|vacatureradar|widefrog|github|code.?server/, - }, -]; - -/** - * Suggests one of DockDeck's canonical categories from stable container - * metadata. Unknown services deliberately return null so a bulk organization - * pass never guesses over a user's existing choice. - */ -export function inferAppCategoryName( - app: Pick, -): string | null { - const haystack = searchable( - `${app.technicalName} ${app.displayName} ${app.image ?? ""}`, - ); - return ( - categoryRules.find(({ pattern }) => pattern.test(haystack))?.name ?? null - ); -} - -export type ServiceKind = - | "dns" - | "media" - | "photos" - | "automation" - | "downloads" - | "monitoring" - | "code" - | "ai" - | "documents" - | "security" - | "service"; - -export function inferServiceKind(name: string): ServiceKind { - const normalized = name.toLowerCase(); - if (/adguard|pihole|dns/.test(normalized)) return "dns"; - if (/plex|jellyfin|emby|audiobook|komga|romm|peertube/.test(normalized)) - return "media"; - if (/immich|photo/.test(normalized)) return "photos"; - if ( - /home.?assistant|sonarr|radarr|lidarr|bazarr|prowlarr|seerr/.test( - normalized, - ) - ) - return "automation"; - if (/deluge|torrent|qbittorrent|jdownloader|metube|slskd/.test(normalized)) - return "downloads"; - if (/glances|netdata|monitor|grafana|uptime/.test(normalized)) - return "monitoring"; - if (/gitea|gitlab|forge|code/.test(normalized)) return "code"; - if (/ollama|litellm|anythingllm|chroma|searxng/.test(normalized)) return "ai"; - if (/paperless|stirling|leafwiki|readmeabook/.test(normalized)) - return "documents"; - if (/authentik|vaultwarden|nginx.?proxy|portainer/.test(normalized)) - return "security"; - return "service"; -} - -export function matchNpmHost(appName: string, hosts: string[]): string | null { - const normalized = appName.toLowerCase().replace(/[^a-z0-9]/g, ""); - const matches = hosts.filter((host) => { - const firstLabel = - host - .split(".")[0] - ?.toLowerCase() - .replace(/[^a-z0-9]/g, "") ?? ""; - return ( - firstLabel === normalized || - normalized.includes(firstLabel) || - firstLabel.includes(normalized) - ); - }); - return matches.length === 1 ? `https://${matches[0]}` : null; -} - -export function sortByPosition< - T extends { sortOrder: number; name?: string; displayName?: string }, ->(items: T[]): T[] { - return [...items].sort( - (a, b) => - a.sortOrder - b.sortOrder || - (a.name ?? a.displayName ?? "").localeCompare( - b.name ?? b.displayName ?? "", - undefined, - { sensitivity: "base" }, - ), - ); -} diff --git a/src/tests/api.test.ts b/src/tests/api.test.ts deleted file mode 100644 index a4386cee..00000000 --- a/src/tests/api.test.ts +++ /dev/null @@ -1,642 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { FastifyInstance } from "fastify"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { buildApp } from "../server/app.js"; -import { DockDeckRepository, type DiscoveredApp } from "../server/database.js"; -import type { DiscoveryService } from "../server/integrations/discovery.js"; - -let app: FastifyInstance | undefined; -afterEach(async () => { - await app?.close(); - app = undefined; - vi.unstubAllGlobals(); - for (const key of [ - "PLEX_URL", - "PLEX_TOKEN", - "CUSTOM_WIDGET_PLAIN_SERVICE_URL", - "CUSTOM_WIDGET_PLAIN_SERVICE_TOKEN", - "UNRAID_ICONS_PATH", - ]) - delete process.env[key]; -}); - -describe("DockDeck API", () => { - it("offers an explicit safe category organization action", async () => { - const repository = new DockDeckRepository(":memory:"); - repository.createCategory({ - name: "Downloads", - icon: "Download", - sortOrder: 40, - }); - repository.syncDiscovered([ - { - id: "deluge", - technicalName: "binhex-deluge", - displayName: "Deluge", - image: "binhex/arch-deluge:latest", - icon: null, - status: "online", - localUrl: "http://tower.local:8112", - externalUrl: null, - detectionSource: "test", - lastChecked: "2026-07-22T00:00:00.000Z", - }, - ]); - app = await buildApp({ repository, serveClient: false }); - - const response = await app.inject({ - method: "POST", - url: "/api/categories/organize", - }); - expect(response.statusCode).toBe(200); - expect(response.json()).toEqual({ - updated: 1, - unchanged: 0, - unmatched: [], - }); - expect(repository.getApps()[0]?.categoryId).toBe( - repository - .getCategories() - .find((category) => category.name === "Downloads")?.id, - ); - }); - - it("serves cached Unraid icons read-only and case-insensitively", async () => { - const iconsPath = mkdtempSync(join(tmpdir(), "dockdeck-icons-")); - const iconBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47]); - writeFileSync(join(iconsPath, "Media-Service-icon.png"), iconBytes); - process.env.UNRAID_ICONS_PATH = iconsPath; - try { - app = await buildApp({ - repository: new DockDeckRepository(":memory:"), - serveClient: false, - }); - const response = await app.inject({ - method: "GET", - url: "/api/icons/media-service-icon.png", - }); - expect(response.statusCode).toBe(200); - expect(response.headers["content-type"]).toContain("image/png"); - expect(response.rawPayload).toEqual(iconBytes); - } finally { - await app?.close(); - app = undefined; - delete process.env.UNRAID_ICONS_PATH; - rmSync(iconsPath, { recursive: true, force: true }); - } - }); - - it("prunes stale records only after a connected inventory heartbeat", async () => { - const repository = new DockDeckRepository(":memory:"); - const staleApp: DiscoveredApp = { - id: "stale-container", - technicalName: "retired-service", - displayName: "Retired service", - image: "retired:latest", - icon: null, - status: "offline", - localUrl: null, - externalUrl: null, - detectionSource: "test", - lastChecked: "2026-07-22T00:00:00.000Z", - }; - repository.syncDiscovered([staleApp]); - const degradedState = { - id: "docker" as const, - label: "AppOps inventory", - state: "degraded" as const, - message: "Niet bereikbaar", - checkedAt: "2026-07-22T00:00:00.000Z", - }; - const connectedState = { - ...degradedState, - state: "connected" as const, - message: "0 containers gelezen", - }; - let inventoryConnected = false; - const degradedDiscovery = { - getStates: () => [inventoryConnected ? connectedState : degradedState], - getWidgetMetrics: () => ({}), - getServerWidget: () => null, - discover: async () => ({ - apps: [], - integrations: [inventoryConnected ? connectedState : degradedState], - }), - } as unknown as DiscoveryService; - app = await buildApp({ - repository, - discovery: degradedDiscovery, - serveClient: false, - }); - - await app.inject({ method: "POST", url: "/api/discovery/sync" }); - expect(repository.getApps()).toHaveLength(1); - inventoryConnected = true; - await app.inject({ method: "POST", url: "/api/discovery/sync" }); - expect(repository.getApps().some((item) => item.id === staleApp.id)).toBe( - false, - ); - }); - - it("discovers deterministic containers and defaults them to hidden", async () => { - app = await buildApp({ - repository: new DockDeckRepository(":memory:"), - serveClient: false, - }); - const sync = await app.inject({ - method: "POST", - url: "/api/discovery/sync", - }); - expect(sync.statusCode).toBe(200); - const body = sync.json<{ - apps: Array<{ visible: boolean }>; - integrations: Array<{ state: string }>; - }>(); - expect(body.apps.length).toBeGreaterThan(2); - expect(body.apps.every((item) => item.visible === false)).toBe(true); - expect(body.integrations.some((item) => item.state === "connected")).toBe( - true, - ); - const dashboard = ( - await app.inject({ method: "GET", url: "/api/dashboard" }) - ).json(); - expect(dashboard.widgetCapabilities).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - technicalName: "Plex-Media-Server", - source: "provider", - }), - ]), - ); - expect(dashboard.widgetCapabilities).toHaveLength(dashboard.apps.length); - expect(dashboard.preferences.showServerWidget).toBe(true); - expect(dashboard.serverWidget).toMatchObject({ - hostName: "Tower", - url: "http://tower.local:5000", - }); - }); - - it("supports visibility, category, favorites, preferences and import/export flows", async () => { - app = await buildApp({ - repository: new DockDeckRepository(":memory:"), - serveClient: false, - }); - await app.inject({ method: "POST", url: "/api/discovery/sync" }); - const dashboard = ( - await app.inject({ method: "GET", url: "/api/dashboard" }) - ).json(); - const category = ( - await app.inject({ - method: "POST", - url: "/api/categories", - payload: { name: "Lab", icon: "FlaskConical", sortOrder: 1 }, - }) - ).json(); - expect( - ( - await app.inject({ - method: "PATCH", - url: `/api/apps/${dashboard.apps[0].id}`, - payload: { - visible: true, - widgetEnabled: true, - widgetSize: "wide", - widgetShowSummary: false, - widgetShowInfrastructure: true, - widgetShowProgress: false, - categoryId: category.id, - }, - }) - ).statusCode, - ).toBe(200); - expect( - ( - await app.inject({ - method: "POST", - url: "/api/favorites", - payload: { - name: "OpenAI", - url: "https://openai.com", - icon: null, - categoryId: category.id, - sortOrder: 0, - }, - }) - ).statusCode, - ).toBe(201); - expect( - ( - await app.inject({ - method: "PATCH", - url: "/api/preferences", - payload: { - theme: "dark", - viewMode: "categories", - accent: "violet", - density: "spacious", - contentWidth: "fluid", - backgroundStyle: "grid", - wallpaperFit: "cover", - wallpaperPositionX: 30, - wallpaperPositionY: 70, - wallpaperZoom: 120, - reducedMotion: true, - showHero: false, - showStatus: false, - serverWidgetSize: "compact", - serverWidgetShowDetails: false, - serverWidgetChartMetric: "Storage used", - serverWidgetChartType: "bar", - serverWidgetChartSamples: 45, - serverWidgetShowLegend: false, - serverWidgetHiddenMetrics: ["CPU cores"], - serverWidgetMetricOrder: ["Storage used", "Array state"], - serverWidgetStatLimit: 4, - serverName: "Main Unraid", - serverUrl: "http://192.168.10.150:5000", - pollingIntervalMs: 60000, - }, - }) - ).statusCode, - ).toBe(200); - const customized = ( - await app.inject({ method: "GET", url: "/api/dashboard" }) - ).json(); - expect(customized.preferences).toMatchObject({ - accent: "violet", - density: "spacious", - contentWidth: "fluid", - backgroundStyle: "grid", - wallpaperFit: "cover", - wallpaperPositionX: 30, - wallpaperPositionY: 70, - wallpaperZoom: 120, - reducedMotion: true, - showHero: false, - showStatus: false, - serverWidgetSize: "compact", - serverWidgetShowDetails: false, - serverWidgetChartMetric: "Storage used", - serverWidgetChartType: "bar", - serverWidgetChartSamples: 45, - serverWidgetShowLegend: false, - serverWidgetHiddenMetrics: ["CPU cores"], - serverWidgetMetricOrder: ["Storage used", "Array state"], - serverWidgetStatLimit: 4, - serverName: "Main Unraid", - serverUrl: "http://192.168.10.150:5000", - pollingIntervalMs: 60000, - }); - expect(customized.serverWidget).toMatchObject({ - hostName: "Main Unraid", - url: "http://192.168.10.150:5000", - }); - await app.inject({ method: "POST", url: "/api/discovery/sync" }); - const withWidgets = ( - await app.inject({ method: "GET", url: "/api/dashboard" }) - ).json(); - expect(Object.keys(withWidgets.widgetMetrics)).toContain( - customized.apps[0].technicalName, - ); - expect(withWidgets.apps[0]).toMatchObject({ - widgetSize: "wide", - widgetShowSummary: false, - widgetShowInfrastructure: true, - widgetShowProgress: false, - }); - const exported = await app.inject({ method: "GET", url: "/api/export" }); - expect(exported.headers["content-disposition"]).toContain( - "dockdeck-backup", - ); - expect( - ( - await app.inject({ - method: "POST", - url: "/api/import", - payload: exported.json(), - }) - ).statusCode, - ).toBe(200); - }); - - it("validates unsafe or malformed input at the API boundary", async () => { - app = await buildApp({ - repository: new DockDeckRepository(":memory:"), - serveClient: false, - }); - expect( - ( - await app.inject({ - method: "POST", - url: "/api/favorites", - payload: { name: "", url: "javascript:alert(1)" }, - }) - ).statusCode, - ).toBe(400); - await app.inject({ method: "POST", url: "/api/discovery/sync" }); - const dashboard = ( - await app.inject({ method: "GET", url: "/api/dashboard" }) - ).json(); - expect( - ( - await app.inject({ - method: "PATCH", - url: `/api/apps/${dashboard.apps[0].id}`, - payload: { widgetSize: "billboard" }, - }) - ).statusCode, - ).toBe(400); - expect( - ( - await app.inject({ - method: "PATCH", - url: "/api/preferences", - payload: { pollingIntervalMs: 100 }, - }) - ).statusCode, - ).toBe(400); - }); - - it("manages widget ordering, presets and secret-free diagnostics", async () => { - app = await buildApp({ - repository: new DockDeckRepository(":memory:"), - serveClient: false, - }); - await app.inject({ method: "POST", url: "/api/discovery/sync" }); - const dashboard = ( - await app.inject({ method: "GET", url: "/api/dashboard" }) - ).json(); - const first = dashboard.apps[0]; - await app.inject({ - method: "PATCH", - url: `/api/apps/${first.id}`, - payload: { - widgetEnabled: true, - widgetOrder: 20, - widgetShowSparkline: true, - widgetChartMetric: "CPU", - widgetChartType: "area", - widgetChartSamples: 30, - widgetShowLegend: false, - widgetMetricStyles: { CPU: "gauge", Memory: "progress" }, - widgetStatLimit: 4, - widgetMetricOrder: ["CPU", "Memory"], - widgetHiddenMetrics: ["Processes", "Uptime"], - widgetPrimaryMetric: "CPU", - widgetCustomLabel: "Priority workload", - widgetWarningMetric: "cpu", - widgetWarningDirection: "above", - widgetWarningThreshold: 70, - widgetRefreshIntervalMs: 60000, - }, - }); - expect( - ( - await app.inject({ - method: "PUT", - url: "/api/widget-layout", - payload: { - items: [ - { id: "server", kind: "server", order: 20 }, - { id: first.id, kind: "app", order: 10 }, - ], - }, - }) - ).statusCode, - ).toBe(200); - const preset = ( - await app.inject({ - method: "POST", - url: "/api/layout-presets", - payload: { name: "Maintenance" }, - }) - ).json(); - expect(preset.configuration.apps[0]).toMatchObject({ - appId: first.id, - widgetOrder: 10, - widgetPrimaryMetric: "CPU", - widgetHiddenMetrics: ["Processes", "Uptime"], - widgetRefreshIntervalMs: 60000, - }); - const exported = await app.inject({ - method: "GET", - url: `/api/layout-presets/${preset.id}/export`, - }); - expect(exported.statusCode).toBe(200); - expect(exported.json()).toMatchObject({ version: 1, name: "Maintenance" }); - const diagnostics = await app.inject({ - method: "GET", - url: "/api/diagnostics", - }); - expect(diagnostics.statusCode).toBe(200); - expect(diagnostics.json()).toMatchObject({ - database: "connected", - enabledWidgets: 1, - pwa: "enabled", - }); - expect(diagnostics.body).not.toMatch(/token|password|secret/i); - }); - - it("removes an app from DockDeck without rediscovering it", async () => { - app = await buildApp({ - repository: new DockDeckRepository(":memory:"), - serveClient: false, - }); - await app.inject({ method: "POST", url: "/api/discovery/sync" }); - const initial = ( - await app.inject({ method: "GET", url: "/api/dashboard" }) - ).json(); - const removed = initial.apps[0]; - expect( - ( - await app.inject({ - method: "DELETE", - url: `/api/apps/${removed.id}`, - }) - ).statusCode, - ).toBe(204); - await app.inject({ method: "POST", url: "/api/discovery/sync" }); - const afterSync = ( - await app.inject({ method: "GET", url: "/api/dashboard" }) - ).json(); - expect( - afterSync.apps.some( - (item: { technicalName: string }) => - item.technicalName === removed.technicalName, - ), - ).toBe(false); - expect( - (await app.inject({ method: "GET", url: "/api/export" })).json() - .removedApps, - ).toContain(removed.technicalName); - }); - - it("accepts provider credentials without returning or exporting their values", async () => { - app = await buildApp({ - repository: new DockDeckRepository(":memory:"), - serveClient: false, - }); - await app.inject({ method: "POST", url: "/api/discovery/sync" }); - const initial = ( - await app.inject({ method: "GET", url: "/api/dashboard" }) - ).json(); - const plex = initial.apps.find( - (item: { technicalName: string }) => - item.technicalName === "Plex-Media-Server", - ); - await app.inject({ - method: "PATCH", - url: `/api/apps/${plex.id}`, - payload: { widgetEnabled: true }, - }); - await app.inject({ method: "POST", url: "/api/discovery/sync" }); - vi.stubGlobal( - "fetch", - vi.fn(async (input: string | URL | Request) => { - const url = String(input); - return new Response( - JSON.stringify({ - MediaContainer: url.includes("status/sessions") - ? { size: 1, Metadata: [{ TranscodeSession: {} }] } - : { size: 2 }, - }), - { status: 200, headers: { "content-type": "application/json" } }, - ); - }), - ); - const opaqueValue = "opaque-test-value"; - const rejected = await app.inject({ - method: "PUT", - url: "/api/integrations/widget-Plex-Media-Server/credentials", - headers: { origin: "http://foreign.test", host: "dockdeck.test" }, - payload: { - values: { - PLEX_URL: "http://plex.test", - PLEX_TOKEN: opaqueValue, - }, - }, - }); - expect(rejected.statusCode).toBe(403); - expect(process.env.PLEX_TOKEN).toBeUndefined(); - const saved = await app.inject({ - method: "PUT", - url: "/api/integrations/widget-Plex-Media-Server/credentials", - headers: { origin: "http://dockdeck.test", host: "dockdeck.test" }, - payload: { - values: { - PLEX_URL: "http://plex.test", - PLEX_TOKEN: opaqueValue, - }, - }, - }); - expect(saved.statusCode).toBe(200); - expect(saved.json()).toEqual({ saved: true }); - const dashboard = await app.inject({ - method: "GET", - url: "/api/dashboard", - }); - const exported = await app.inject({ method: "GET", url: "/api/export" }); - expect(dashboard.body).not.toContain(opaqueValue); - expect(exported.body).not.toContain(opaqueValue); - expect( - dashboard.json().widgetMetrics["Plex-Media-Server"].provider, - ).toMatchObject({ - kind: "plex", - summary: "1 active stream", - }); - }); - - it("configures GET-only metrics for an app without a native provider", async () => { - const repository = new DockDeckRepository(":memory:"); - repository.syncDiscovered([ - { - id: "plain-service", - technicalName: "plain-service", - displayName: "Plain service", - image: "example/plain:latest", - icon: null, - status: "online", - localUrl: "http://plain.test", - externalUrl: null, - detectionSource: "test", - lastChecked: "2026-07-14T00:00:00.000Z", - }, - ]); - app = await buildApp({ - repository, - serveClient: false, - }); - const initial = ( - await app.inject({ method: "GET", url: "/api/dashboard" }) - ).json(); - const plain = initial.apps.find( - (item: { technicalName: string }) => - item.technicalName === "plain-service", - ); - expect(plain).toBeDefined(); - if (!plain) return; - await app.inject({ - method: "PATCH", - url: `/api/apps/${plain.id}`, - payload: { widgetEnabled: true }, - }); - vi.stubGlobal( - "fetch", - vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { - expect(init?.method).toBe("GET"); - expect(init?.headers).toMatchObject({ - Authorization: "Bearer opaque-custom-token", - }); - return new Response( - JSON.stringify({ - summary: "42 jobs processed", - stats: [ - { label: "Jobs", value: 42, tone: "positive" }, - { label: "Failures", value: 0 }, - ], - }), - { status: 200, headers: { "content-type": "application/json" } }, - ); - }), - ); - const saved = await app.inject({ - method: "PUT", - url: "/api/integrations/widget-plain-service/credentials", - headers: { origin: "http://dockdeck.test", host: "dockdeck.test" }, - payload: { - values: { - CUSTOM_WIDGET_PLAIN_SERVICE_URL: "http://plain.test/metrics", - CUSTOM_WIDGET_PLAIN_SERVICE_TOKEN: "opaque-custom-token", - }, - }, - }); - expect(saved.statusCode).toBe(200); - const dashboard = await app.inject({ - method: "GET", - url: "/api/dashboard", - }); - expect( - dashboard.json().widgetMetrics["plain-service"].provider, - ).toMatchObject({ - kind: "custom", - summary: "42 jobs processed", - stats: [ - { label: "Jobs", value: "42", tone: "positive" }, - { label: "Failures", value: "0" }, - ], - }); - expect(dashboard.body).not.toContain("opaque-custom-token"); - const cleared = await app.inject({ - method: "PUT", - url: "/api/integrations/widget-plain-service/credentials", - headers: { origin: "http://dockdeck.test", host: "dockdeck.test" }, - payload: { - values: { CUSTOM_WIDGET_PLAIN_SERVICE_TOKEN: "" }, - }, - }); - expect(cleared.statusCode).toBe(200); - expect(process.env.CUSTOM_WIDGET_PLAIN_SERVICE_TOKEN).toBe(""); - }); -}); diff --git a/src/tests/category-icons.test.ts b/src/tests/category-icons.test.ts deleted file mode 100644 index e6830231..00000000 --- a/src/tests/category-icons.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - categoryIconOptions, - nextAvailableCategoryIcon, - resolveCategoryIcon, -} from "../client/categoryIcons.js"; - -const canonicalCategoryIcons = [ - "Play", - "Download", - "FileText", - "Image", - "Home", - "ShieldCheck", - "Network", - "Activity", - "Code2", - "Bot", - "Database", - "Server", - "Gamepad2", - "Wrench", - "Archive", - "Star", -] as const; - -describe("category icons", () => { - it("renders every canonical category with a distinct supported icon", () => { - const optionIds = categoryIconOptions.map((option) => option.id); - expect(new Set(optionIds).size).toBe(optionIds.length); - expect(optionIds).toEqual( - expect.arrayContaining([...canonicalCategoryIcons]), - ); - - const components = canonicalCategoryIcons.map(resolveCategoryIcon); - expect(new Set(components).size).toBe(canonicalCategoryIcons.length); - }); - - it("keeps a safe generic fallback for imported custom values", () => { - expect(resolveCategoryIcon("LegacyCustomIcon")).toBe( - resolveCategoryIcon("Layers3"), - ); - }); - - it("assigns a still-unused icon to a newly created category", () => { - expect(nextAvailableCategoryIcon(["Layers3", "Play"])).toBe("Download"); - }); -}); diff --git a/src/tests/database.test.ts b/src/tests/database.test.ts deleted file mode 100644 index 524f5954..00000000 --- a/src/tests/database.test.ts +++ /dev/null @@ -1,515 +0,0 @@ -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import Database from "better-sqlite3"; -import { afterEach, describe, expect, it } from "vitest"; -import { DockDeckRepository, type DiscoveredApp } from "../server/database.js"; - -const tempDirs: string[] = []; -const discovered: DiscoveredApp = { - id: "container-1", - technicalName: "example", - displayName: "Example", - image: "example:latest", - icon: null, - status: "online", - localUrl: "http://tower.local:8080", - externalUrl: null, - detectionSource: "test", - lastChecked: "2026-07-14T00:00:00.000Z", -}; - -afterEach(() => { - while (tempDirs.length) - rmSync(tempDirs.pop()!, { recursive: true, force: true }); -}); - -describe("SQLite repository", () => { - it("migrates a new database and keeps newly discovered apps hidden", () => { - const repository = new DockDeckRepository(":memory:"); - repository.syncDiscovered([discovered]); - expect(repository.getApps()[0]).toMatchObject({ - visible: false, - widgetEnabled: false, - widgetSize: "standard", - widgetShowSummary: true, - widgetShowMetrics: true, - widgetShowInfrastructure: true, - widgetShowProgress: true, - widgetShowSparkline: true, - widgetChartMetric: "auto", - widgetChartType: "line", - widgetChartSamples: 20, - widgetShowLegend: true, - widgetMetricStyles: {}, - widgetStatLimit: 6, - widgetOrder: 0, - widgetMetricOrder: [], - widgetHiddenMetrics: [], - widgetPrimaryMetric: null, - widgetCustomLabel: null, - widgetWarningMetric: "cpu", - widgetWarningDirection: "above", - widgetWarningThreshold: null, - widgetRefreshIntervalMs: 30000, - displayName: "Example", - status: "online", - isNew: true, - }); - expect(repository.getApps()[0]?.discoveredAt).toEqual(expect.any(String)); - expect(repository.getPreferences()).toMatchObject({ - theme: "light", - viewMode: "overview", - accent: "coral", - density: "comfortable", - contentWidth: "wide", - backgroundStyle: "aurora", - wallpaperFit: "contain", - wallpaperPositionX: 50, - wallpaperPositionY: 50, - wallpaperZoom: 100, - showHero: true, - showStatus: true, - serverWidgetSize: "standard", - serverWidgetShowStats: true, - serverWidgetShowDetails: true, - serverWidgetShowProgress: true, - serverWidgetShowNative: true, - serverWidgetShowChart: true, - serverWidgetChartMetric: "Running containers", - serverWidgetChartType: "area", - serverWidgetChartSamples: 20, - serverWidgetShowLegend: true, - serverWidgetHiddenMetrics: [], - serverWidgetMetricOrder: [], - serverWidgetStatLimit: 8, - serverWidgetOrder: 0, - serverName: "Tower", - serverUrl: "http://tower.local:5000", - }); - repository.close(); - }); - - it("preserves user configuration and data across a restart", () => { - const directory = mkdtempSync(join(tmpdir(), "dockdeck-")); - tempDirs.push(directory); - const path = join(directory, "dockdeck.db"); - const first = new DockDeckRepository(path); - first.syncDiscovered([discovered]); - first.updateApp(discovered.id, { - visible: true, - widgetEnabled: true, - widgetSize: "wide", - widgetShowSummary: false, - widgetShowMetrics: true, - widgetShowInfrastructure: true, - widgetShowProgress: false, - widgetShowSparkline: true, - widgetChartMetric: "Queued jobs", - widgetChartType: "area", - widgetChartSamples: 45, - widgetShowLegend: false, - widgetMetricStyles: { - "Working memory": "gauge", - "Process load": "progress", - }, - widgetStatLimit: 4, - widgetOrder: 30, - widgetMetricOrder: ["Working memory", "Process load"], - widgetHiddenMetrics: ["Process load"], - widgetPrimaryMetric: "Working memory", - widgetCustomLabel: "Example workload", - widgetWarningMetric: "memory", - widgetWarningDirection: "below", - widgetWarningThreshold: 75, - widgetRefreshIntervalMs: 60000, - displayName: "My Example", - }); - first.createFavorite({ - name: "Docs", - url: "https://example.com/docs", - icon: null, - categoryId: null, - sortOrder: 0, - }); - first.close(); - - const restarted = new DockDeckRepository(path); - expect(restarted.getApps()[0]).toMatchObject({ - visible: true, - widgetEnabled: true, - widgetSize: "wide", - widgetShowSummary: false, - widgetShowProgress: false, - widgetChartMetric: "Queued jobs", - widgetChartType: "area", - widgetChartSamples: 45, - widgetShowLegend: false, - widgetMetricStyles: { - "Working memory": "gauge", - "Process load": "progress", - }, - widgetStatLimit: 4, - widgetOrder: 30, - widgetMetricOrder: ["Working memory", "Process load"], - widgetHiddenMetrics: ["Process load"], - widgetPrimaryMetric: "Working memory", - widgetCustomLabel: "Example workload", - widgetWarningMetric: "memory", - widgetWarningDirection: "below", - widgetWarningThreshold: 75, - widgetRefreshIntervalMs: 60000, - displayName: "My Example", - isNew: false, - }); - expect(restarted.getFavorites()[0]?.name).toBe("Docs"); - restarted.close(); - }); - - it("preserves configuration when Docker assigns a replacement container id", () => { - const repository = new DockDeckRepository(":memory:"); - repository.syncDiscovered([discovered]); - repository.updateApp(discovered.id, { - visible: true, - displayName: "Configured Example", - }); - repository.syncDiscovered([ - { ...discovered, id: "replacement-container-id", status: "online" }, - ]); - expect(repository.getApps()).toHaveLength(1); - expect(repository.getApps()[0]).toMatchObject({ - id: discovered.id, - visible: true, - displayName: "Configured Example", - status: "online", - }); - repository.close(); - }); - - it("removes containers that disappeared from a successful inventory", () => { - const repository = new DockDeckRepository(":memory:"); - const removedContainer = { - ...discovered, - id: "container-2", - technicalName: "retired-service", - }; - repository.syncDiscovered([discovered, removedContainer]); - - repository.syncDiscovered([discovered]); - - expect(repository.getApps().map((app) => app.technicalName)).toEqual([ - "example", - ]); - repository.close(); - }); - - it("organizes recognized containers and preserves unknown assignments", () => { - const repository = new DockDeckRepository(":memory:"); - const downloads = repository.createCategory({ - name: "Downloads", - icon: "Download", - sortOrder: 40, - }); - repository.syncDiscovered([ - { ...discovered, technicalName: "binhex-deluge" }, - { - ...discovered, - id: "container-2", - technicalName: "private-workload", - displayName: "Private workload", - }, - ]); - repository.updateApp("container-2", { categoryId: "cat-tools" }); - - expect(repository.organizeAppCategories()).toEqual({ - updated: 1, - unchanged: 0, - unmatched: ["Private workload"], - }); - expect(repository.getApps()).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - technicalName: "binhex-deluge", - categoryId: downloads.id, - }), - expect.objectContaining({ - technicalName: "private-workload", - categoryId: "cat-tools", - }), - ]), - ); - repository.close(); - }); - - it("keeps saved containers when the inventory check is not authoritative", () => { - const repository = new DockDeckRepository(":memory:"); - repository.syncDiscovered([discovered]); - - repository.syncDiscovered([], { removeMissing: false }); - - expect(repository.getApps()).toHaveLength(1); - expect(repository.getApps()[0]).toMatchObject({ - technicalName: "example", - status: "online", - }); - repository.close(); - }); - - it("merges case-only duplicates and keeps their configured dashboard state", () => { - const repository = new DockDeckRepository(":memory:"); - repository.syncDiscovered([ - { ...discovered, technicalName: "media-service", status: "offline" }, - ]); - repository.updateApp(discovered.id, { - visible: true, - displayName: "Mijn media", - }); - repository.syncDiscovered( - [ - { - ...discovered, - id: "replacement-container-id", - technicalName: "Media-Service", - status: "online", - }, - ], - { removeMissing: false }, - ); - - expect(repository.getApps()).toHaveLength(1); - expect(repository.getApps()[0]).toMatchObject({ - technicalName: "Media-Service", - displayName: "Mijn media", - visible: true, - status: "online", - }); - repository.close(); - }); - - it("keeps DockDeck customization when the Compose container gets its final name", () => { - const repository = new DockDeckRepository(":memory:"); - repository.syncDiscovered([ - { - ...discovered, - id: "legacy-dockdeck", - technicalName: "dockdeck-dockdeck-1", - displayName: "DockDeck", - image: "dockdeck:local", - icon: "http://[IP]:[PORT:1218]/dockdeck-icon.svg", - }, - ]); - repository.updateApp("legacy-dockdeck", { - visible: true, - displayName: "My DockDeck", - }); - repository.syncDiscovered([ - { - ...discovered, - id: "final-dockdeck", - technicalName: "DockDeck", - displayName: "DockDeck", - image: "dockdeck:local", - icon: "http://tower.local:1218/dockdeck-icon.png", - localUrl: "http://tower.local:1218", - }, - ]); - expect(repository.getApps()).toHaveLength(1); - expect(repository.getApps()[0]).toMatchObject({ - id: "legacy-dockdeck", - technicalName: "DockDeck", - displayName: "My DockDeck", - visible: true, - icon: "http://tower.local:1218/dockdeck-icon.png", - localUrl: "http://tower.local:1218", - status: "online", - }); - repository.close(); - }); - - it("round-trips a versioned export without secrets", () => { - const repository = new DockDeckRepository(":memory:"); - repository.syncDiscovered([discovered]); - repository.updateApp(discovered.id, { - visible: true, - widgetEnabled: true, - }); - repository.updatePreferences({ - theme: "harbor", - accent: "ocean", - density: "compact", - contentWidth: "fluid", - backgroundStyle: "grid", - wallpaperFit: "cover", - wallpaperPositionX: 28, - wallpaperPositionY: 64, - wallpaperZoom: 125, - reducedMotion: true, - showHero: false, - showStatus: false, - serverWidgetSize: "compact", - serverWidgetShowDetails: false, - serverName: "Rack server", - serverUrl: "http://192.168.10.150:5000", - }); - const backup = repository.export(); - expect(backup.version).toBe(1); - expect(JSON.stringify(backup)).not.toMatch(/token|password|secret/i); - repository.import(backup); - expect(repository.getPreferences()).toMatchObject({ - theme: "harbor", - accent: "ocean", - density: "compact", - contentWidth: "fluid", - backgroundStyle: "grid", - wallpaperFit: "cover", - wallpaperPositionX: 28, - wallpaperPositionY: 64, - wallpaperZoom: 125, - reducedMotion: true, - showHero: false, - showStatus: false, - serverWidgetSize: "compact", - serverWidgetShowDetails: false, - serverName: "Rack server", - serverUrl: "http://192.168.10.150:5000", - }); - expect(repository.getApps()[0]?.visible).toBe(true); - expect(repository.getApps()[0]?.widgetEnabled).toBe(true); - repository.close(); - }); - - it("adds customization defaults to a legacy preferences table", () => { - const directory = mkdtempSync(join(tmpdir(), "dockdeck-legacy-")); - tempDirs.push(directory); - const path = join(directory, "dockdeck.db"); - const legacy = new Database(path); - legacy.exec(` - CREATE TABLE preferences ( - id INTEGER PRIMARY KEY CHECK(id = 1), - theme TEXT NOT NULL DEFAULT 'light', - view_mode TEXT NOT NULL DEFAULT 'overview', - polling_interval_ms INTEGER NOT NULL DEFAULT 30000, - external_access INTEGER NOT NULL DEFAULT 0 - ); - INSERT INTO preferences (id, theme) VALUES (1, 'dark'); - `); - legacy.close(); - - const migrated = new DockDeckRepository(path); - expect(migrated.getPreferences()).toMatchObject({ - theme: "dark", - accent: "coral", - density: "comfortable", - contentWidth: "wide", - backgroundStyle: "aurora", - wallpaperFit: "contain", - wallpaperPositionX: 50, - wallpaperPositionY: 50, - wallpaperZoom: 100, - showHero: true, - showStatus: true, - serverWidgetSize: "standard", - serverWidgetShowStats: true, - serverName: "Tower", - serverUrl: "http://tower.local:5000", - }); - migrated.close(); - }); - - it("enables widgets for visible apps when migrating a legacy app table", () => { - const directory = mkdtempSync(join(tmpdir(), "dockdeck-widget-legacy-")); - tempDirs.push(directory); - const path = join(directory, "dockdeck.db"); - const legacy = new Database(path); - legacy.exec(` - CREATE TABLE apps ( - id TEXT PRIMARY KEY, technical_name TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, - image TEXT, icon TEXT, status TEXT NOT NULL DEFAULT 'unknown', local_url TEXT, - local_url_override TEXT, external_url TEXT, external_url_override TEXT, - visible INTEGER NOT NULL DEFAULT 0, category_id TEXT, sort_order INTEGER NOT NULL DEFAULT 0, - detection_source TEXT NOT NULL, last_checked TEXT - ); - INSERT INTO apps (id, technical_name, display_name, visible, detection_source) - VALUES ('legacy-app', 'legacy', 'Legacy', 1, 'test'); - `); - legacy.close(); - - const migrated = new DockDeckRepository(path); - expect(migrated.getApps()[0]).toMatchObject({ - visible: true, - widgetEnabled: true, - isNew: false, - }); - migrated.close(); - }); - - it("captures, applies and exports independent widget layout presets", () => { - const repository = new DockDeckRepository(":memory:"); - repository.syncDiscovered([discovered]); - repository.updateApp(discovered.id, { - widgetEnabled: true, - widgetOrder: 20, - widgetSize: "wide", - widgetCustomLabel: "Morning status", - widgetChartMetric: "Memory", - widgetChartType: "bar", - widgetChartSamples: 30, - widgetShowLegend: false, - widgetMetricStyles: { Memory: "gauge" }, - widgetStatLimit: 3, - widgetWarningMetric: "memory", - widgetWarningDirection: "below", - widgetWarningThreshold: 80, - widgetRefreshIntervalMs: 60000, - widgetHiddenMetrics: ["Processes", "Uptime"], - }); - repository.updatePreferences({ - serverWidgetOrder: 10, - serverWidgetShowNative: false, - serverWidgetChartMetric: "Storage used", - serverWidgetChartType: "bar", - serverWidgetChartSamples: 45, - serverWidgetShowLegend: false, - serverWidgetHiddenMetrics: ["CPU cores"], - serverWidgetMetricOrder: ["Storage used", "Array state"], - serverWidgetStatLimit: 4, - }); - const preset = repository.createLayoutPreset({ name: "Morning" }); - repository.updateApp(discovered.id, { - widgetEnabled: false, - widgetSize: "compact", - widgetCustomLabel: null, - }); - expect(repository.applyLayoutPreset(preset.id)).toBe(true); - expect(repository.getApps()[0]).toMatchObject({ - widgetEnabled: true, - widgetOrder: 20, - widgetSize: "wide", - widgetCustomLabel: "Morning status", - widgetChartMetric: "Memory", - widgetChartType: "bar", - widgetChartSamples: 30, - widgetShowLegend: false, - widgetMetricStyles: { Memory: "gauge" }, - widgetStatLimit: 3, - widgetWarningMetric: "memory", - widgetWarningDirection: "below", - widgetWarningThreshold: 80, - widgetRefreshIntervalMs: 60000, - widgetHiddenMetrics: ["Processes", "Uptime"], - }); - expect(repository.getPreferences()).toMatchObject({ - serverWidgetOrder: 10, - serverWidgetShowNative: false, - serverWidgetChartMetric: "Storage used", - serverWidgetChartType: "bar", - serverWidgetChartSamples: 45, - serverWidgetShowLegend: false, - serverWidgetHiddenMetrics: ["CPU cores"], - serverWidgetMetricOrder: ["Storage used", "Array state"], - serverWidgetStatLimit: 4, - }); - expect(repository.export().layoutPresets).toHaveLength(1); - repository.close(); - }); -}); diff --git a/src/tests/discovery.test.ts b/src/tests/discovery.test.ts deleted file mode 100644 index f05497b7..00000000 --- a/src/tests/discovery.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - appOpsContainerToDocker, - DiscoveryService, - metricFromAppOps, - parseUnraidTemplate, -} from "../server/integrations/discovery.js"; - -describe("discovery adapters", () => { - it("normalizes AppOps inventory and measured stats without a Docker socket", () => { - const item = { - id: "appops-1", - name: "DockDeck", - state: "running", - health: "healthy", - image: "dockdeck:local", - ports: ["1218:3000", "1218:3000"], - cpu: "1.7%", - ram: "90 MiB / 62 GiB", - metricsStatus: "measured", - }; - expect(appOpsContainerToDocker(item)).toMatchObject({ - Id: "appops-1", - Names: ["/DockDeck"], - State: "running", - Ports: [{ PublicPort: 1218, PrivatePort: 3000, Type: "tcp" }], - }); - expect(metricFromAppOps(item)).toMatchObject({ - statusText: "running · healthy", - cpuPercent: 1.7, - memoryUsageBytes: 90 * 1024 ** 2, - memoryLimitBytes: 62 * 1024 ** 3, - pids: null, - freshness: "fresh", - }); - }); - - it("parses Unraid template WebUI and icon metadata", () => { - const template = parseUnraidTemplate( - "Jellyfinhttp://[IP]:[PORT:8096]", - ); - expect(template).toEqual({ - name: "Jellyfin", - webUi: "http://[IP]:[PORT:8096]", - icon: "https://example.test/icon.png", - }); - }); - - it("provides deterministic mock inventory and graceful optional NPM state", async () => { - const discovery = new DiscoveryService(); - const result = await discovery.discover(true, ["Plex-Media-Server"]); - expect(result.apps.map((app) => app.technicalName)).toContain( - "Plex-Media-Server", - ); - expect( - result.apps.find((app) => app.technicalName === "home-assistant")?.status, - ).toBe("offline"); - expect(result.integrations.find((item) => item.id === "npm")?.state).toBe( - "connected", - ); - expect(discovery.getWidgetMetrics()["Plex-Media-Server"]).toMatchObject({ - cpuPercent: 1.4, - pids: 24, - networkRxBytesPerSecond: 3 * 1024 ** 2, - networkTxBytesPerSecond: 180 * 1024, - freshness: "fresh", - history: [expect.objectContaining({ cpuPercent: 1.4 })], - }); - expect(discovery.getServerWidget()).toMatchObject({ - hostName: "Tower", - containers: 4, - containersRunning: 3, - freshness: "fresh", - native: expect.objectContaining({ - arrayState: "Started", - parityStatus: "healthy", - }), - history: expect.objectContaining({ - "Running containers": [expect.objectContaining({ value: 3 })], - "Storage used": [ - expect.objectContaining({ value: expect.any(Number) }), - ], - "Hottest temperature": [expect.objectContaining({ value: 42 })], - }), - }); - await discovery.discover(true, ["Plex-Media-Server"]); - expect( - discovery.getServerWidget()?.history?.["Running containers"], - ).toHaveLength(2); - }); -}); diff --git a/src/tests/domain.test.ts b/src/tests/domain.test.ts deleted file mode 100644 index 0df04906..00000000 --- a/src/tests/domain.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { DockApp, Favorite } from "../shared/contracts.js"; -import { - favoriteIconUrl, - googleSearchUrl, - inferAppCategoryName, - inferServiceKind, - matchNpmHost, - normalizeUrl, - resolveContainerHost, - resolveUnraidIcon, - resolveUnraidWebUi, - searchItems, - sortByPosition, -} from "../shared/domain.js"; - -const app: DockApp = { - id: "1", - technicalName: "paperless-ngx", - displayName: "Paperless", - image: null, - icon: null, - status: "online", - localUrl: "http://tower.local:8010", - localUrlOverride: null, - externalUrl: null, - externalUrlOverride: null, - visible: true, - widgetEnabled: false, - widgetSize: "standard", - widgetShowSummary: true, - widgetShowMetrics: true, - widgetShowInfrastructure: true, - widgetShowProgress: true, - widgetShowSparkline: true, - widgetChartMetric: "auto", - widgetChartType: "line", - widgetChartSamples: 20, - widgetShowLegend: true, - widgetMetricStyles: {}, - widgetStatLimit: 6, - widgetOrder: 0, - widgetMetricOrder: [], - widgetHiddenMetrics: [], - widgetPrimaryMetric: null, - widgetCustomLabel: null, - widgetWarningMetric: "cpu", - widgetWarningDirection: "above", - widgetWarningThreshold: null, - widgetRefreshIntervalMs: 30000, - categoryId: null, - sortOrder: 10, - detectionSource: "test", - lastChecked: null, - discoveredAt: null, - isNew: false, -}; -const favorite: Favorite = { - id: "f1", - name: "GitHub", - url: "https://github.com", - icon: null, - categoryId: null, - sortOrder: 0, -}; - -describe("URL rules", () => { - it("normalizes host-like values and rejects invalid values", () => { - expect(normalizeUrl("tower.local:8080")).toBe("http://tower.local:8080"); - expect(normalizeUrl("not a url")).toBeNull(); - }); - - it("resolves Unraid port templates against published Docker ports", () => { - expect( - resolveUnraidWebUi("http://[IP]:[PORT:8000]/", "tower.local", [ - { PrivatePort: 8000, PublicPort: 8010 }, - ]), - ).toBe("http://tower.local:8010"); - expect( - resolveUnraidWebUi("http://[IP]:[PORT:8080]/", "192.168.10.150", [ - { PrivatePort: 8075, PublicPort: 8075 }, - { PrivatePort: 8075, PublicPort: 8075 }, - { PrivatePort: 8080 }, - ]), - ).toBe("http://192.168.10.150:8075"); - expect( - resolveContainerHost("192.168.10.150", { - br0: { IPAddress: "192.168.10.3" }, - }), - ).toBe("192.168.10.3"); - }); - - it("serves local Unraid DockerMan icons through DockDeck", () => { - expect( - resolveUnraidIcon( - "/boot/config/plugins/dockerMan/images/media codec-icon.png", - "192.168.10.150", - ), - ).toBe("/api/icons/media%20codec-icon.png"); - expect(resolveUnraidIcon("/plugins/docker/icon.png", "tower.local")).toBe( - "http://tower.local/plugins/docker/icon.png", - ); - }); - - it("matches only an unambiguous NPM hostname", () => { - expect(matchNpmHost("paperless-ngx", ["paperless.home.test"])).toBe( - "https://paperless.home.test", - ); - expect( - matchNpmHost("plex", ["plex.home.test", "plex.example.test"]), - ).toBeNull(); - }); - - it("derives first-party favorite favicons without an external service", () => { - expect(favoriteIconUrl(favorite)).toBe("https://github.com/favicon.ico"); - expect( - favoriteIconUrl({ ...favorite, icon: "https://cdn.test/custom.svg" }), - ).toBe("https://cdn.test/custom.svg"); - }); -}); - -describe("dashboard rules", () => { - it("searches all current app metadata and follows renamed apps", () => { - expect(searchItems("paper", [app], [favorite])).toEqual([ - { - id: "1", - name: "Paperless", - url: app.localUrl, - kind: "app", - detail: "paperless-ngx", - hidden: false, - }, - ]); - expect( - searchItems("paperless-ngx", [{ ...app, visible: false }], [favorite]), - ).toEqual([expect.objectContaining({ name: "Paperless", hidden: true })]); - expect( - searchItems("archive", [{ ...app, displayName: "Archive" }], [favorite]), - ).toEqual([expect.objectContaining({ name: "Archive" })]); - expect(searchItems("git", [app], [favorite])[0]?.name).toBe("GitHub"); - }); - - it("builds an encoded Google fallback and applies stable sort rules", () => { - expect(googleSearchUrl("unraid dashboard")).toBe( - "https://www.google.com/search?q=unraid%20dashboard", - ); - expect( - sortByPosition([ - { name: "B", sortOrder: 0 }, - { name: "A", sortOrder: 0 }, - { name: "C", sortOrder: 5 }, - ]).map((x) => x.name), - ).toEqual(["A", "B", "C"]); - }); - - it("classifies common homelab services for useful widget treatments", () => { - expect(inferServiceKind("AdGuard Home")).toBe("dns"); - expect(inferServiceKind("binhex-plexpass")).toBe("media"); - expect(inferServiceKind("Immich")).toBe("photos"); - expect(inferServiceKind("Glances")).toBe("monitoring"); - expect(inferServiceKind("Ollama")).toBe("ai"); - expect(inferServiceKind("Paperless-ngx")).toBe("documents"); - expect(inferServiceKind("Vaultwarden")).toBe("security"); - }); - - it("suggests canonical categories without guessing unknown services", () => { - const categorized = (technicalName: string) => - inferAppCategoryName({ - ...app, - technicalName, - displayName: technicalName, - }); - expect(categorized("binhex-deluge")).toBe("Downloads"); - expect(categorized("JDownloader2")).toBe("Downloads"); - expect(categorized("Glances")).toBe("Monitoring"); - expect(categorized("NginxProxyManager")).toBe("Network & Admin"); - expect(categorized("organizrv2")).toBe("Tools & Productivity"); - expect(categorized("projectbrain")).toBe("Development & Projects"); - expect(categorized("unknown-personal-service")).toBeNull(); - }); -}); diff --git a/src/tests/integration-environment.test.ts b/src/tests/integration-environment.test.ts deleted file mode 100644 index 8853eb86..00000000 --- a/src/tests/integration-environment.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { IntegrationEnvironmentStore } from "../server/integration-environment.js"; - -const directories: string[] = []; - -afterEach(() => { - delete process.env.PLEX_URL; - delete process.env.PLEX_TOKEN; - for (const directory of directories.splice(0)) - rmSync(directory, { recursive: true, force: true }); -}); - -describe("integration environment store", () => { - it("persists environment-formatted values and reloads them into the process", () => { - const directory = mkdtempSync(join(tmpdir(), "dockdeck-integrations-")); - directories.push(directory); - const path = join(directory, "integrations.env"); - const opaqueValue = "opaque-test-value"; - - new IntegrationEnvironmentStore(path).set({ - PLEX_URL: "http://plex.test", - PLEX_TOKEN: opaqueValue, - }); - - const stored = readFileSync(path, "utf8"); - expect(stored).toContain('PLEX_URL="http://plex.test"'); - expect(stored).toContain(`PLEX_TOKEN="${opaqueValue}"`); - if (process.platform !== "win32") - expect(statSync(path).mode & 0o777).toBe(0o600); - - delete process.env.PLEX_URL; - delete process.env.PLEX_TOKEN; - new IntegrationEnvironmentStore(path); - expect(process.env.PLEX_URL).toBe("http://plex.test"); - expect(process.env.PLEX_TOKEN).toBe(opaqueValue); - }); -}); diff --git a/src/tests/provider-widgets.test.ts b/src/tests/provider-widgets.test.ts deleted file mode 100644 index 1ccbcbdc..00000000 --- a/src/tests/provider-widgets.test.ts +++ /dev/null @@ -1,396 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { DockApp } from "../shared/contracts.js"; -import { - detectProvider, - ProviderWidgetService, -} from "../server/integrations/provider-widgets.js"; - -const app = ( - technicalName: string, - localUrl: string | null = null, -): DockApp => ({ - id: technicalName, - technicalName, - displayName: technicalName, - image: null, - icon: null, - status: "online", - localUrl, - localUrlOverride: null, - externalUrl: null, - externalUrlOverride: null, - visible: true, - widgetEnabled: true, - widgetSize: "standard", - widgetShowSummary: true, - widgetShowMetrics: true, - widgetShowInfrastructure: true, - widgetShowProgress: true, - widgetShowSparkline: true, - widgetChartMetric: "auto", - widgetChartType: "line", - widgetChartSamples: 20, - widgetShowLegend: true, - widgetMetricStyles: {}, - widgetStatLimit: 6, - widgetOrder: 0, - widgetMetricOrder: [], - widgetHiddenMetrics: [], - widgetPrimaryMetric: null, - widgetCustomLabel: null, - widgetWarningMetric: "cpu", - widgetWarningDirection: "above", - widgetWarningThreshold: null, - widgetRefreshIntervalMs: 30000, - categoryId: null, - sortOrder: 0, - detectionSource: "test", - lastChecked: null, - discoveredAt: null, - isNew: false, -}); - -afterEach(() => { - vi.unstubAllEnvs(); - vi.unstubAllGlobals(); -}); - -describe("provider-aware widgets", () => { - it("detects supported providers without guessing unrelated services", () => { - expect(detectProvider("binhex-sonarr")).toBe("arr"); - expect(detectProvider("home-assistant")).toBe("homeAssistant"); - expect(detectProvider("paperless-ngx")).toBe("paperless"); - expect(detectProvider("ollama")).toBe("ollama"); - expect(detectProvider("Glances")).toBe("glances"); - expect(detectProvider("tautulli")).toBe("tautulli"); - expect(detectProvider("mintplexlabs/anythingllm:latest")).toBeNull(); - expect(detectProvider("Immich_PostgreSQL")).toBeNull(); - expect(detectProvider("binhex-plexpass")).toBe("plex"); - expect(detectProvider("Gitea")).toBe("gitea"); - expect(detectProvider("binhex-seerr")).toBe("seerr"); - expect(detectProvider("PortainerCE")).toBe("portainer"); - expect(detectProvider("authentik-worker")).toBeNull(); - expect(detectProvider("tdarr_node")).toBeNull(); - }); - - it("keeps an unconfigured provider disabled and names required env vars", async () => { - const service = new ProviderWidgetService(); - await service.refresh([app("Plex-Media-Server")]); - expect(service.getWidgets()).toEqual({}); - expect(service.getStates()[0]).toMatchObject({ - state: "disabled", - requiredEnvironment: ["PLEX_URL", "PLEX_TOKEN"], - }); - }); - - it("converts AdGuard read-only statistics into functional widget data", async () => { - vi.stubEnv("ADGUARD_URL", "http://adguard.test"); - vi.stubEnv("ADGUARD_USERNAME", "dockdeck"); - vi.stubEnv("ADGUARD_PASSWORD", "secret"); - vi.stubGlobal( - "fetch", - vi.fn( - async () => - new Response( - JSON.stringify({ - num_dns_queries: 200, - num_blocked_filtering: 50, - avg_processing_time: 0.012, - }), - { status: 200, headers: { "content-type": "application/json" } }, - ), - ), - ); - const service = new ProviderWidgetService(); - await service.refresh([app("AdGuard-Home")]); - expect(service.getWidgets()["AdGuard-Home"]).toMatchObject({ - kind: "adguard", - summary: "25% of DNS traffic blocked", - }); - expect(JSON.stringify(service.getStates())).not.toContain("secret"); - }); - - it("uses the discovered app URL for zero-config Ollama insights", async () => { - vi.stubGlobal( - "fetch", - vi.fn(async (input: string | URL | Request) => { - const url = String(input); - return new Response( - JSON.stringify( - url.endsWith("/api/tags") - ? { models: [{ size: 2 * 1024 ** 3 }, { size: 3 * 1024 ** 3 }] } - : { models: [{ name: "phi4" }] }, - ), - { status: 200, headers: { "content-type": "application/json" } }, - ); - }), - ); - const service = new ProviderWidgetService(); - await service.refresh([app("ollama", "http://ollama.test:11434/ui")]); - expect(service.getWidgets().ollama).toMatchObject({ - kind: "ollama", - summary: "1 model loaded", - stats: [ - { label: "Installed", value: "2" }, - { label: "Loaded", value: "1" }, - { label: "Model storage", value: "5.0 GB" }, - ], - }); - expect(service.getStates()[0]).toMatchObject({ - state: "connected", - requiredEnvironment: [], - }); - expect(vi.mocked(fetch).mock.calls[0]?.[0]).toBe( - "http://ollama.test:11434/api/tags", - ); - }); - - it("publishes a capability catalog for rich and tailored widgets", () => { - const service = new ProviderWidgetService(); - const capabilities = service.getCapabilities([ - app("ollama", "http://ollama.test:11434"), - app("JDownloader2", "http://downloads.test"), - app("plain-service", "http://plain.test"), - ]); - expect(capabilities).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - technicalName: "ollama", - source: "provider", - credentialMode: "none", - }), - expect.objectContaining({ - technicalName: "JDownloader2", - source: "provider", - credentialMode: "required", - features: ["Packages", "Progress", "ETA"], - }), - ]), - ); - expect( - capabilities.some((item) => item.technicalName === "plain-service"), - ).toBe(true); - expect( - capabilities.find((item) => item.technicalName === "plain-service"), - ).toMatchObject({ - source: "custom-get", - credentialMode: "optional", - metrics: expect.arrayContaining([ - expect.objectContaining({ label: "Status", group: "health" }), - ]), - }); - }); - - it("publishes domain metrics for recognized self-hosted apps", () => { - const service = new ProviderWidgetService(); - const capabilities = service.getCapabilities([ - app("Komga", "http://komga.test"), - app("GPU-Hot", "http://gpu.test"), - app("MeTube", "http://metube.test"), - app("evidencevault-postgres-1", "http://database.test"), - app("evidencevault-worker-1", "http://worker.test"), - ]); - expect( - capabilities.find((item) => item.technicalName === "Komga")?.features, - ).toEqual(expect.arrayContaining(["Series", "Books", "Unread"])); - expect( - capabilities.find((item) => item.technicalName === "GPU-Hot")?.features, - ).toEqual( - expect.arrayContaining(["GPU temperature", "VRAM used", "Power draw"]), - ); - expect( - capabilities.find((item) => item.technicalName === "MeTube")?.features, - ).toEqual(expect.arrayContaining(["Queued", "Active", "Completed"])); - expect( - capabilities.find( - (item) => item.technicalName === "evidencevault-postgres-1", - )?.features, - ).toEqual(expect.arrayContaining(["Connections", "Transactions", "Locks"])); - expect( - capabilities.find( - (item) => item.technicalName === "evidencevault-worker-1", - )?.features, - ).toEqual( - expect.arrayContaining(["Queued jobs", "Processed", "Last completed"]), - ); - }); - - it("publishes dedicated capability profiles for every previously generic live app", () => { - const names = [ - "blockpilot-autonomous-agent-runtime-1", - "blockpilot-autonomous-api-1", - "blockpilot-autonomous-minecraft-sandbox-1", - "blockpilot-autonomous-viaproxy-1", - "blockpilot-autonomous-web-1", - "blockpilot", - "lumaops", - "chimera", - "inspiring_carson", - "OpenRGB-GUI-Unraid", - "porkfolio", - ]; - const capabilities = new ProviderWidgetService().getCapabilities( - names.map((name) => app(name, `http://${name}.test`)), - ); - expect(capabilities).toHaveLength(names.length); - for (const capability of capabilities) { - expect(capability.features).toHaveLength(6); - expect(capability.features).not.toEqual([ - "Status", - "CPU", - "Memory", - "Ingress", - "Egress", - "Processes", - "Uptime", - ]); - expect( - capability.metrics.every((metric) => metric.description.length > 40), - ).toBe(true); - expect( - capability.metrics.every( - (metric) => metric.defaultStyle && metric.unit, - ), - ).toBe(true); - } - }); - - it("normalizes Glances, Tautulli and Paperless read-only responses", async () => { - vi.stubEnv("TAUTULLI_API_KEY", "opaque-test-value"); - vi.stubEnv("PAPERLESS_TOKEN", "opaque-test-value"); - vi.stubGlobal( - "fetch", - vi.fn(async (input: string | URL | Request) => { - const url = String(input); - const payload = url.includes("/api/4/cpu") - ? { total: 12.4 } - : url.includes("/api/4/mem") - ? { percent: 44.5 } - : url.includes("/api/4/load") - ? { min1: 1.25 } - : url.includes("cmd=get_activity") - ? { - response: { - result: "success", - data: { - stream_count: "2", - stream_count_transcode: "1", - total_bandwidth: "4200", - }, - }, - } - : { - document_count: 1200, - inbox_count: 3, - document_file_size: 4 * 1024 ** 3, - }; - return new Response(JSON.stringify(payload), { - status: 200, - headers: { "content-type": "application/json" }, - }); - }), - ); - const service = new ProviderWidgetService(); - await service.refresh([ - app("Glances", "http://glances.test:61208"), - app("tautulli", "http://tautulli.test:8181"), - app("paperless-ngx", "http://paperless.test:8000"), - ]); - expect(service.getWidgets().Glances).toMatchObject({ - summary: "45% host memory in use", - stats: expect.arrayContaining([ - expect.objectContaining({ label: "Load 1m", value: "1.25" }), - ]), - }); - expect(service.getWidgets().tautulli).toMatchObject({ - summary: "2 active Plex streams", - stats: expect.arrayContaining([ - expect.objectContaining({ label: "Transcodes", value: "1" }), - ]), - }); - expect(service.getWidgets()["paperless-ngx"]).toMatchObject({ - summary: "1.2K documents organized", - stats: expect.arrayContaining([ - expect.objectContaining({ label: "Inbox", value: "3" }), - ]), - }); - }); - - it("accepts app-specific GET-only bridge metrics for restricted providers", async () => { - vi.stubEnv("JDOWNLOADER_METRICS_URL", "http://metrics.test/jdownloader"); - vi.stubEnv("JDOWNLOADER_METRICS_TOKEN", "opaque-test-value"); - vi.stubGlobal( - "fetch", - vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { - expect(init?.method).toBe("GET"); - expect(init?.headers).toMatchObject({ - Authorization: "Bearer opaque-test-value", - }); - return new Response( - JSON.stringify({ - summary: "2 packages downloading", - stats: [ - { label: "Packages", value: 2, tone: "positive" }, - { label: "Progress", value: "61%" }, - { label: "ETA", value: "18 min" }, - ], - }), - { status: 200, headers: { "content-type": "application/json" } }, - ); - }), - ); - const service = new ProviderWidgetService(); - await service.refresh([app("JDownloader2", "http://downloads.test")]); - expect(service.getWidgets().JDownloader2).toMatchObject({ - kind: "jdownloader", - summary: "2 packages downloading", - freshness: "fresh", - stats: [ - { label: "Packages", value: "2", tone: "positive" }, - { label: "Progress", value: "61%" }, - { label: "ETA", value: "18 min" }, - ], - }); - }); - - it("keeps numeric app API history in memory for configurable charts", async () => { - vi.stubEnv("CUSTOM_WIDGET_CHIMERA_URL", "http://metrics.test/chimera"); - let processed = 40; - vi.stubGlobal( - "fetch", - vi.fn(async () => { - processed += 2; - return new Response( - JSON.stringify({ - summary: "Processing is healthy", - stats: [ - { label: "Processed items", value: processed }, - { label: "Last run", value: "just now" }, - ], - }), - { status: 200, headers: { "content-type": "application/json" } }, - ); - }), - ); - const target = app("chimera", "http://chimera.test"); - target.widgetRefreshIntervalMs = 0; - const service = new ProviderWidgetService(); - await service.refresh([target]); - await service.refresh([target]); - expect(service.getWidgets().chimera).toMatchObject({ - stats: expect.arrayContaining([ - expect.objectContaining({ - label: "Processed items", - numericValue: 44, - }), - ]), - history: { - "Processed items": [ - expect.objectContaining({ value: 42 }), - expect.objectContaining({ value: 44 }), - ], - }, - }); - expect(service.getWidgets().chimera.history?.["Last run"]).toBeUndefined(); - }); -}); diff --git a/src/tests/security.test.ts b/src/tests/security.test.ts deleted file mode 100644 index 1b59de93..00000000 --- a/src/tests/security.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { readFileSync } from "node:fs"; -import { describe, expect, it } from "vitest"; - -const discoverySource = readFileSync( - new URL("../server/integrations/discovery.ts", import.meta.url), - "utf8", -); -const apiSource = readFileSync( - new URL("../server/app.ts", import.meta.url), - "utf8", -); -const composeSource = readFileSync( - new URL("../../docker-compose.yml", import.meta.url), - "utf8", -); -const socketProxyComposeSource = readFileSync( - new URL("../../docker-compose.socket-proxy.yml", import.meta.url), - "utf8", -); -const providerSource = readFileSync( - new URL("../server/integrations/provider-widgets.ts", import.meta.url), - "utf8", -); -const serviceWorkerSource = readFileSync( - new URL("../../public/sw.js", import.meta.url), - "utf8", -); -const unraidIconRefreshSource = readFileSync( - new URL("../../deploy/refresh-unraid-icon.sh", import.meta.url), - "utf8", -); -const entrypointSource = readFileSync( - new URL("../../docker-entrypoint.sh", import.meta.url), - "utf8", -); - -describe("read-only security boundary", () => { - it("only calls read-only Docker inventory and stats endpoints", () => { - const dockerCalls = [ - ...discoverySource.matchAll(/\$\{dockerBase\}(\/[^`]+)/g), - ].map((match) => match[1]); - expect(dockerCalls).toContain("/containers/json?all=1"); - expect(dockerCalls).toContain( - "/containers/${encodeURIComponent(container.Id)}/stats?stream=false&one-shot=true", - ); - expect( - dockerCalls.some((path) => - /start|stop|restart|kill|remove|exec|create|update/i.test(path), - ), - ).toBe(false); - }); - - it("does not expose any Docker mutation routes or verbs", () => { - expect(apiSource).not.toMatch(/docker.*(start|stop|restart|delete|exec)/i); - expect(apiSource).not.toMatch( - /\/api\/containers\/.+(start|stop|restart|delete|exec)/i, - ); - }); - - it("ships one branded container while keeping a restricted proxy fallback", () => { - const serviceSection = composeSource.split(/^networks:/m)[0] ?? ""; - expect(serviceSection?.match(/^ {2}[a-z][\w-]+:\s*$/gm)).toEqual([ - " dockdeck:", - ]); - expect(composeSource).toContain("container_name: DockDeck"); - expect(composeSource).toContain("APPOPS_URL:"); - expect(composeSource).toContain("net.unraid.docker.icon:"); - expect(composeSource).not.toContain("/var/run/docker.sock"); - expect(composeSource).toContain( - "/boot/config/plugins/dockerMan/images}:/unraid-icons-source:ro", - ); - expect(composeSource).toContain("/unraid-icons-cache:size=64m,mode=0755"); - expect(entrypointSource).toContain("exec su-exec dockdeck"); - expect(entrypointSource).not.toMatch(/(?:rm|mv)\s+[^\n]*icon_source/); - expect(socketProxyComposeSource).toContain("CONTAINERS: 1"); - expect(socketProxyComposeSource).toContain("POST: 0"); - expect(socketProxyComposeSource).toContain( - "/var/run/docker.sock:/var/run/docker.sock:ro", - ); - expect(composeSource).toContain("DOCKDECK_PORT:-1218"); - expect(composeSource).toContain("DOCKDECK_BIND_ADDRESS:-127.0.0.1"); - expect(socketProxyComposeSource).toContain( - "DOCKDECK_INTERNAL_SUBNET:-172.31.240.0/28", - ); - expect(composeSource).toContain("DOCKDECK_EGRESS_SUBNET:-172.31.240.16/28"); - }); - - it("keeps widget adapters GET-only and excludes API data from PWA caches", () => { - expect(providerSource).toContain('method: "GET"'); - expect(providerSource).not.toMatch( - /method:\s*["'](?:POST|PUT|PATCH|DELETE)/, - ); - expect(serviceWorkerSource).toContain('url.pathname.startsWith("/api/")'); - expect(serviceWorkerSource).not.toMatch(/caches\.put\([^\n]*\/api\//); - }); - - it("refreshes only DockDeck's persistent and active Unraid icon caches", () => { - expect(unraidIconRefreshSource).toContain("/var/lib/docker/unraid/images"); - expect(unraidIconRefreshSource).toContain( - "/usr/local/emhttp/state/plugins/dynamix.docker.manager/images", - ); - expect(unraidIconRefreshSource).toContain( - 'file -b --mime-type "$temporary_icon"', - ); - expect(unraidIconRefreshSource).toContain('= "image/png"'); - expect(unraidIconRefreshSource).toContain("install -m 0644"); - expect(unraidIconRefreshSource).not.toMatch( - /docker\s+(?:start|stop|restart|rm|exec|update)/, - ); - }); -}); diff --git a/src/tests/setup.ts b/src/tests/setup.ts deleted file mode 100644 index c527517f..00000000 --- a/src/tests/setup.ts +++ /dev/null @@ -1 +0,0 @@ -process.env.NODE_ENV = "test"; diff --git a/src/tests/unraid-status.test.ts b/src/tests/unraid-status.test.ts deleted file mode 100644 index d09ed6d1..00000000 --- a/src/tests/unraid-status.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - parseIni, - storageFromDisksIni, -} from "../server/integrations/unraid-status.js"; - -describe("Unraid native status", () => { - it("parses quoted root values and sectioned disk data", () => { - expect(parseIni('mdState="STARTED"\nsbSyncErrs="0"').get("root")).toEqual({ - mdState: "STARTED", - sbSyncErrs: "0", - }); - const storage = storageFromDisksIni(` - [disk1] - name="disk1" - type="Data" - status="DISK_OK" - fsSize="1000" - fsUsed="600" - fsFree="400" - temp="38" - [cache] - name="cache" - type="Cache" - status="DISK_OK" - fsSize="500" - fsUsed="100" - fsFree="400" - temp="42" - `); - expect(storage).toEqual([ - expect.objectContaining({ - name: "Array", - kind: "array", - usedBytes: 600 * 1024, - hottestTemperatureC: 38, - }), - expect.objectContaining({ - name: "cache", - kind: "pool", - freeBytes: 400 * 1024, - hottestTemperatureC: 42, - }), - ]); - }); -}); diff --git a/src/tests/weather-wallpaper.test.ts b/src/tests/weather-wallpaper.test.ts deleted file mode 100644 index 24906467..00000000 --- a/src/tests/weather-wallpaper.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { WallpaperStore } from "../server/wallpaper.js"; -import { WeatherService } from "../server/weather.js"; - -const temporaryDirectories: string[] = []; - -afterEach(() => { - for (const directory of temporaryDirectories.splice(0)) - rmSync(directory, { recursive: true, force: true }); -}); - -describe("WeatherService", () => { - it("returns and caches current Mol weather from Open-Meteo", async () => { - const fetcher = vi.fn(async () => - Promise.resolve( - new Response( - JSON.stringify({ - current: { - temperature_2m: 22.6, - weather_code: 2, - is_day: 1, - time: "2026-07-22T14:30", - }, - }), - { status: 200 }, - ), - ), - ); - const weather = new WeatherService(fetcher as typeof fetch); - - const first = await weather.current("Mol", 51.191, 5.116); - const second = await weather.current("Mol", 51.191, 5.116); - - expect(first).toMatchObject({ - location: "Mol", - temperatureC: 22.6, - weatherCode: 2, - isDay: true, - summary: "Bewolkt", - }); - expect(second).toEqual(first); - expect(fetcher).toHaveBeenCalledTimes(1); - }); -}); - -describe("WallpaperStore", () => { - it("validates, stores, reads and removes a local wallpaper", () => { - const directory = mkdtempSync(join(tmpdir(), "dockdeck-wallpaper-")); - temporaryDirectories.push(directory); - const store = new WallpaperStore(directory); - const png = Buffer.concat([ - Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), - Buffer.from("test-image"), - ]); - - expect(store.save(png).url).toMatch(/^\/api\/wallpaper\?v=/); - expect(store.read()).toMatchObject({ contentType: "image/png", body: png }); - store.remove(); - expect(store.describe()).toBeNull(); - }); - - it("rejects unsupported content", () => { - const directory = mkdtempSync(join(tmpdir(), "dockdeck-wallpaper-")); - temporaryDirectories.push(directory); - expect(() => - new WallpaperStore(directory).save(Buffer.from("text")), - ).toThrow(/PNG-, JPEG- of WebP/); - }); -}); diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts deleted file mode 100644 index 11f02fe2..00000000 --- a/src/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/tests/dockdeck.spec.ts b/tests/dockdeck.spec.ts deleted file mode 100644 index a287f727..00000000 --- a/tests/dockdeck.spec.ts +++ /dev/null @@ -1,524 +0,0 @@ -import { expect, test, type Page } from "@playwright/test"; - -const browserErrors = new WeakMap(); - -test.beforeEach(async ({ page }) => { - const errors: string[] = []; - browserErrors.set(page, errors); - page.on("console", (message) => { - if (message.type() === "error") errors.push(message.text()); - }); - page.on("pageerror", (error) => errors.push(error.message)); -}); - -test.afterEach(async ({ page }) => { - expect(browserErrors.get(page) ?? []).toEqual([]); -}); - -const openSettings = async ( - page: import("@playwright/test").Page, - projectName: string, -) => { - if (["mobile-chromium", "tablet-chromium"].includes(projectName)) { - await page - .getByRole("navigation", { name: "Mobiele navigatie" }) - .getByRole("button", { name: "Instellingen" }) - .click(); - return; - } - await page.getByRole("button", { name: "Instellingen openen" }).click(); -}; - -test("dashboard en Quick Launch vormen de primaire startflow", async ({ - page, - context, -}, testInfo) => { - test.skip( - testInfo.project.name !== "desktop-chromium", - "De desktopflow wordt eenmaal uitgevoerd", - ); - - await page.goto("/"); - await expect(page.getByRole("main")).toBeVisible(); - await expect( - page.getByRole("navigation", { name: "Dashboardnavigatie" }), - ).toBeVisible(); - await expect(page.getByLabel(/graden.*Mol/)).toBeVisible(); - await expect( - page.getByRole("complementary", { - name: "Favorieten en deckstatus", - }), - ).toBeVisible(); - await expect(page.getByText("Live inzicht", { exact: true })).toBeVisible(); - await page.locator(".dashboard-insights-panel > summary").click(); - await expect( - page.getByRole("region", { name: "Servicepanelen" }), - ).toBeVisible(); - - const refreshResponse = page.waitForResponse( - (response) => - response.url().endsWith("/api/discovery/sync") && - response.request().method() === "POST", - ); - await page - .getByRole("button", { name: "Statussen vernieuwen" }) - .first() - .click(); - expect((await refreshResponse).status()).toBe(200); - - await page.keyboard.press("Control+K"); - const search = page.getByLabel("Zoek apps, favorieten of Google"); - await expect(search).toBeFocused(); - await search.fill("Google"); - const favoriteResults = page - .locator(".result-group") - .filter({ has: page.getByRole("heading", { name: "Favorieten" }) }); - await expect( - favoriteResults.locator(".app-icon-image, .app-brand-icon"), - ).toHaveCount(1); - await search.fill("Plex"); - await expect( - page.getByRole("option", { name: /Plex Media Server/ }), - ).toBeVisible(); - - const googlePopupPromise = context.waitForEvent("page"); - await page.getByRole("option", { name: /Zoeken op Google naar/ }).click(); - const googlePopup = await googlePopupPromise; - expect(googlePopup.url()).toContain("google.com/search"); - await googlePopup.close(); -}); - -test("dashboardcategorieën filteren de zichtbare snelkoppelingen", async ({ - page, - request, -}, testInfo) => { - test.skip( - testInfo.project.name !== "desktop-chromium", - "De desktopflow wordt eenmaal uitgevoerd", - ); - - const dashboard = await (await request.get("/api/dashboard")).json(); - const [firstApp, secondApp] = dashboard.apps; - const [firstCategory, secondCategory] = dashboard.categories; - expect(firstApp).toBeTruthy(); - expect(secondApp).toBeTruthy(); - expect(firstCategory).toBeTruthy(); - expect(secondCategory).toBeTruthy(); - await request.patch(`/api/apps/${firstApp.id}`, { - data: { visible: true, categoryId: firstCategory.id }, - }); - await request.patch(`/api/apps/${secondApp.id}`, { - data: { visible: true, categoryId: secondCategory.id }, - }); - - await page.goto("/"); - const categoryButton = page - .locator(".category-tabs button") - .filter({ hasText: firstCategory.name }) - .first(); - await categoryButton.click(); - - await expect(categoryButton).toHaveClass(/active/); - await expect(page.locator(".dashboard-groups .app-section")).toHaveCount(1); - await expect(page.locator(".dashboard-groups .app-section h2")).toContainText( - firstCategory.name, - ); -}); - -test("compact appbeheer opent een toegankelijke detail-drawer", async ({ - page, -}, testInfo) => { - test.skip( - testInfo.project.name !== "desktop-chromium", - "De desktopflow wordt eenmaal uitgevoerd", - ); - - await page.goto("/"); - await openSettings(page, testInfo.project.name); - await expect( - page.getByRole("heading", { name: "App Beheer", exact: true }), - ).toBeVisible(); - await page.getByLabel("Apps zoeken").fill("Plex"); - const row = page.getByRole("article").filter({ hasText: "Plex" }); - await expect(row).toBeVisible(); - const visibilitySwitch = row.getByRole("switch"); - const knob = visibilitySwitch.locator(".visibility-toggle-track > span"); - const initialVisibility = await visibilitySwitch.getAttribute("aria-checked"); - const initialKnob = await knob.boundingBox(); - await visibilitySwitch.click(); - await expect(visibilitySwitch).toHaveAttribute( - "aria-checked", - initialVisibility === "true" ? "false" : "true", - ); - await expect - .poll(async () => { - const movedKnob = await knob.boundingBox(); - return Math.abs((movedKnob?.x ?? 0) - (initialKnob?.x ?? 0)); - }) - .toBeGreaterThan(18); - await visibilitySwitch.click(); - await row.getByRole("button", { name: /Bewerken/ }).click(); - - const drawer = page.getByRole("dialog", { name: /Plex/ }); - await expect(drawer).toBeVisible(); - await expect(drawer.getByLabel("Weergavenaam")).toHaveValue(/Plex/); - await expect( - drawer.getByText("Automatisch ontdekt", { exact: true }), - ).toBeVisible(); - await drawer.getByRole("button", { name: "Sluiten" }).click(); - await expect(drawer).toBeHidden(); - await expect(row.getByRole("button", { name: /Bewerken/ })).toBeFocused(); -}); - -test("weergavevoorkeuren en geavanceerde read-only status blijven werken", async ({ - page, - request, -}, testInfo) => { - test.skip( - testInfo.project.name !== "desktop-chromium", - "De desktopflow wordt eenmaal uitgevoerd", - ); - - await request.patch("/api/preferences", { - data: { reducedMotion: false, pollingIntervalMs: 30000 }, - }); - await page.goto("/"); - await openSettings(page, testInfo.project.name); - const settingsNavigation = page.getByRole("navigation", { - name: "Instellingensecties", - }); - - await settingsNavigation.getByRole("button", { name: "Weergave" }).click(); - await page.getByRole("button", { name: /Haven/ }).click(); - await expect(page.locator("html")).toHaveAttribute("data-theme", "harbor"); - await page.locator(".appearance-advanced > summary").click(); - await page.getByRole("switch", { name: "Verminderde beweging" }).click(); - await page.getByLabel("Verversingsinterval").selectOption("60000"); - await expect(page.locator("html")).toHaveAttribute( - "data-reduced-motion", - "true", - ); - await page.reload(); - await expect(page.locator("html")).toHaveAttribute("data-theme", "harbor"); - await expect(page.locator("html")).toHaveAttribute( - "data-reduced-motion", - "true", - ); - - await openSettings(page, testInfo.project.name); - await page.getByLabel("Instellingen zoeken").fill("diagnostiek"); - await expect( - page - .getByRole("navigation", { name: "Instellingensecties" }) - .getByRole("button", { name: "Algemeen" }), - ).toBeVisible(); - await expect( - page - .getByRole("navigation", { name: "Instellingensecties" }) - .getByRole("button", { name: "Apps" }), - ).toHaveCount(0); - await page - .getByRole("navigation", { name: "Instellingensecties" }) - .getByRole("button", { name: "Algemeen" }) - .click(); - await expect( - page.getByRole("region", { name: "Paneelstudio" }), - ).toBeVisible(); - await page.getByRole("button", { name: "Systeemstatus" }).click(); - await page.getByRole("button", { name: "Diagnostiek uitvoeren" }).click(); - await expect(page.locator(".diagnostics-result")).toContainText("Database"); -}); - -test("achtergrondpresets en een geüploade wallpaper wijzigen de zichtbare canvas", async ({ - page, - request, -}, testInfo) => { - test.skip( - testInfo.project.name !== "desktop-chromium", - "De visuele achtergrondflow wordt eenmaal uitgevoerd", - ); - - await request.delete("/api/wallpaper"); - await request.patch("/api/preferences", { - data: { - backgroundStyle: "aurora", - wallpaperFit: "contain", - wallpaperPositionX: 50, - wallpaperPositionY: 50, - wallpaperZoom: 100, - }, - }); - await page.goto("/"); - - const selectBackground = async ( - name: "Aurora" | "Blauwdruk" | "Minimaal", - ) => { - await openSettings(page, testInfo.project.name); - await page - .getByRole("navigation", { name: "Instellingensecties" }) - .getByRole("button", { name: "Weergave" }) - .click(); - await page.locator(".appearance-advanced > summary").click(); - await page.getByRole("button", { name: new RegExp(`^${name}`) }).click(); - await expect(page.locator("html")).toHaveAttribute( - "data-background", - { Aurora: "aurora", Blauwdruk: "grid", Minimaal: "minimal" }[name], - ); - await page - .locator(".settings-topbar nav") - .getByRole("button", { name: "Dashboard" }) - .click(); - return page.locator(".dashboard-canvas").evaluate((element) => ({ - image: getComputedStyle(element).backgroundImage, - size: getComputedStyle(element).backgroundSize, - })); - }; - - const aurora = await selectBackground("Aurora"); - const grid = await selectBackground("Blauwdruk"); - const minimal = await selectBackground("Minimaal"); - expect(new Set([aurora.image, grid.image, minimal.image]).size).toBe(3); - expect(grid.size).toContain("40px"); - - await openSettings(page, testInfo.project.name); - await page - .getByRole("navigation", { name: "Instellingensecties" }) - .getByRole("button", { name: "Weergave" }) - .click(); - await page.locator(".appearance-advanced > summary").click(); - await page - .locator('.wallpaper-settings input[type="file"]') - .setInputFiles("public/dockdeck-icon.png"); - await expect( - page.locator(".wallpaper-editor-preview .wallpaper-preview-image"), - ).toBeVisible(); - await page.getByRole("button", { name: "Vullend" }).click(); - await page.getByLabel("Horizontale wallpaperfocus").fill("32"); - await page.getByLabel("Verticale wallpaperfocus").fill("68"); - await page.getByLabel("Wallpaperzoom").fill("120"); - await page.getByRole("button", { name: "Toepassen" }).click(); - await page - .locator(".settings-topbar nav") - .getByRole("button", { name: "Dashboard" }) - .click(); - await expect(page.locator(".dashboard")).toHaveClass(/has-wallpaper/); - const wallpaperStage = page.locator(".dashboard-wallpaper-stage"); - const wallpaperImage = wallpaperStage.locator(".dashboard-wallpaper-image"); - const wallpaperAmbient = wallpaperStage.locator( - ".dashboard-wallpaper-ambient", - ); - await expect(wallpaperStage).toBeVisible(); - await expect(wallpaperImage).toHaveAttribute("src", /\/api\/wallpaper/); - await expect(wallpaperAmbient).toHaveAttribute("src", /\/api\/wallpaper/); - await expect - .poll(() => - wallpaperImage.evaluate((element) => ({ - fit: getComputedStyle(element).objectFit, - position: getComputedStyle(element).objectPosition, - transform: getComputedStyle(element).transform, - })), - ) - .toMatchObject({ - fit: "cover", - position: "32% 68%", - }); - const stageBeforeScroll = await wallpaperStage.boundingBox(); - const canvasBackground = await page - .locator(".dashboard-canvas") - .evaluate((element) => ({ - color: getComputedStyle(element).backgroundColor, - image: getComputedStyle(element).backgroundImage, - })); - expect(stageBeforeScroll?.x).toBe(0); - expect(stageBeforeScroll?.width).toBe(testInfo.project.use.viewport!.width); - expect(stageBeforeScroll?.y).toBe(72); - expect(stageBeforeScroll?.height).toBe( - testInfo.project.use.viewport!.height - 72, - ); - expect(canvasBackground.image).toBe("none"); - expect(canvasBackground.color).toBe("rgba(0, 0, 0, 0)"); - await page.evaluate(() => window.scrollTo(0, 900)); - await page.waitForTimeout(100); - const stageAfterScroll = await wallpaperStage.boundingBox(); - expect(stageBeforeScroll?.height).toBeLessThanOrEqual( - testInfo.project.use.viewport!.height, - ); - expect(stageAfterScroll?.y).toBeLessThanOrEqual(stageBeforeScroll!.y); - expect(stageAfterScroll?.y).toBeGreaterThanOrEqual(71); - - await request.delete("/api/wallpaper"); - await request.patch("/api/preferences", { - data: { - backgroundStyle: "aurora", - wallpaperFit: "contain", - wallpaperPositionX: 50, - wallpaperPositionY: 50, - wallpaperZoom: 100, - }, - }); -}); - -test("dashboard en instellingen blijven bruikbaar zonder horizontale overflow", async ({ - page, -}, testInfo) => { - await page.goto("/"); - const compact = ["mobile-chromium", "tablet-chromium"].includes( - testInfo.project.name, - ); - await expect( - page.getByRole("navigation", { - name: compact ? "Mobiele navigatie" : "Dashboardnavigatie", - }), - ).toBeVisible(); - await expect( - page.getByLabel( - testInfo.project.name === "mobile-chromium" - ? "Zoek apps en favorieten mobiel" - : "Zoek apps, favorieten of Google", - ), - ).toBeVisible(); - if (testInfo.project.name === "ultrawide-chromium") { - const widgetDock = page.locator(".dashboard-insights-panel"); - await expect(widgetDock).toHaveAttribute("open", ""); - const dockBox = await widgetDock.boundingBox(); - const groupsBox = await page.locator(".dashboard-groups").boundingBox(); - expect(dockBox).not.toBeNull(); - expect(groupsBox).not.toBeNull(); - expect(dockBox!.x).toBeGreaterThan(groupsBox!.x); - } - await openSettings(page, testInfo.project.name); - await expect( - page.getByRole("heading", { name: "App Beheer", exact: true }), - ).toBeVisible(); - const overflow = await page.evaluate( - () => document.documentElement.scrollWidth - window.innerWidth, - ); - expect(overflow).toBeLessThanOrEqual(1); -}); - -test("legt de finale Stitch-compositie op de canonieke viewports vast", async ({ - page, - request, -}, testInfo) => { - const dashboardResponse = await request.get("/api/dashboard"); - const dashboard = (await dashboardResponse.json()) as { - apps: Array<{ id: string; technicalName: string }>; - categories: Array<{ id: string; name: string }>; - favorites: Array<{ id: string }>; - }; - let smartHome = dashboard.categories.find( - (category) => category.name === "Smart Home", - ); - if (!smartHome) { - const response = await request.post("/api/categories", { - data: { name: "Smart Home", icon: "House", sortOrder: 20 }, - }); - smartHome = (await response.json()) as { id: string; name: string }; - } - const media = dashboard.categories.find( - (category) => category.name === "Media", - ); - const tools = dashboard.categories.find( - (category) => category.name === "Tools", - ); - for (const app of dashboard.apps) { - const isHome = /home/i.test(app.technicalName); - const isMedia = /plex|immich/i.test(app.technicalName); - await request.patch(`/api/apps/${app.id}`, { - data: { - visible: !/paperless/i.test(app.technicalName), - categoryId: isHome - ? smartHome.id - : isMedia - ? (media?.id ?? null) - : (tools?.id ?? null), - }, - }); - } - if (dashboard.favorites.length === 0) { - for (const [index, favorite] of [ - ["Google", "https://google.com"], - ["YouTube", "https://youtube.com"], - ["GitHub", "https://github.com"], - ].entries()) { - await request.post("/api/favorites", { - data: { - name: favorite[0], - url: favorite[1], - icon: null, - categoryId: null, - sortOrder: index, - }, - }); - } - } - await request.patch("/api/preferences", { data: { theme: "dark" } }); - await page.goto("/"); - await expect(page.getByRole("main")).toBeVisible(); - const inferredAppIcons = page.locator(".app-tile .app-brand-icon"); - expect(await inferredAppIcons.count()).toBeGreaterThan(0); - await expect(page.locator(".app-monogram")).toHaveCount(0); - - const screenshotRoot = "docs/design/dockdeck-canon-v1/implementation"; - if (testInfo.project.name === "desktop-chromium") { - await page.screenshot({ - path: `${screenshotRoot}/dashboard-dark-desktop-1440x900.png`, - }); - await page.keyboard.press("Control+K"); - await page.getByLabel("Zoek apps, favorieten of Google").fill("Plex"); - await page.screenshot({ - path: `${screenshotRoot}/quick-launch-dark-desktop-1440x900.png`, - }); - await page.keyboard.press("Escape"); - await openSettings(page, testInfo.project.name); - await page.screenshot({ - path: `${screenshotRoot}/settings-apps-dark-desktop-1440x900.png`, - }); - await page - .getByRole("button", { name: /Bewerken/ }) - .first() - .click(); - await page.screenshot({ - path: `${screenshotRoot}/app-drawer-dark-desktop-1440x900.png`, - }); - await page - .getByRole("dialog") - .getByRole("button", { name: "Sluiten", exact: true }) - .click(); - await request.patch("/api/preferences", { data: { theme: "light" } }); - await page.goto("/"); - await page.screenshot({ - path: `${screenshotRoot}/dashboard-light-desktop-1440x900.png`, - }); - await page.keyboard.press("Control+K"); - await page.getByLabel("Zoek apps, favorieten of Google").fill("Plex"); - await page.screenshot({ - path: `${screenshotRoot}/quick-launch-light-desktop-1440x900.png`, - }); - await page.keyboard.press("Escape"); - await openSettings(page, testInfo.project.name); - await page.screenshot({ - path: `${screenshotRoot}/settings-apps-light-desktop-1440x900.png`, - }); - } else if (testInfo.project.name === "mobile-chromium") { - await page.screenshot({ - path: `${screenshotRoot}/dashboard-dark-mobile-390x844.png`, - }); - await openSettings(page, testInfo.project.name); - await page - .getByRole("button", { name: /Bewerken/ }) - .first() - .click(); - await page.screenshot({ - path: `${screenshotRoot}/app-drawer-mobile-390x844.png`, - }); - } else if (testInfo.project.name === "tablet-chromium") { - await page.screenshot({ - path: `${screenshotRoot}/dashboard-dark-tablet-768x1024.png`, - }); - } else { - await page.screenshot({ - path: `${screenshotRoot}/dashboard-dark-ultrawide-3440x1440.png`, - }); - } -}); diff --git a/tsconfig.app.json b/tsconfig.app.json deleted file mode 100644 index af0ae811..00000000 --- a/tsconfig.app.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "useDefineForClassFields": true, - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "allowJs": false, - "skipLibCheck": true, - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "strict": true, - "forceConsistentCasingInFileNames": true, - "module": "ESNext", - "moduleResolution": "Bundler", - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "jsx": "react-jsx", - "types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"] - }, - "include": ["src/client", "src/shared", "src/tests"] -} diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index 1ffef600..00000000 --- a/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "files": [], - "references": [ - { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } - ] -} diff --git a/tsconfig.node.json b/tsconfig.node.json deleted file mode 100644 index 4ce5af63..00000000 --- a/tsconfig.node.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "lib": ["ES2022"], - "module": "NodeNext", - "moduleResolution": "NodeNext", - "strict": true, - "skipLibCheck": true, - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "noEmit": true, - "types": ["node", "vitest/globals"] - }, - "include": [ - "src/server", - "src/shared", - "scripts", - "vite.config.ts", - "vitest.config.ts", - "playwright.config.ts" - ] -} diff --git a/vite.config.ts b/vite.config.ts deleted file mode 100644 index dca71781..00000000 --- a/vite.config.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { defineConfig } from "vite"; -import react from "@vitejs/plugin-react"; - -export default defineConfig({ - plugins: [react()], - server: { - port: Number(process.env.VITE_PORT ?? 5173), - proxy: { - "/api": `http://127.0.0.1:${process.env.VITE_API_PORT ?? "3000"}`, - }, - }, - build: { outDir: "dist/client" }, -}); diff --git a/vitest.config.ts b/vitest.config.ts deleted file mode 100644 index 86e1fc34..00000000 --- a/vitest.config.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { defineConfig } from "vitest/config"; -import react from "@vitejs/plugin-react"; - -export default defineConfig({ - plugins: [react()], - test: { - include: ["src/**/*.test.ts", "src/**/*.test.tsx"], - environment: "node", - setupFiles: ["./src/tests/setup.ts"], - coverage: { reporter: ["text", "html"] }, - }, -});