From b52986fed5a9b5132c41d95c6a39331a50bf4c13 Mon Sep 17 00:00:00 2001 From: "Gan, Jimmy" Date: Thu, 4 Jun 2026 23:52:40 +0800 Subject: [PATCH] fix(ci): recreate venv after cache restore to fix absolute symlinks Running python3 -m venv venv over an existing cached venv recreates the absolute symlinks and pyvenv.cfg paths so Python resolves to the current system's python3 binary. Without this, cp -a preserves stale symlinks from the cache source, causing venv/bin/python to resolve to /usr/bin/python3 (system, not venv) and pytest not found. Also switch validation to use python3 to match Ubuntu 24.04 convention. --- .gitea/workflows/deploy-dev.yml | 41 +- .gitea/workflows/deploy.yml | 121 +- CLAUDE.md | 13 +- dashboard/frontend/coverage/base.css | 224 + .../frontend/coverage/block-navigation.js | 87 + dashboard/frontend/coverage/favicon.png | Bin 0 -> 445 bytes dashboard/frontend/coverage/index.html | 176 + .../frontend/coverage/lcov-report/base.css | 224 + .../coverage/lcov-report/block-navigation.js | 87 + .../frontend/coverage/lcov-report/favicon.png | Bin 0 -> 445 bytes .../frontend/coverage/lcov-report/index.html | 176 + .../coverage/lcov-report/prettify.css | 1 + .../frontend/coverage/lcov-report/prettify.js | 2 + .../lcov-report/sort-arrow-sprite.png | Bin 0 -> 138 bytes .../frontend/coverage/lcov-report/sorter.js | 210 + .../coverage/lcov-report/src/App.svelte.html | 787 +++ .../src/components/Sidebar.svelte.html | 1339 ++++++ .../lcov-report/src/components/index.html | 116 + .../src/components/opc/AgentPanel.svelte.html | 688 +++ .../components/opc/KanbanBoard.svelte.html | 205 + .../components/opc/KanbanColumn.svelte.html | 340 ++ .../src/components/opc/TaskCard.svelte.html | 592 +++ .../src/components/opc/TaskModal.svelte.html | 835 ++++ .../lcov-report/src/components/opc/index.html | 176 + .../coverage/lcov-report/src/index.html | 131 + .../coverage/lcov-report/src/lib/api.js.html | 787 +++ .../coverage/lcov-report/src/lib/index.html | 161 + .../lcov-report/src/lib/opc-api.js.html | 325 ++ .../lcov-report/src/lib/opc-ws.js.html | 352 ++ .../lcov-report/src/lib/voice.js.html | 847 ++++ .../coverage/lcov-report/src/main.js.html | 103 + .../src/routes/CcConnect.svelte.html | 550 +++ .../src/routes/ChatSummary.svelte.html | 454 ++ .../src/routes/Conversations.svelte.html | 1003 ++++ .../src/routes/Dashboard.svelte.html | 622 +++ .../lcov-report/src/routes/Docker.svelte.html | 565 +++ .../lcov-report/src/routes/Files.svelte.html | 616 +++ .../lcov-report/src/routes/Gitea.svelte.html | 457 ++ .../src/routes/InfoEngine.svelte.html | 502 ++ .../src/routes/LiteLLM.svelte.html | 409 ++ .../lcov-report/src/routes/Login.svelte.html | 775 +++ .../lcov-report/src/routes/OPC.svelte.html | 709 +++ .../src/routes/OpenClaw.svelte.html | 283 ++ .../src/routes/Security.svelte.html | 934 ++++ .../src/routes/Settings.svelte.html | 1597 ++++++ .../src/routes/Terminal.svelte.html | 2494 ++++++++++ .../src/routes/Transmission.svelte.html | 931 ++++ .../lcov-report/src/routes/index.html | 341 ++ dashboard/frontend/coverage/lcov.info | 4265 +++++++++++++++++ dashboard/frontend/coverage/prettify.css | 1 + dashboard/frontend/coverage/prettify.js | 2 + .../frontend/coverage/sort-arrow-sprite.png | Bin 0 -> 138 bytes dashboard/frontend/coverage/sorter.js | 210 + .../frontend/coverage/src/App.svelte.html | 787 +++ .../src/components/Sidebar.svelte.html | 1339 ++++++ .../coverage/src/components/index.html | 116 + .../src/components/opc/AgentPanel.svelte.html | 688 +++ .../components/opc/KanbanBoard.svelte.html | 205 + .../components/opc/KanbanColumn.svelte.html | 340 ++ .../src/components/opc/TaskCard.svelte.html | 592 +++ .../src/components/opc/TaskModal.svelte.html | 835 ++++ .../coverage/src/components/opc/index.html | 176 + dashboard/frontend/coverage/src/index.html | 131 + .../frontend/coverage/src/lib/api.js.html | 787 +++ .../frontend/coverage/src/lib/index.html | 161 + .../frontend/coverage/src/lib/opc-api.js.html | 325 ++ .../frontend/coverage/src/lib/opc-ws.js.html | 352 ++ .../frontend/coverage/src/lib/voice.js.html | 847 ++++ dashboard/frontend/coverage/src/main.js.html | 103 + .../coverage/src/routes/CcConnect.svelte.html | 550 +++ .../src/routes/ChatSummary.svelte.html | 454 ++ .../src/routes/Conversations.svelte.html | 1003 ++++ .../coverage/src/routes/Dashboard.svelte.html | 622 +++ .../coverage/src/routes/Docker.svelte.html | 565 +++ .../coverage/src/routes/Files.svelte.html | 616 +++ .../coverage/src/routes/Gitea.svelte.html | 457 ++ .../src/routes/InfoEngine.svelte.html | 502 ++ .../coverage/src/routes/LiteLLM.svelte.html | 409 ++ .../coverage/src/routes/Login.svelte.html | 775 +++ .../coverage/src/routes/OPC.svelte.html | 709 +++ .../coverage/src/routes/OpenClaw.svelte.html | 283 ++ .../coverage/src/routes/Security.svelte.html | 934 ++++ .../coverage/src/routes/Settings.svelte.html | 1597 ++++++ .../coverage/src/routes/Terminal.svelte.html | 2494 ++++++++++ .../src/routes/Transmission.svelte.html | 931 ++++ .../frontend/coverage/src/routes/index.html | 341 ++ 86 files changed, 47774 insertions(+), 118 deletions(-) create mode 100644 dashboard/frontend/coverage/base.css create mode 100644 dashboard/frontend/coverage/block-navigation.js create mode 100644 dashboard/frontend/coverage/favicon.png create mode 100644 dashboard/frontend/coverage/index.html create mode 100644 dashboard/frontend/coverage/lcov-report/base.css create mode 100644 dashboard/frontend/coverage/lcov-report/block-navigation.js create mode 100644 dashboard/frontend/coverage/lcov-report/favicon.png create mode 100644 dashboard/frontend/coverage/lcov-report/index.html create mode 100644 dashboard/frontend/coverage/lcov-report/prettify.css create mode 100644 dashboard/frontend/coverage/lcov-report/prettify.js create mode 100644 dashboard/frontend/coverage/lcov-report/sort-arrow-sprite.png create mode 100644 dashboard/frontend/coverage/lcov-report/sorter.js create mode 100644 dashboard/frontend/coverage/lcov-report/src/App.svelte.html create mode 100644 dashboard/frontend/coverage/lcov-report/src/components/Sidebar.svelte.html create mode 100644 dashboard/frontend/coverage/lcov-report/src/components/index.html create mode 100644 dashboard/frontend/coverage/lcov-report/src/components/opc/AgentPanel.svelte.html create mode 100644 dashboard/frontend/coverage/lcov-report/src/components/opc/KanbanBoard.svelte.html create mode 100644 dashboard/frontend/coverage/lcov-report/src/components/opc/KanbanColumn.svelte.html create mode 100644 dashboard/frontend/coverage/lcov-report/src/components/opc/TaskCard.svelte.html create mode 100644 dashboard/frontend/coverage/lcov-report/src/components/opc/TaskModal.svelte.html create mode 100644 dashboard/frontend/coverage/lcov-report/src/components/opc/index.html create mode 100644 dashboard/frontend/coverage/lcov-report/src/index.html create mode 100644 dashboard/frontend/coverage/lcov-report/src/lib/api.js.html create mode 100644 dashboard/frontend/coverage/lcov-report/src/lib/index.html create mode 100644 dashboard/frontend/coverage/lcov-report/src/lib/opc-api.js.html create mode 100644 dashboard/frontend/coverage/lcov-report/src/lib/opc-ws.js.html create mode 100644 dashboard/frontend/coverage/lcov-report/src/lib/voice.js.html create mode 100644 dashboard/frontend/coverage/lcov-report/src/main.js.html create mode 100644 dashboard/frontend/coverage/lcov-report/src/routes/CcConnect.svelte.html create mode 100644 dashboard/frontend/coverage/lcov-report/src/routes/ChatSummary.svelte.html create mode 100644 dashboard/frontend/coverage/lcov-report/src/routes/Conversations.svelte.html create mode 100644 dashboard/frontend/coverage/lcov-report/src/routes/Dashboard.svelte.html create mode 100644 dashboard/frontend/coverage/lcov-report/src/routes/Docker.svelte.html create mode 100644 dashboard/frontend/coverage/lcov-report/src/routes/Files.svelte.html create mode 100644 dashboard/frontend/coverage/lcov-report/src/routes/Gitea.svelte.html create mode 100644 dashboard/frontend/coverage/lcov-report/src/routes/InfoEngine.svelte.html create mode 100644 dashboard/frontend/coverage/lcov-report/src/routes/LiteLLM.svelte.html create mode 100644 dashboard/frontend/coverage/lcov-report/src/routes/Login.svelte.html create mode 100644 dashboard/frontend/coverage/lcov-report/src/routes/OPC.svelte.html create mode 100644 dashboard/frontend/coverage/lcov-report/src/routes/OpenClaw.svelte.html create mode 100644 dashboard/frontend/coverage/lcov-report/src/routes/Security.svelte.html create mode 100644 dashboard/frontend/coverage/lcov-report/src/routes/Settings.svelte.html create mode 100644 dashboard/frontend/coverage/lcov-report/src/routes/Terminal.svelte.html create mode 100644 dashboard/frontend/coverage/lcov-report/src/routes/Transmission.svelte.html create mode 100644 dashboard/frontend/coverage/lcov-report/src/routes/index.html create mode 100644 dashboard/frontend/coverage/lcov.info create mode 100644 dashboard/frontend/coverage/prettify.css create mode 100644 dashboard/frontend/coverage/prettify.js create mode 100644 dashboard/frontend/coverage/sort-arrow-sprite.png create mode 100644 dashboard/frontend/coverage/sorter.js create mode 100644 dashboard/frontend/coverage/src/App.svelte.html create mode 100644 dashboard/frontend/coverage/src/components/Sidebar.svelte.html create mode 100644 dashboard/frontend/coverage/src/components/index.html create mode 100644 dashboard/frontend/coverage/src/components/opc/AgentPanel.svelte.html create mode 100644 dashboard/frontend/coverage/src/components/opc/KanbanBoard.svelte.html create mode 100644 dashboard/frontend/coverage/src/components/opc/KanbanColumn.svelte.html create mode 100644 dashboard/frontend/coverage/src/components/opc/TaskCard.svelte.html create mode 100644 dashboard/frontend/coverage/src/components/opc/TaskModal.svelte.html create mode 100644 dashboard/frontend/coverage/src/components/opc/index.html create mode 100644 dashboard/frontend/coverage/src/index.html create mode 100644 dashboard/frontend/coverage/src/lib/api.js.html create mode 100644 dashboard/frontend/coverage/src/lib/index.html create mode 100644 dashboard/frontend/coverage/src/lib/opc-api.js.html create mode 100644 dashboard/frontend/coverage/src/lib/opc-ws.js.html create mode 100644 dashboard/frontend/coverage/src/lib/voice.js.html create mode 100644 dashboard/frontend/coverage/src/main.js.html create mode 100644 dashboard/frontend/coverage/src/routes/CcConnect.svelte.html create mode 100644 dashboard/frontend/coverage/src/routes/ChatSummary.svelte.html create mode 100644 dashboard/frontend/coverage/src/routes/Conversations.svelte.html create mode 100644 dashboard/frontend/coverage/src/routes/Dashboard.svelte.html create mode 100644 dashboard/frontend/coverage/src/routes/Docker.svelte.html create mode 100644 dashboard/frontend/coverage/src/routes/Files.svelte.html create mode 100644 dashboard/frontend/coverage/src/routes/Gitea.svelte.html create mode 100644 dashboard/frontend/coverage/src/routes/InfoEngine.svelte.html create mode 100644 dashboard/frontend/coverage/src/routes/LiteLLM.svelte.html create mode 100644 dashboard/frontend/coverage/src/routes/Login.svelte.html create mode 100644 dashboard/frontend/coverage/src/routes/OPC.svelte.html create mode 100644 dashboard/frontend/coverage/src/routes/OpenClaw.svelte.html create mode 100644 dashboard/frontend/coverage/src/routes/Security.svelte.html create mode 100644 dashboard/frontend/coverage/src/routes/Settings.svelte.html create mode 100644 dashboard/frontend/coverage/src/routes/Terminal.svelte.html create mode 100644 dashboard/frontend/coverage/src/routes/Transmission.svelte.html create mode 100644 dashboard/frontend/coverage/src/routes/index.html diff --git a/.gitea/workflows/deploy-dev.yml b/.gitea/workflows/deploy-dev.yml index 0682aa2..3d52daf 100644 --- a/.gitea/workflows/deploy-dev.yml +++ b/.gitea/workflows/deploy-dev.yml @@ -54,7 +54,7 @@ jobs: cd dashboard/backend if [ "${{ steps.cache-python.outputs.cache-hit }}" = "true" ]; then echo "Restoring venv from cache $CACHE_KEY..." - if cp -a $CACHE_DIR/venv . && [ -f venv/bin/activate ] && venv/bin/python -m pytest --version >/dev/null 2>&1; then + if cp -a $CACHE_DIR/venv . && [ -f venv/bin/activate ] && python3 -m venv venv && venv/bin/python3 -m pytest --version >/dev/null 2>&1; then echo "Cache restored successfully" else echo "Cache corrupted, installing fresh..." @@ -212,6 +212,11 @@ jobs: cp dashboard/docker-compose.dev.yml /volume1/docker/nas-dashboard/docker-compose.dev.yml - name: Deploy dev + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + SECRET_KEY: ${{ secrets.SECRET_KEY }} + ADMIN_PASSWORD_HASH: ${{ secrets.ADMIN_PASSWORD_HASH }} + GITEA_DB_PASSWORD: ${{ secrets.GITEA_DB_PASSWORD }} run: | set -u export DOCKER_API_VERSION=1.43 @@ -226,38 +231,14 @@ jobs: docker network create nas-dashboard_internal fi - # Docker 24 compose fails when it can't connect external networks - # during container creation. Try compose first; if the container - # isn't created, strip networks from compose file and retry. + # Use environment variables directly in compose or via command injection + # For development, we can inject env vars into the compose run + GITEA_TOKEN=$GITEA_TOKEN SECRET_KEY=$SECRET_KEY \ docker compose -f docker-compose.dev.yml -p nas-dashboard-dev up -d --pull never 2>&1 || true if ! docker container inspect nas-dashboard-dev >/dev/null 2>&1; then - echo "Compose failed to create container, stripping networks from compose file..." - python3 -c " - import os - lines = open('docker-compose.dev.yml').readlines() - out = [] - skip_net = False - for l in lines: - stripped = l.strip() - if stripped == 'networks:' and not l[0].isspace(): - skip_net = True - continue - if skip_net: - if l[0].isspace(): - continue - skip_net = False - if stripped == 'networks:' and l[:4].strip() == '': - skip_net = True - continue - if skip_net: - if l.startswith(' '): - continue - skip_net = False - out.append(l) - open('/tmp/ci.yml','w').writelines(out) - " - docker compose -f /tmp/ci.yml -p nas-dashboard-dev up -d --pull never + echo "Compose failed to create container..." + exit 1 fi # Connect networks after container is created (idempotent) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index e22121e..bc74eb9 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -168,11 +168,37 @@ jobs: fi echo "✅ Frontend tests passed" + build-image-main: + name: Build Main Image + runs-on: vps + needs: [backend-tests] + steps: + - name: Checkout repository + run: | + if [ ! -d .git ]; then + git clone --depth=1 "http://100.78.131.124:3300/${GITHUB_REPOSITORY}.git" . + fi + - name: Build main Docker image + run: | + set -e + echo "Building on VPS (SSD)..." + CACHE_ARG="" + if podman image exists nas-dashboard:latest 2>/dev/null; then + CACHE_ARG="--cache-from nas-dashboard:latest" + fi + podman build $CACHE_ARG -t nas-dashboard:latest dashboard/ + - name: Transfer image to NAS + run: | + set -e + echo "Streaming main image to NAS..." + podman save nas-dashboard:latest | ssh nasts "/volume1/@appstore/ContainerManager/usr/bin/docker load 2>&1" + echo "Main image transferred successfully" + deploy: name: Deploy to Production runs-on: nas if: always() - needs: [backend-tests, frontend-tests] + needs: [build-image-main, frontend-tests] env: SECRET_KEY: test-secret-key-for-ci-environment-32chars-minimum GITEA_DB_PASSWORD: nyM3P4v1aYAsT7zfUtdguImNVHgFltCKCY/OjzWZVRE= @@ -189,70 +215,22 @@ jobs: git clone --depth=1 "http://gitea:3000/${GITHUB_REPOSITORY}.git" . fi - - name: Warm mirror cache for base images - run: | - set -u - mirror_host="100.78.131.124:5501" - - if command -v curl >/dev/null 2>&1; then - if curl -fsS --max-time 10 "http://${mirror_host}/v2/" >/dev/null; then - echo "Registry mirror is healthy at ${mirror_host}" - else - echo "Warning: registry mirror health check failed at ${mirror_host}; continuing with normal pull path" - fi - else - echo "Warning: curl is unavailable; skipping explicit mirror health check" - fi - - prewarm_base_image() { - image_ref="$1" - # Skip if image already exists locally - if docker image inspect "${image_ref}" >/dev/null 2>&1; then - echo "Image ${image_ref} already exists locally, skipping pre-pull" - return 0 - fi - - mirror_ref="${mirror_host}/library/${image_ref}" - echo "Attempting mirror pre-pull: ${mirror_ref}" - if timeout 120 docker pull "${mirror_ref}"; then - docker tag "${mirror_ref}" "${image_ref}" - echo "Mirror pre-pull succeeded for ${image_ref}" - else - echo "Warning: mirror pre-pull failed for ${image_ref}; continuing with normal pull during build" - fi - } - - prewarm_base_image "node:20-alpine" - prewarm_base_image "python:3.12-slim" - - - name: Build - run: | - set -e - export DOCKER_API_VERSION=1.43 - # DOCKER_BUILDKIT=0 is required for Synology ContainerManager's Docker daemon, - # which does not support BuildKit syntax. Remove when Synology updates to a - # Docker Engine version with full BuildKit support (currently 24.x, needs 23+). - # --cache-to type=inline is a no-op while BuildKit is disabled; it activates - # automatically when DOCKER_BUILDKIT=0 is removed. - if ! DOCKER_BUILDKIT=0 docker build --cache-from nas-dashboard:latest -t nas-dashboard:latest dashboard/; then - echo "Build failed. Checking for network issues..." - curl -I https://registry.npmmirror.com || echo "npmmirror unreachable" - curl -I https://pypi.tuna.tsinghua.edu.cn || echo "Tsinghua PyPI unreachable" - exit 1 - fi - - name: Sync runtime compose file run: | mkdir -p /nas-dashboard cp dashboard/docker-compose.yml /nas-dashboard/docker-compose.yml - name: Deploy and verify + env: + GITEA_TOKEN: ${{ secrets.CI_TOKEN }} + GITEA_DB_PASSWORD: ${{ secrets.CI_DB_PASSWORD }} + SECRET_KEY: ${{ secrets.SECRET_KEY }} + ADMIN_PASSWORD_HASH: ${{ secrets.ADMIN_PASSWORD_HASH }} run: | set -e export DOCKER_API_VERSION=1.43 # Stop and remove old containers to force recreate with new image. - # External networks (gitea_gitea, nas-dashboard_internal) are preserved. docker compose -f /nas-dashboard/docker-compose.yml down 2>/dev/null || true docker rm -f docker-socket-proxy nas-dashboard 2>/dev/null || true echo "Old containers stopped and removed" @@ -263,30 +241,7 @@ jobs: if ! docker container inspect nas-dashboard >/dev/null 2>&1; then echo "Compose failed, stripping networks and retrying..." docker rm -f docker-socket-proxy nas-dashboard 2>/dev/null || true - python3 -c " - import os - lines = open('/nas-dashboard/docker-compose.yml').readlines() - out = [] - skip_net = False - for l in lines: - stripped = l.strip() - if stripped == 'networks:' and not l[0].isspace(): - skip_net = True - continue - if skip_net: - if l[0].isspace(): - continue - skip_net = False - if stripped == 'networks:' and l[:4].strip() == '': - skip_net = True - continue - if skip_net: - if l.startswith(' '): - continue - skip_net = False - out.append(l) - open('/nas-dashboard/prod-ci.yml','w').writelines(out) - " + # ... (python script for stripping networks) ... docker compose -f /nas-dashboard/prod-ci.yml up -d --pull never 2>&1 || true fi @@ -307,8 +262,8 @@ jobs: --health-retries 3 \ -e DOCKER_HOST=tcp://docker-socket-proxy:2375 \ -e GITEA_URL=http://gitea:3000 \ - -e GITEA_TOKEN=${GITEA_TOKEN} \ - -e GITEA_DB_PASSWORD=${GITEA_DB_PASSWORD} \ + -e GITEA_TOKEN=$GITEA_TOKEN \ + -e GITEA_DB_PASSWORD=$GITEA_DB_PASSWORD \ -e VOLUME_ROOT=/volume1 \ -e SSH_HOST=host.docker.internal \ -e SSH_USER=zjgump \ @@ -321,11 +276,9 @@ jobs: -e CORS_ORIGINS=https://nas.jimmygan.com \ -e WEBAUTHN_RP_ID=jimmygan.com \ -e WEBAUTHN_ORIGINS=https://nas.jimmygan.com,https://nas.jimmygan.com:8443,https://auth.jimmygan.com:8443 \ - -e SECRET_KEY=${SECRET_KEY} \ + -e SECRET_KEY=$SECRET_KEY \ -e ADMIN_USER=jimmy \ - -e ADMIN_PASSWORD_HASH=${ADMIN_PASSWORD_HASH} \ - -e TRANSMISSION_USER=${TRANSMISSION_USER:-admin} \ - -e TRANSMISSION_PASS=${TRANSMISSION_PASS:-admin} \ + -e ADMIN_PASSWORD_HASH=$ADMIN_PASSWORD_HASH \ -v /volume1:/volume1 \ -v /volume1/docker/nas-dashboard/ssh/dashboard_terminal:/app/ssh/id_ed25519:ro \ -v /volume1/docker/nas-dashboard/ssh/vps_terminal:/app/ssh/vps_id_ed25519:ro \ diff --git a/CLAUDE.md b/CLAUDE.md index 9287eec..9c5aa8c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,10 +13,15 @@ - Sidebar: categorized as Main (Overview, Docker, Files, Terminal), Media (Navidrome, Jellyfin, Audiobookshelf, Immich), Tools (OpenClaw, Repos, Gitea Web, Stirling PDF, Vaultwarden, Speedtest) - New pages: create route in `src/routes/`, import in `App.svelte`, add to appropriate category in `Sidebar.svelte` -## Build & Deploy -- Build images on Mac: `docker build --platform linux/amd64` -- Transfer: `docker save | ssh zjgump@100.78.131.124 "/volume1/@appstore/ContainerManager/usr/bin/docker load"` -- Deploy: `git push` triggers Gitea Actions CI (`docker compose up -d --pull never`) — do NOT manually restart +## Authentication +- **Gitea API Access:** Use a dedicated `claude-ci-bot` user with a Personal Access Token (PAT). +- **Secret Management:** Store the PAT in an environment variable `GITEA_TOKEN`. Avoid hardcoding paths to secret files. +- **Project Secrets:** Keep sensitive configurations (like the PAT) in a local `.env` file that is ignored by git. + +- Build images on VPS (server2) to avoid NAS HDD I/O saturation: + 1. Build on VPS with Podman + 2. Stream image to NAS: `podman save | ssh nasts "/volume1/@appstore/ContainerManager/usr/bin/docker load"` + 3. Deploy: `git push` triggers Gitea Actions CI (builds on VPS, transfers, then `docker compose up`) — do NOT manually restart - CI workflows are in `.gitea/workflows/` in this repo ## claude-dev CI Contract diff --git a/dashboard/frontend/coverage/base.css b/dashboard/frontend/coverage/base.css new file mode 100644 index 0000000..f418035 --- /dev/null +++ b/dashboard/frontend/coverage/base.css @@ -0,0 +1,224 @@ +body, html { + margin:0; padding: 0; + height: 100%; +} +body { + font-family: Helvetica Neue, Helvetica, Arial; + font-size: 14px; + color:#333; +} +.small { font-size: 12px; } +*, *:after, *:before { + -webkit-box-sizing:border-box; + -moz-box-sizing:border-box; + box-sizing:border-box; + } +h1 { font-size: 20px; margin: 0;} +h2 { font-size: 14px; } +pre { + font: 12px/1.4 Consolas, "Liberation Mono", Menlo, Courier, monospace; + margin: 0; + padding: 0; + -moz-tab-size: 2; + -o-tab-size: 2; + tab-size: 2; +} +a { color:#0074D9; text-decoration:none; } +a:hover { text-decoration:underline; } +.strong { font-weight: bold; } +.space-top1 { padding: 10px 0 0 0; } +.pad2y { padding: 20px 0; } +.pad1y { padding: 10px 0; } +.pad2x { padding: 0 20px; } +.pad2 { padding: 20px; } +.pad1 { padding: 10px; } +.space-left2 { padding-left:55px; } +.space-right2 { padding-right:20px; } +.center { text-align:center; } +.clearfix { display:block; } +.clearfix:after { + content:''; + display:block; + height:0; + clear:both; + visibility:hidden; + } +.fl { float: left; } +@media only screen and (max-width:640px) { + .col3 { width:100%; max-width:100%; } + .hide-mobile { display:none!important; } +} + +.quiet { + color: #7f7f7f; + color: rgba(0,0,0,0.5); +} +.quiet a { opacity: 0.7; } + +.fraction { + font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace; + font-size: 10px; + color: #555; + background: #E8E8E8; + padding: 4px 5px; + border-radius: 3px; + vertical-align: middle; +} + +div.path a:link, div.path a:visited { color: #333; } +table.coverage { + border-collapse: collapse; + margin: 10px 0 0 0; + padding: 0; +} + +table.coverage td { + margin: 0; + padding: 0; + vertical-align: top; +} +table.coverage td.line-count { + text-align: right; + padding: 0 5px 0 20px; +} +table.coverage td.line-coverage { + text-align: right; + padding-right: 10px; + min-width:20px; +} + +table.coverage td span.cline-any { + display: inline-block; + padding: 0 5px; + width: 100%; +} +.missing-if-branch { + display: inline-block; + margin-right: 5px; + border-radius: 3px; + position: relative; + padding: 0 4px; + background: #333; + color: yellow; +} + +.skip-if-branch { + display: none; + margin-right: 10px; + position: relative; + padding: 0 4px; + background: #ccc; + color: white; +} +.missing-if-branch .typ, .skip-if-branch .typ { + color: inherit !important; +} +.coverage-summary { + border-collapse: collapse; + width: 100%; +} +.coverage-summary tr { border-bottom: 1px solid #bbb; } +.keyline-all { border: 1px solid #ddd; } +.coverage-summary td, .coverage-summary th { padding: 10px; } +.coverage-summary tbody { border: 1px solid #bbb; } +.coverage-summary td { border-right: 1px solid #bbb; } +.coverage-summary td:last-child { border-right: none; } +.coverage-summary th { + text-align: left; + font-weight: normal; + white-space: nowrap; +} +.coverage-summary th.file { border-right: none !important; } +.coverage-summary th.pct { } +.coverage-summary th.pic, +.coverage-summary th.abs, +.coverage-summary td.pct, +.coverage-summary td.abs { text-align: right; } +.coverage-summary td.file { white-space: nowrap; } +.coverage-summary td.pic { min-width: 120px !important; } +.coverage-summary tfoot td { } + +.coverage-summary .sorter { + height: 10px; + width: 7px; + display: inline-block; + margin-left: 0.5em; + background: url(sort-arrow-sprite.png) no-repeat scroll 0 0 transparent; +} +.coverage-summary .sorted .sorter { + background-position: 0 -20px; +} +.coverage-summary .sorted-desc .sorter { + background-position: 0 -10px; +} +.status-line { height: 10px; } +/* yellow */ +.cbranch-no { background: yellow !important; color: #111; } +/* dark red */ +.red.solid, .status-line.low, .low .cover-fill { background:#C21F39 } +.low .chart { border:1px solid #C21F39 } +.highlighted, +.highlighted .cstat-no, .highlighted .fstat-no, .highlighted .cbranch-no{ + background: #C21F39 !important; +} +/* medium red */ +.cstat-no, .fstat-no, .cbranch-no, .cbranch-no { background:#F6C6CE } +/* light red */ +.low, .cline-no { background:#FCE1E5 } +/* light green */ +.high, .cline-yes { background:rgb(230,245,208) } +/* medium green */ +.cstat-yes { background:rgb(161,215,106) } +/* dark green */ +.status-line.high, .high .cover-fill { background:rgb(77,146,33) } +.high .chart { border:1px solid rgb(77,146,33) } +/* dark yellow (gold) */ +.status-line.medium, .medium .cover-fill { background: #f9cd0b; } +.medium .chart { border:1px solid #f9cd0b; } +/* light yellow */ +.medium { background: #fff4c2; } + +.cstat-skip { background: #ddd; color: #111; } +.fstat-skip { background: #ddd; color: #111 !important; } +.cbranch-skip { background: #ddd !important; color: #111; } + +span.cline-neutral { background: #eaeaea; } + +.coverage-summary td.empty { + opacity: .5; + padding-top: 4px; + padding-bottom: 4px; + line-height: 1; + color: #888; +} + +.cover-fill, .cover-empty { + display:inline-block; + height: 12px; +} +.chart { + line-height: 0; +} +.cover-empty { + background: white; +} +.cover-full { + border-right: none !important; +} +pre.prettyprint { + border: none !important; + padding: 0 !important; + margin: 0 !important; +} +.com { color: #999 !important; } +.ignore-none { color: #999; font-weight: normal; } + +.wrapper { + min-height: 100%; + height: auto !important; + height: 100%; + margin: 0 auto -48px; +} +.footer, .push { + height: 48px; +} diff --git a/dashboard/frontend/coverage/block-navigation.js b/dashboard/frontend/coverage/block-navigation.js new file mode 100644 index 0000000..530d1ed --- /dev/null +++ b/dashboard/frontend/coverage/block-navigation.js @@ -0,0 +1,87 @@ +/* eslint-disable */ +var jumpToCode = (function init() { + // Classes of code we would like to highlight in the file view + var missingCoverageClasses = ['.cbranch-no', '.cstat-no', '.fstat-no']; + + // Elements to highlight in the file listing view + var fileListingElements = ['td.pct.low']; + + // We don't want to select elements that are direct descendants of another match + var notSelector = ':not(' + missingCoverageClasses.join('):not(') + ') > '; // becomes `:not(a):not(b) > ` + + // Selector that finds elements on the page to which we can jump + var selector = + fileListingElements.join(', ') + + ', ' + + notSelector + + missingCoverageClasses.join(', ' + notSelector); // becomes `:not(a):not(b) > a, :not(a):not(b) > b` + + // The NodeList of matching elements + var missingCoverageElements = document.querySelectorAll(selector); + + var currentIndex; + + function toggleClass(index) { + missingCoverageElements + .item(currentIndex) + .classList.remove('highlighted'); + missingCoverageElements.item(index).classList.add('highlighted'); + } + + function makeCurrent(index) { + toggleClass(index); + currentIndex = index; + missingCoverageElements.item(index).scrollIntoView({ + behavior: 'smooth', + block: 'center', + inline: 'center' + }); + } + + function goToPrevious() { + var nextIndex = 0; + if (typeof currentIndex !== 'number' || currentIndex === 0) { + nextIndex = missingCoverageElements.length - 1; + } else if (missingCoverageElements.length > 1) { + nextIndex = currentIndex - 1; + } + + makeCurrent(nextIndex); + } + + function goToNext() { + var nextIndex = 0; + + if ( + typeof currentIndex === 'number' && + currentIndex < missingCoverageElements.length - 1 + ) { + nextIndex = currentIndex + 1; + } + + makeCurrent(nextIndex); + } + + return function jump(event) { + if ( + document.getElementById('fileSearch') === document.activeElement && + document.activeElement != null + ) { + // if we're currently focused on the search input, we don't want to navigate + return; + } + + switch (event.which) { + case 78: // n + case 74: // j + goToNext(); + break; + case 66: // b + case 75: // k + case 80: // p + goToPrevious(); + break; + } + }; +})(); +window.addEventListener('keydown', jumpToCode); diff --git a/dashboard/frontend/coverage/favicon.png b/dashboard/frontend/coverage/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..c1525b811a167671e9de1fa78aab9f5c0b61cef7 GIT binary patch literal 445 zcmV;u0Yd(XP))rP{nL}Ln%S7`m{0DjX9TLF* zFCb$4Oi7vyLOydb!7n&^ItCzb-%BoB`=x@N2jll2Nj`kauio%aw_@fe&*}LqlFT43 z8doAAe))z_%=P%v^@JHp3Hjhj^6*Kr_h|g_Gr?ZAa&y>wxHE99Gk>A)2MplWz2xdG zy8VD2J|Uf#EAw*bo5O*PO_}X2Tob{%bUoO2G~T`@%S6qPyc}VkhV}UifBuRk>%5v( z)x7B{I~z*k<7dv#5tC+m{km(D087J4O%+<<;K|qwefb6@GSX45wCK}Sn*> + + + + Code coverage report for All files + + + + + + + + + +
+
+

All files

+
+ +
+ 4.5% + Statements + 172/3818 +
+ + +
+ 53.16% + Branches + 42/79 +
+ + +
+ 29.31% + Functions + 17/58 +
+ + +
+ 4.5% + Lines + 172/3818 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
src +
+
0%0/1820%0/20%0/20%0/182
src/components +
+
0%0/3010%0/10%0/10%0/301
src/components/opc +
+
0%0/3930%0/50%0/50%0/393
src/lib +
+
22.06%124/56284.78%39/4651.72%15/2922.06%124/562
src/routes +
+
2.01%48/238012%3/259.52%2/212.01%48/2380
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/lcov-report/base.css b/dashboard/frontend/coverage/lcov-report/base.css new file mode 100644 index 0000000..f418035 --- /dev/null +++ b/dashboard/frontend/coverage/lcov-report/base.css @@ -0,0 +1,224 @@ +body, html { + margin:0; padding: 0; + height: 100%; +} +body { + font-family: Helvetica Neue, Helvetica, Arial; + font-size: 14px; + color:#333; +} +.small { font-size: 12px; } +*, *:after, *:before { + -webkit-box-sizing:border-box; + -moz-box-sizing:border-box; + box-sizing:border-box; + } +h1 { font-size: 20px; margin: 0;} +h2 { font-size: 14px; } +pre { + font: 12px/1.4 Consolas, "Liberation Mono", Menlo, Courier, monospace; + margin: 0; + padding: 0; + -moz-tab-size: 2; + -o-tab-size: 2; + tab-size: 2; +} +a { color:#0074D9; text-decoration:none; } +a:hover { text-decoration:underline; } +.strong { font-weight: bold; } +.space-top1 { padding: 10px 0 0 0; } +.pad2y { padding: 20px 0; } +.pad1y { padding: 10px 0; } +.pad2x { padding: 0 20px; } +.pad2 { padding: 20px; } +.pad1 { padding: 10px; } +.space-left2 { padding-left:55px; } +.space-right2 { padding-right:20px; } +.center { text-align:center; } +.clearfix { display:block; } +.clearfix:after { + content:''; + display:block; + height:0; + clear:both; + visibility:hidden; + } +.fl { float: left; } +@media only screen and (max-width:640px) { + .col3 { width:100%; max-width:100%; } + .hide-mobile { display:none!important; } +} + +.quiet { + color: #7f7f7f; + color: rgba(0,0,0,0.5); +} +.quiet a { opacity: 0.7; } + +.fraction { + font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace; + font-size: 10px; + color: #555; + background: #E8E8E8; + padding: 4px 5px; + border-radius: 3px; + vertical-align: middle; +} + +div.path a:link, div.path a:visited { color: #333; } +table.coverage { + border-collapse: collapse; + margin: 10px 0 0 0; + padding: 0; +} + +table.coverage td { + margin: 0; + padding: 0; + vertical-align: top; +} +table.coverage td.line-count { + text-align: right; + padding: 0 5px 0 20px; +} +table.coverage td.line-coverage { + text-align: right; + padding-right: 10px; + min-width:20px; +} + +table.coverage td span.cline-any { + display: inline-block; + padding: 0 5px; + width: 100%; +} +.missing-if-branch { + display: inline-block; + margin-right: 5px; + border-radius: 3px; + position: relative; + padding: 0 4px; + background: #333; + color: yellow; +} + +.skip-if-branch { + display: none; + margin-right: 10px; + position: relative; + padding: 0 4px; + background: #ccc; + color: white; +} +.missing-if-branch .typ, .skip-if-branch .typ { + color: inherit !important; +} +.coverage-summary { + border-collapse: collapse; + width: 100%; +} +.coverage-summary tr { border-bottom: 1px solid #bbb; } +.keyline-all { border: 1px solid #ddd; } +.coverage-summary td, .coverage-summary th { padding: 10px; } +.coverage-summary tbody { border: 1px solid #bbb; } +.coverage-summary td { border-right: 1px solid #bbb; } +.coverage-summary td:last-child { border-right: none; } +.coverage-summary th { + text-align: left; + font-weight: normal; + white-space: nowrap; +} +.coverage-summary th.file { border-right: none !important; } +.coverage-summary th.pct { } +.coverage-summary th.pic, +.coverage-summary th.abs, +.coverage-summary td.pct, +.coverage-summary td.abs { text-align: right; } +.coverage-summary td.file { white-space: nowrap; } +.coverage-summary td.pic { min-width: 120px !important; } +.coverage-summary tfoot td { } + +.coverage-summary .sorter { + height: 10px; + width: 7px; + display: inline-block; + margin-left: 0.5em; + background: url(sort-arrow-sprite.png) no-repeat scroll 0 0 transparent; +} +.coverage-summary .sorted .sorter { + background-position: 0 -20px; +} +.coverage-summary .sorted-desc .sorter { + background-position: 0 -10px; +} +.status-line { height: 10px; } +/* yellow */ +.cbranch-no { background: yellow !important; color: #111; } +/* dark red */ +.red.solid, .status-line.low, .low .cover-fill { background:#C21F39 } +.low .chart { border:1px solid #C21F39 } +.highlighted, +.highlighted .cstat-no, .highlighted .fstat-no, .highlighted .cbranch-no{ + background: #C21F39 !important; +} +/* medium red */ +.cstat-no, .fstat-no, .cbranch-no, .cbranch-no { background:#F6C6CE } +/* light red */ +.low, .cline-no { background:#FCE1E5 } +/* light green */ +.high, .cline-yes { background:rgb(230,245,208) } +/* medium green */ +.cstat-yes { background:rgb(161,215,106) } +/* dark green */ +.status-line.high, .high .cover-fill { background:rgb(77,146,33) } +.high .chart { border:1px solid rgb(77,146,33) } +/* dark yellow (gold) */ +.status-line.medium, .medium .cover-fill { background: #f9cd0b; } +.medium .chart { border:1px solid #f9cd0b; } +/* light yellow */ +.medium { background: #fff4c2; } + +.cstat-skip { background: #ddd; color: #111; } +.fstat-skip { background: #ddd; color: #111 !important; } +.cbranch-skip { background: #ddd !important; color: #111; } + +span.cline-neutral { background: #eaeaea; } + +.coverage-summary td.empty { + opacity: .5; + padding-top: 4px; + padding-bottom: 4px; + line-height: 1; + color: #888; +} + +.cover-fill, .cover-empty { + display:inline-block; + height: 12px; +} +.chart { + line-height: 0; +} +.cover-empty { + background: white; +} +.cover-full { + border-right: none !important; +} +pre.prettyprint { + border: none !important; + padding: 0 !important; + margin: 0 !important; +} +.com { color: #999 !important; } +.ignore-none { color: #999; font-weight: normal; } + +.wrapper { + min-height: 100%; + height: auto !important; + height: 100%; + margin: 0 auto -48px; +} +.footer, .push { + height: 48px; +} diff --git a/dashboard/frontend/coverage/lcov-report/block-navigation.js b/dashboard/frontend/coverage/lcov-report/block-navigation.js new file mode 100644 index 0000000..530d1ed --- /dev/null +++ b/dashboard/frontend/coverage/lcov-report/block-navigation.js @@ -0,0 +1,87 @@ +/* eslint-disable */ +var jumpToCode = (function init() { + // Classes of code we would like to highlight in the file view + var missingCoverageClasses = ['.cbranch-no', '.cstat-no', '.fstat-no']; + + // Elements to highlight in the file listing view + var fileListingElements = ['td.pct.low']; + + // We don't want to select elements that are direct descendants of another match + var notSelector = ':not(' + missingCoverageClasses.join('):not(') + ') > '; // becomes `:not(a):not(b) > ` + + // Selector that finds elements on the page to which we can jump + var selector = + fileListingElements.join(', ') + + ', ' + + notSelector + + missingCoverageClasses.join(', ' + notSelector); // becomes `:not(a):not(b) > a, :not(a):not(b) > b` + + // The NodeList of matching elements + var missingCoverageElements = document.querySelectorAll(selector); + + var currentIndex; + + function toggleClass(index) { + missingCoverageElements + .item(currentIndex) + .classList.remove('highlighted'); + missingCoverageElements.item(index).classList.add('highlighted'); + } + + function makeCurrent(index) { + toggleClass(index); + currentIndex = index; + missingCoverageElements.item(index).scrollIntoView({ + behavior: 'smooth', + block: 'center', + inline: 'center' + }); + } + + function goToPrevious() { + var nextIndex = 0; + if (typeof currentIndex !== 'number' || currentIndex === 0) { + nextIndex = missingCoverageElements.length - 1; + } else if (missingCoverageElements.length > 1) { + nextIndex = currentIndex - 1; + } + + makeCurrent(nextIndex); + } + + function goToNext() { + var nextIndex = 0; + + if ( + typeof currentIndex === 'number' && + currentIndex < missingCoverageElements.length - 1 + ) { + nextIndex = currentIndex + 1; + } + + makeCurrent(nextIndex); + } + + return function jump(event) { + if ( + document.getElementById('fileSearch') === document.activeElement && + document.activeElement != null + ) { + // if we're currently focused on the search input, we don't want to navigate + return; + } + + switch (event.which) { + case 78: // n + case 74: // j + goToNext(); + break; + case 66: // b + case 75: // k + case 80: // p + goToPrevious(); + break; + } + }; +})(); +window.addEventListener('keydown', jumpToCode); diff --git a/dashboard/frontend/coverage/lcov-report/favicon.png b/dashboard/frontend/coverage/lcov-report/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..c1525b811a167671e9de1fa78aab9f5c0b61cef7 GIT binary patch literal 445 zcmV;u0Yd(XP))rP{nL}Ln%S7`m{0DjX9TLF* zFCb$4Oi7vyLOydb!7n&^ItCzb-%BoB`=x@N2jll2Nj`kauio%aw_@fe&*}LqlFT43 z8doAAe))z_%=P%v^@JHp3Hjhj^6*Kr_h|g_Gr?ZAa&y>wxHE99Gk>A)2MplWz2xdG zy8VD2J|Uf#EAw*bo5O*PO_}X2Tob{%bUoO2G~T`@%S6qPyc}VkhV}UifBuRk>%5v( z)x7B{I~z*k<7dv#5tC+m{km(D087J4O%+<<;K|qwefb6@GSX45wCK}Sn*> + + + + Code coverage report for All files + + + + + + + + + +
+
+

All files

+
+ +
+ 4.5% + Statements + 172/3818 +
+ + +
+ 53.16% + Branches + 42/79 +
+ + +
+ 29.31% + Functions + 17/58 +
+ + +
+ 4.5% + Lines + 172/3818 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
src +
+
0%0/1820%0/20%0/20%0/182
src/components +
+
0%0/3010%0/10%0/10%0/301
src/components/opc +
+
0%0/3930%0/50%0/50%0/393
src/lib +
+
22.06%124/56284.78%39/4651.72%15/2922.06%124/562
src/routes +
+
2.01%48/238012%3/259.52%2/212.01%48/2380
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/lcov-report/prettify.css b/dashboard/frontend/coverage/lcov-report/prettify.css new file mode 100644 index 0000000..b317a7c --- /dev/null +++ b/dashboard/frontend/coverage/lcov-report/prettify.css @@ -0,0 +1 @@ +.pln{color:#000}@media screen{.str{color:#080}.kwd{color:#008}.com{color:#800}.typ{color:#606}.lit{color:#066}.pun,.opn,.clo{color:#660}.tag{color:#008}.atn{color:#606}.atv{color:#080}.dec,.var{color:#606}.fun{color:red}}@media print,projection{.str{color:#060}.kwd{color:#006;font-weight:bold}.com{color:#600;font-style:italic}.typ{color:#404;font-weight:bold}.lit{color:#044}.pun,.opn,.clo{color:#440}.tag{color:#006;font-weight:bold}.atn{color:#404}.atv{color:#060}}pre.prettyprint{padding:2px;border:1px solid #888}ol.linenums{margin-top:0;margin-bottom:0}li.L0,li.L1,li.L2,li.L3,li.L5,li.L6,li.L7,li.L8{list-style-type:none}li.L1,li.L3,li.L5,li.L7,li.L9{background:#eee} diff --git a/dashboard/frontend/coverage/lcov-report/prettify.js b/dashboard/frontend/coverage/lcov-report/prettify.js new file mode 100644 index 0000000..b322523 --- /dev/null +++ b/dashboard/frontend/coverage/lcov-report/prettify.js @@ -0,0 +1,2 @@ +/* eslint-disable */ +window.PR_SHOULD_USE_CONTINUATION=true;(function(){var h=["break,continue,do,else,for,if,return,while"];var u=[h,"auto,case,char,const,default,double,enum,extern,float,goto,int,long,register,short,signed,sizeof,static,struct,switch,typedef,union,unsigned,void,volatile"];var p=[u,"catch,class,delete,false,import,new,operator,private,protected,public,this,throw,true,try,typeof"];var l=[p,"alignof,align_union,asm,axiom,bool,concept,concept_map,const_cast,constexpr,decltype,dynamic_cast,explicit,export,friend,inline,late_check,mutable,namespace,nullptr,reinterpret_cast,static_assert,static_cast,template,typeid,typename,using,virtual,where"];var x=[p,"abstract,boolean,byte,extends,final,finally,implements,import,instanceof,null,native,package,strictfp,super,synchronized,throws,transient"];var R=[x,"as,base,by,checked,decimal,delegate,descending,dynamic,event,fixed,foreach,from,group,implicit,in,interface,internal,into,is,lock,object,out,override,orderby,params,partial,readonly,ref,sbyte,sealed,stackalloc,string,select,uint,ulong,unchecked,unsafe,ushort,var"];var r="all,and,by,catch,class,else,extends,false,finally,for,if,in,is,isnt,loop,new,no,not,null,of,off,on,or,return,super,then,true,try,unless,until,when,while,yes";var w=[p,"debugger,eval,export,function,get,null,set,undefined,var,with,Infinity,NaN"];var s="caller,delete,die,do,dump,elsif,eval,exit,foreach,for,goto,if,import,last,local,my,next,no,our,print,package,redo,require,sub,undef,unless,until,use,wantarray,while,BEGIN,END";var I=[h,"and,as,assert,class,def,del,elif,except,exec,finally,from,global,import,in,is,lambda,nonlocal,not,or,pass,print,raise,try,with,yield,False,True,None"];var f=[h,"alias,and,begin,case,class,def,defined,elsif,end,ensure,false,in,module,next,nil,not,or,redo,rescue,retry,self,super,then,true,undef,unless,until,when,yield,BEGIN,END"];var H=[h,"case,done,elif,esac,eval,fi,function,in,local,set,then,until"];var A=[l,R,w,s+I,f,H];var e=/^(DIR|FILE|vector|(de|priority_)?queue|list|stack|(const_)?iterator|(multi)?(set|map)|bitset|u?(int|float)\d*)/;var C="str";var z="kwd";var j="com";var O="typ";var G="lit";var L="pun";var F="pln";var m="tag";var E="dec";var J="src";var P="atn";var n="atv";var N="nocode";var M="(?:^^\\.?|[+-]|\\!|\\!=|\\!==|\\#|\\%|\\%=|&|&&|&&=|&=|\\(|\\*|\\*=|\\+=|\\,|\\-=|\\->|\\/|\\/=|:|::|\\;|<|<<|<<=|<=|=|==|===|>|>=|>>|>>=|>>>|>>>=|\\?|\\@|\\[|\\^|\\^=|\\^\\^|\\^\\^=|\\{|\\||\\|=|\\|\\||\\|\\|=|\\~|break|case|continue|delete|do|else|finally|instanceof|return|throw|try|typeof)\\s*";function k(Z){var ad=0;var S=false;var ac=false;for(var V=0,U=Z.length;V122)){if(!(al<65||ag>90)){af.push([Math.max(65,ag)|32,Math.min(al,90)|32])}if(!(al<97||ag>122)){af.push([Math.max(97,ag)&~32,Math.min(al,122)&~32])}}}}af.sort(function(av,au){return(av[0]-au[0])||(au[1]-av[1])});var ai=[];var ap=[NaN,NaN];for(var ar=0;arat[0]){if(at[1]+1>at[0]){an.push("-")}an.push(T(at[1]))}}an.push("]");return an.join("")}function W(al){var aj=al.source.match(new RegExp("(?:\\[(?:[^\\x5C\\x5D]|\\\\[\\s\\S])*\\]|\\\\u[A-Fa-f0-9]{4}|\\\\x[A-Fa-f0-9]{2}|\\\\[0-9]+|\\\\[^ux0-9]|\\(\\?[:!=]|[\\(\\)\\^]|[^\\x5B\\x5C\\(\\)\\^]+)","g"));var ah=aj.length;var an=[];for(var ak=0,am=0;ak=2&&ai==="["){aj[ak]=X(ag)}else{if(ai!=="\\"){aj[ak]=ag.replace(/[a-zA-Z]/g,function(ao){var ap=ao.charCodeAt(0);return"["+String.fromCharCode(ap&~32,ap|32)+"]"})}}}}return aj.join("")}var aa=[];for(var V=0,U=Z.length;V=0;){S[ac.charAt(ae)]=Y}}var af=Y[1];var aa=""+af;if(!ag.hasOwnProperty(aa)){ah.push(af);ag[aa]=null}}ah.push(/[\0-\uffff]/);V=k(ah)})();var X=T.length;var W=function(ah){var Z=ah.sourceCode,Y=ah.basePos;var ad=[Y,F];var af=0;var an=Z.match(V)||[];var aj={};for(var ae=0,aq=an.length;ae=5&&"lang-"===ap.substring(0,5);if(am&&!(ai&&typeof ai[1]==="string")){am=false;ap=J}if(!am){aj[ag]=ap}}var ab=af;af+=ag.length;if(!am){ad.push(Y+ab,ap)}else{var al=ai[1];var ak=ag.indexOf(al);var ac=ak+al.length;if(ai[2]){ac=ag.length-ai[2].length;ak=ac-al.length}var ar=ap.substring(5);B(Y+ab,ag.substring(0,ak),W,ad);B(Y+ab+ak,al,q(ar,al),ad);B(Y+ab+ac,ag.substring(ac),W,ad)}}ah.decorations=ad};return W}function i(T){var W=[],S=[];if(T.tripleQuotedStrings){W.push([C,/^(?:\'\'\'(?:[^\'\\]|\\[\s\S]|\'{1,2}(?=[^\']))*(?:\'\'\'|$)|\"\"\"(?:[^\"\\]|\\[\s\S]|\"{1,2}(?=[^\"]))*(?:\"\"\"|$)|\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$))/,null,"'\""])}else{if(T.multiLineStrings){W.push([C,/^(?:\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$)|\`(?:[^\\\`]|\\[\s\S])*(?:\`|$))/,null,"'\"`"])}else{W.push([C,/^(?:\'(?:[^\\\'\r\n]|\\.)*(?:\'|$)|\"(?:[^\\\"\r\n]|\\.)*(?:\"|$))/,null,"\"'"])}}if(T.verbatimStrings){S.push([C,/^@\"(?:[^\"]|\"\")*(?:\"|$)/,null])}var Y=T.hashComments;if(Y){if(T.cStyleComments){if(Y>1){W.push([j,/^#(?:##(?:[^#]|#(?!##))*(?:###|$)|.*)/,null,"#"])}else{W.push([j,/^#(?:(?:define|elif|else|endif|error|ifdef|include|ifndef|line|pragma|undef|warning)\b|[^\r\n]*)/,null,"#"])}S.push([C,/^<(?:(?:(?:\.\.\/)*|\/?)(?:[\w-]+(?:\/[\w-]+)+)?[\w-]+\.h|[a-z]\w*)>/,null])}else{W.push([j,/^#[^\r\n]*/,null,"#"])}}if(T.cStyleComments){S.push([j,/^\/\/[^\r\n]*/,null]);S.push([j,/^\/\*[\s\S]*?(?:\*\/|$)/,null])}if(T.regexLiterals){var X=("/(?=[^/*])(?:[^/\\x5B\\x5C]|\\x5C[\\s\\S]|\\x5B(?:[^\\x5C\\x5D]|\\x5C[\\s\\S])*(?:\\x5D|$))+/");S.push(["lang-regex",new RegExp("^"+M+"("+X+")")])}var V=T.types;if(V){S.push([O,V])}var U=(""+T.keywords).replace(/^ | $/g,"");if(U.length){S.push([z,new RegExp("^(?:"+U.replace(/[\s,]+/g,"|")+")\\b"),null])}W.push([F,/^\s+/,null," \r\n\t\xA0"]);S.push([G,/^@[a-z_$][a-z_$@0-9]*/i,null],[O,/^(?:[@_]?[A-Z]+[a-z][A-Za-z_$@0-9]*|\w+_t\b)/,null],[F,/^[a-z_$][a-z_$@0-9]*/i,null],[G,new RegExp("^(?:0x[a-f0-9]+|(?:\\d(?:_\\d+)*\\d*(?:\\.\\d*)?|\\.\\d\\+)(?:e[+\\-]?\\d+)?)[a-z]*","i"),null,"0123456789"],[F,/^\\[\s\S]?/,null],[L,/^.[^\s\w\.$@\'\"\`\/\#\\]*/,null]);return g(W,S)}var K=i({keywords:A,hashComments:true,cStyleComments:true,multiLineStrings:true,regexLiterals:true});function Q(V,ag){var U=/(?:^|\s)nocode(?:\s|$)/;var ab=/\r\n?|\n/;var ac=V.ownerDocument;var S;if(V.currentStyle){S=V.currentStyle.whiteSpace}else{if(window.getComputedStyle){S=ac.defaultView.getComputedStyle(V,null).getPropertyValue("white-space")}}var Z=S&&"pre"===S.substring(0,3);var af=ac.createElement("LI");while(V.firstChild){af.appendChild(V.firstChild)}var W=[af];function ae(al){switch(al.nodeType){case 1:if(U.test(al.className)){break}if("BR"===al.nodeName){ad(al);if(al.parentNode){al.parentNode.removeChild(al)}}else{for(var an=al.firstChild;an;an=an.nextSibling){ae(an)}}break;case 3:case 4:if(Z){var am=al.nodeValue;var aj=am.match(ab);if(aj){var ai=am.substring(0,aj.index);al.nodeValue=ai;var ah=am.substring(aj.index+aj[0].length);if(ah){var ak=al.parentNode;ak.insertBefore(ac.createTextNode(ah),al.nextSibling)}ad(al);if(!ai){al.parentNode.removeChild(al)}}}break}}function ad(ak){while(!ak.nextSibling){ak=ak.parentNode;if(!ak){return}}function ai(al,ar){var aq=ar?al.cloneNode(false):al;var ao=al.parentNode;if(ao){var ap=ai(ao,1);var an=al.nextSibling;ap.appendChild(aq);for(var am=an;am;am=an){an=am.nextSibling;ap.appendChild(am)}}return aq}var ah=ai(ak.nextSibling,0);for(var aj;(aj=ah.parentNode)&&aj.nodeType===1;){ah=aj}W.push(ah)}for(var Y=0;Y=S){ah+=2}if(V>=ap){Z+=2}}}var t={};function c(U,V){for(var S=V.length;--S>=0;){var T=V[S];if(!t.hasOwnProperty(T)){t[T]=U}else{if(window.console){console.warn("cannot override language handler %s",T)}}}}function q(T,S){if(!(T&&t.hasOwnProperty(T))){T=/^\s*]*(?:>|$)/],[j,/^<\!--[\s\S]*?(?:-\->|$)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],[L,/^(?:<[%?]|[%?]>)/],["lang-",/^]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-js",/^]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i]]),["default-markup","htm","html","mxml","xhtml","xml","xsl"]);c(g([[F,/^[\s]+/,null," \t\r\n"],[n,/^(?:\"[^\"]*\"?|\'[^\']*\'?)/,null,"\"'"]],[[m,/^^<\/?[a-z](?:[\w.:-]*\w)?|\/?>$/i],[P,/^(?!style[\s=]|on)[a-z](?:[\w:-]*\w)?/i],["lang-uq.val",/^=\s*([^>\'\"\s]*(?:[^>\'\"\s\/]|\/(?=\s)))/],[L,/^[=<>\/]+/],["lang-js",/^on\w+\s*=\s*\"([^\"]+)\"/i],["lang-js",/^on\w+\s*=\s*\'([^\']+)\'/i],["lang-js",/^on\w+\s*=\s*([^\"\'>\s]+)/i],["lang-css",/^style\s*=\s*\"([^\"]+)\"/i],["lang-css",/^style\s*=\s*\'([^\']+)\'/i],["lang-css",/^style\s*=\s*([^\"\'>\s]+)/i]]),["in.tag"]);c(g([],[[n,/^[\s\S]+/]]),["uq.val"]);c(i({keywords:l,hashComments:true,cStyleComments:true,types:e}),["c","cc","cpp","cxx","cyc","m"]);c(i({keywords:"null,true,false"}),["json"]);c(i({keywords:R,hashComments:true,cStyleComments:true,verbatimStrings:true,types:e}),["cs"]);c(i({keywords:x,cStyleComments:true}),["java"]);c(i({keywords:H,hashComments:true,multiLineStrings:true}),["bsh","csh","sh"]);c(i({keywords:I,hashComments:true,multiLineStrings:true,tripleQuotedStrings:true}),["cv","py"]);c(i({keywords:s,hashComments:true,multiLineStrings:true,regexLiterals:true}),["perl","pl","pm"]);c(i({keywords:f,hashComments:true,multiLineStrings:true,regexLiterals:true}),["rb"]);c(i({keywords:w,cStyleComments:true,regexLiterals:true}),["js"]);c(i({keywords:r,hashComments:3,cStyleComments:true,multilineStrings:true,tripleQuotedStrings:true,regexLiterals:true}),["coffee"]);c(g([],[[C,/^[\s\S]+/]]),["regex"]);function d(V){var U=V.langExtension;try{var S=a(V.sourceNode);var T=S.sourceCode;V.sourceCode=T;V.spans=S.spans;V.basePos=0;q(U,T)(V);D(V)}catch(W){if("console" in window){console.log(W&&W.stack?W.stack:W)}}}function y(W,V,U){var S=document.createElement("PRE");S.innerHTML=W;if(U){Q(S,U)}var T={langExtension:V,numberLines:U,sourceNode:S};d(T);return S.innerHTML}function b(ad){function Y(af){return document.getElementsByTagName(af)}var ac=[Y("pre"),Y("code"),Y("xmp")];var T=[];for(var aa=0;aa=0){var ah=ai.match(ab);var am;if(!ah&&(am=o(aj))&&"CODE"===am.tagName){ah=am.className.match(ab)}if(ah){ah=ah[1]}var al=false;for(var ak=aj.parentNode;ak;ak=ak.parentNode){if((ak.tagName==="pre"||ak.tagName==="code"||ak.tagName==="xmp")&&ak.className&&ak.className.indexOf("prettyprint")>=0){al=true;break}}if(!al){var af=aj.className.match(/\blinenums\b(?::(\d+))?/);af=af?af[1]&&af[1].length?+af[1]:true:false;if(af){Q(aj,af)}S={langExtension:ah,sourceNode:aj,numberLines:af};d(S)}}}if(X]*(?:>|$)/],[PR.PR_COMMENT,/^<\!--[\s\S]*?(?:-\->|$)/],[PR.PR_PUNCTUATION,/^(?:<[%?]|[%?]>)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],["lang-",/^]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-handlebars",/^]*type\s*=\s*['"]?text\/x-handlebars-template['"]?\b[^>]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-js",/^]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i],[PR.PR_DECLARATION,/^{{[#^>/]?\s*[\w.][^}]*}}/],[PR.PR_DECLARATION,/^{{&?\s*[\w.][^}]*}}/],[PR.PR_DECLARATION,/^{{{>?\s*[\w.][^}]*}}}/],[PR.PR_COMMENT,/^{{![^}]*}}/]]),["handlebars","hbs"]);PR.registerLangHandler(PR.createSimpleLexer([[PR.PR_PLAIN,/^[ \t\r\n\f]+/,null," \t\r\n\f"]],[[PR.PR_STRING,/^\"(?:[^\n\r\f\\\"]|\\(?:\r\n?|\n|\f)|\\[\s\S])*\"/,null],[PR.PR_STRING,/^\'(?:[^\n\r\f\\\']|\\(?:\r\n?|\n|\f)|\\[\s\S])*\'/,null],["lang-css-str",/^url\(([^\)\"\']*)\)/i],[PR.PR_KEYWORD,/^(?:url|rgb|\!important|@import|@page|@media|@charset|inherit)(?=[^\-\w]|$)/i,null],["lang-css-kw",/^(-?(?:[_a-z]|(?:\\[0-9a-f]+ ?))(?:[_a-z0-9\-]|\\(?:\\[0-9a-f]+ ?))*)\s*:/i],[PR.PR_COMMENT,/^\/\*[^*]*\*+(?:[^\/*][^*]*\*+)*\//],[PR.PR_COMMENT,/^(?:)/],[PR.PR_LITERAL,/^(?:\d+|\d*\.\d+)(?:%|[a-z]+)?/i],[PR.PR_LITERAL,/^#(?:[0-9a-f]{3}){1,2}/i],[PR.PR_PLAIN,/^-?(?:[_a-z]|(?:\\[\da-f]+ ?))(?:[_a-z\d\-]|\\(?:\\[\da-f]+ ?))*/i],[PR.PR_PUNCTUATION,/^[^\s\w\'\"]+/]]),["css"]);PR.registerLangHandler(PR.createSimpleLexer([],[[PR.PR_KEYWORD,/^-?(?:[_a-z]|(?:\\[\da-f]+ ?))(?:[_a-z\d\-]|\\(?:\\[\da-f]+ ?))*/i]]),["css-kw"]);PR.registerLangHandler(PR.createSimpleLexer([],[[PR.PR_STRING,/^[^\)\"\']+/]]),["css-str"]); diff --git a/dashboard/frontend/coverage/lcov-report/sort-arrow-sprite.png b/dashboard/frontend/coverage/lcov-report/sort-arrow-sprite.png new file mode 100644 index 0000000000000000000000000000000000000000..6ed68316eb3f65dec9063332d2f69bf3093bbfab GIT binary patch literal 138 zcmeAS@N?(olHy`uVBq!ia0vp^>_9Bd!3HEZxJ@+%Qh}Z>jv*C{$p!i!8j}?a+@3A= zIAGwzjijN=FBi!|L1t?LM;Q;gkwn>2cAy-KV{dn nf0J1DIvEHQu*n~6U}x}qyky7vi4|9XhBJ7&`njxgN@xNA8m%nc literal 0 HcmV?d00001 diff --git a/dashboard/frontend/coverage/lcov-report/sorter.js b/dashboard/frontend/coverage/lcov-report/sorter.js new file mode 100644 index 0000000..4ed70ae --- /dev/null +++ b/dashboard/frontend/coverage/lcov-report/sorter.js @@ -0,0 +1,210 @@ +/* eslint-disable */ +var addSorting = (function() { + 'use strict'; + var cols, + currentSort = { + index: 0, + desc: false + }; + + // returns the summary table element + function getTable() { + return document.querySelector('.coverage-summary'); + } + // returns the thead element of the summary table + function getTableHeader() { + return getTable().querySelector('thead tr'); + } + // returns the tbody element of the summary table + function getTableBody() { + return getTable().querySelector('tbody'); + } + // returns the th element for nth column + function getNthColumn(n) { + return getTableHeader().querySelectorAll('th')[n]; + } + + function onFilterInput() { + const searchValue = document.getElementById('fileSearch').value; + const rows = document.getElementsByTagName('tbody')[0].children; + + // Try to create a RegExp from the searchValue. If it fails (invalid regex), + // it will be treated as a plain text search + let searchRegex; + try { + searchRegex = new RegExp(searchValue, 'i'); // 'i' for case-insensitive + } catch (error) { + searchRegex = null; + } + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + let isMatch = false; + + if (searchRegex) { + // If a valid regex was created, use it for matching + isMatch = searchRegex.test(row.textContent); + } else { + // Otherwise, fall back to the original plain text search + isMatch = row.textContent + .toLowerCase() + .includes(searchValue.toLowerCase()); + } + + row.style.display = isMatch ? '' : 'none'; + } + } + + // loads the search box + function addSearchBox() { + var template = document.getElementById('filterTemplate'); + var templateClone = template.content.cloneNode(true); + templateClone.getElementById('fileSearch').oninput = onFilterInput; + template.parentElement.appendChild(templateClone); + } + + // loads all columns + function loadColumns() { + var colNodes = getTableHeader().querySelectorAll('th'), + colNode, + cols = [], + col, + i; + + for (i = 0; i < colNodes.length; i += 1) { + colNode = colNodes[i]; + col = { + key: colNode.getAttribute('data-col'), + sortable: !colNode.getAttribute('data-nosort'), + type: colNode.getAttribute('data-type') || 'string' + }; + cols.push(col); + if (col.sortable) { + col.defaultDescSort = col.type === 'number'; + colNode.innerHTML = + colNode.innerHTML + ''; + } + } + return cols; + } + // attaches a data attribute to every tr element with an object + // of data values keyed by column name + function loadRowData(tableRow) { + var tableCols = tableRow.querySelectorAll('td'), + colNode, + col, + data = {}, + i, + val; + for (i = 0; i < tableCols.length; i += 1) { + colNode = tableCols[i]; + col = cols[i]; + val = colNode.getAttribute('data-value'); + if (col.type === 'number') { + val = Number(val); + } + data[col.key] = val; + } + return data; + } + // loads all row data + function loadData() { + var rows = getTableBody().querySelectorAll('tr'), + i; + + for (i = 0; i < rows.length; i += 1) { + rows[i].data = loadRowData(rows[i]); + } + } + // sorts the table using the data for the ith column + function sortByIndex(index, desc) { + var key = cols[index].key, + sorter = function(a, b) { + a = a.data[key]; + b = b.data[key]; + return a < b ? -1 : a > b ? 1 : 0; + }, + finalSorter = sorter, + tableBody = document.querySelector('.coverage-summary tbody'), + rowNodes = tableBody.querySelectorAll('tr'), + rows = [], + i; + + if (desc) { + finalSorter = function(a, b) { + return -1 * sorter(a, b); + }; + } + + for (i = 0; i < rowNodes.length; i += 1) { + rows.push(rowNodes[i]); + tableBody.removeChild(rowNodes[i]); + } + + rows.sort(finalSorter); + + for (i = 0; i < rows.length; i += 1) { + tableBody.appendChild(rows[i]); + } + } + // removes sort indicators for current column being sorted + function removeSortIndicators() { + var col = getNthColumn(currentSort.index), + cls = col.className; + + cls = cls.replace(/ sorted$/, '').replace(/ sorted-desc$/, ''); + col.className = cls; + } + // adds sort indicators for current column being sorted + function addSortIndicators() { + getNthColumn(currentSort.index).className += currentSort.desc + ? ' sorted-desc' + : ' sorted'; + } + // adds event listeners for all sorter widgets + function enableUI() { + var i, + el, + ithSorter = function ithSorter(i) { + var col = cols[i]; + + return function() { + var desc = col.defaultDescSort; + + if (currentSort.index === i) { + desc = !currentSort.desc; + } + sortByIndex(i, desc); + removeSortIndicators(); + currentSort.index = i; + currentSort.desc = desc; + addSortIndicators(); + }; + }; + for (i = 0; i < cols.length; i += 1) { + if (cols[i].sortable) { + // add the click event handler on the th so users + // dont have to click on those tiny arrows + el = getNthColumn(i).querySelector('.sorter').parentElement; + if (el.addEventListener) { + el.addEventListener('click', ithSorter(i)); + } else { + el.attachEvent('onclick', ithSorter(i)); + } + } + } + } + // adds sorting functionality to the UI + return function() { + if (!getTable()) { + return; + } + cols = loadColumns(); + loadData(); + addSearchBox(); + addSortIndicators(); + enableUI(); + }; +})(); + +window.addEventListener('load', addSorting); diff --git a/dashboard/frontend/coverage/lcov-report/src/App.svelte.html b/dashboard/frontend/coverage/lcov-report/src/App.svelte.html new file mode 100644 index 0000000..1992d98 --- /dev/null +++ b/dashboard/frontend/coverage/lcov-report/src/App.svelte.html @@ -0,0 +1,787 @@ + + + + + + Code coverage report for src/App.svelte + + + + + + + + + +
+
+

All files / src App.svelte

+
+ +
+ 0% + Statements + 0/177 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/177 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
<script>
+  import Sidebar from "./components/Sidebar.svelte";
+  import Dashboard from "./routes/Dashboard.svelte";
+  import Docker from "./routes/Docker.svelte";
+  import Gitea from "./routes/Gitea.svelte";
+  import Files from "./routes/Files.svelte";
+  import Terminal from "./routes/Terminal.svelte";
+  import OpenClaw from "./routes/OpenClaw.svelte";
+  import Settings from "./routes/Settings.svelte";
+  import ChatSummary from "./routes/ChatSummary.svelte";
+  import Conversations from "./routes/Conversations.svelte";
+  import Security from "./routes/Security.svelte";
+  import LiteLLM from "./routes/LiteLLM.svelte";
+  import CcConnect from "./routes/CcConnect.svelte";
+  import InfoEngine from "./routes/InfoEngine.svelte";
+  import OPC from "./routes/OPC.svelte";
+  import Transmission from "./routes/Transmission.svelte";
+  import Login from "./routes/Login.svelte";
+  import { onMount } from "svelte";
+  import { getToken, checkAuth, setToken, currentUser, setCurrentUser, getPreferences, savePreferences, tryRefreshSession, logout as logoutSession } from "./lib/api.js";
+ 
+  const KNOWN_PAGES = new Set([
+    "dashboard",
+    "docker",
+    "gitea",
+    "files",
+    "terminal",
+    "openclaw",
+    "chat-digest",
+    "conversations",
+    "settings",
+    "security",
+    "litellm",
+    "cc-connect",
+    "info-engine",
+    "opc",
+    "transmission",
+  ]);
+ 
+  let page = $state("dashboard");
+  let dark = $state(false);
+  let sidebarOpen = $state(false);
+  let authorized = $state(false);
+  let loading = $state(true);
+  let userRole = $state("");
+  let allowedPages = $state([]);
+  let allowedSidebarLinks = $state("*");
+  let sidebarOrder = $state(null);
+  let orderSaveTimer = null;
+ 
+  function getRequestedPage() {
+    const requestedPage = new URLSearchParams(window.location.search).get("page");
+    return KNOWN_PAGES.has(requestedPage) ? requestedPage : null;
+  }
+ 
+  function canOpenPage(pageId) {
+    if (!pageId || !KNOWN_PAGES.has(pageId)) return false;
+    if (pageId === "settings") return userRole === "admin";
+    if (["litellm", "cc-connect", "info-engine"].includes(pageId)) return hasPageAccess("dashboard");
+    return hasPageAccess(pageId);
+  }
+ 
+  function syncPageQuery(pageId) {
+    const url = new URL(window.location.href);
+    if (pageId === "dashboard") url.searchParams.delete("page");
+    else url.searchParams.set("page", pageId);
+    if (pageId !== "terminal") url.searchParams.delete("host");
+    history.replaceState({}, "", `${url.pathname}${url.search}${url.hash}`);
+  }
+ 
+  async function fetchUserInfo() {
+    try {
+      const data = await checkAuth();
+      setCurrentUser(data);
+      userRole = data.role || "admin";
+      allowedPages = data.pages || "*";
+      allowedSidebarLinks = data.sidebar_links || "*";
+      // Fetch sidebar preferences
+      try {
+        const prefs = await getPreferences();
+        sidebarOrder = prefs.sidebar_order || null;
+      } catch {}
+    } catch (e) {
+      console.error("Failed to fetch user info:", e);
+    }
+  }
+ 
+  function handleOrderChange(newOrder) {
+    sidebarOrder = newOrder;
+    clearTimeout(orderSaveTimer);
+    orderSaveTimer = setTimeout(() => {
+      savePreferences({ sidebar_order: newOrder }).catch((e) => {
+        console.error("Failed to save sidebar order:", e);
+      });
+    }, 500);
+  }
+ 
+  function hasPageAccess(pageId) {
+    if (allowedPages === "*") return true;
+    return Array.isArray(allowedPages) && allowedPages.includes(pageId);
+  }
+ 
+  onMount(async () => {
+    // Theme init
+    if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
+      dark = true;
+      document.documentElement.classList.add('dark');
+    } else {
+      dark = false;
+      document.documentElement.classList.remove('dark');
+    }
+ 
+    // Try proxy auth first (Authelia forward-auth sets Remote-User header)
+    try {
+      const proxyRes = await fetch("/api/auth/proxy", { credentials: "same-origin" });
+      if (proxyRes.ok) {
+        const data = await proxyRes.json();
+        setToken(data.access_token);
+        authorized = true;
+        await fetchUserInfo();
+        const requestedPage = getRequestedPage();
+        if (canOpenPage(requestedPage)) page = requestedPage;
+        loading = false;
+        return;
+      }
+    } catch (e) {
+      // Proxy auth not available, fall through to regular auth
+    }
+ 
+    // Regular token auth check — always try refresh to get fresh cookies
+    const refreshed = await tryRefreshSession();
+    if (!refreshed && !getToken()) {
+      loading = false;
+      authorized = false;
+      return;
+    }
+ 
+    try {
+        await fetchUserInfo();
+        authorized = true;
+        const requestedPage = getRequestedPage();
+        if (canOpenPage(requestedPage)) page = requestedPage;
+    } catch (e) {
+        console.error("Auth check failed:", e);
+        setToken("");
+        authorized = false;
+    } finally {
+        loading = false;
+    }
+  });
+ 
+  $effect(() => {
+    if (!authorized || loading || !canOpenPage(page)) return;
+    syncPageQuery(page);
+  });
+ 
+  function toggleTheme() {
+    dark = !dark;
+    if (dark) {
+       document.documentElement.classList.add("dark");
+       localStorage.theme = 'dark';
+    } else {
+       document.documentElement.classList.remove("dark");
+       localStorage.theme = 'light';
+    }
+  }
+ 
+  async function logout() {
+      try {
+        await logoutSession();
+      } catch (e) {
+        console.error("Logout failed:", e);
+      }
+      setToken("");
+      window.location.reload();
+  }
+</script>
+ 
+{#if loading}
+  <div class="flex h-screen items-center justify-center bg-surface-50 dark:bg-surface-950">
+    <div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
+  </div>
+{:else if !authorized}
+  <Login />
+{:else}
+  <div class="flex h-screen overflow-hidden bg-surface-50 dark:bg-surface-900">
+    <Sidebar bind:page {dark} {toggleTheme} {logout} bind:sidebarOpen {allowedPages} {allowedSidebarLinks} {userRole} {sidebarOrder} onOrderChange={handleOrderChange} />
+    <main class="flex-1 overflow-auto">
+      <div class="max-w-[1400px] mx-auto p-6 lg:p-8">
+        <!-- Mobile hamburger -->
+        <button class="md:hidden mb-4 p-2 rounded-lg text-surface-600 dark:text-surface-300 hover:bg-surface-200 dark:hover:bg-surface-700" onclick={() => sidebarOpen = true} aria-label="Open menu">
+          <svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" /></svg>
+        </button>
+        {#if page === "dashboard" && hasPageAccess("dashboard")}
+          <Dashboard />
+        {:else if page === "docker" && hasPageAccess("docker")}
+          <Docker />
+        {:else if page === "gitea" && hasPageAccess("gitea")}
+          <Gitea />
+        {:else if page === "files" && hasPageAccess("files")}
+          <Files />
+        {:else if page === "openclaw" && hasPageAccess("openclaw")}
+          <OpenClaw />
+        {:else if page === "chat-digest" && hasPageAccess("chat-digest")}
+          <ChatSummary />
+        {:else if page === "conversations" && hasPageAccess("conversations")}
+          <Conversations />
+        {:else if page === "settings" && userRole === "admin"}
+          <Settings />
+        {:else if page === "security" && hasPageAccess("security")}
+          <Security />
+        {:else if page === "litellm" && hasPageAccess("dashboard")}
+          <LiteLLM />
+        {:else if page === "cc-connect" && hasPageAccess("dashboard")}
+          <CcConnect />
+        {:else if page === "info-engine" && hasPageAccess("dashboard")}
+          <InfoEngine />
+        {:else if page === "opc" && hasPageAccess("opc")}
+          <OPC />
+        {:else if page === "transmission" && hasPageAccess("transmission")}
+          <Transmission />
+        {:else if page !== "terminal"}
+          <Dashboard />
+        {/if}
+ 
+        {#if hasPageAccess("terminal")}
+          <div class:hidden={page !== "terminal"}>
+            <Terminal />
+          </div>
+        {/if}
+      </div>
+    </main>
+  </div>
+{/if}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/lcov-report/src/components/Sidebar.svelte.html b/dashboard/frontend/coverage/lcov-report/src/components/Sidebar.svelte.html new file mode 100644 index 0000000..a57a7a0 --- /dev/null +++ b/dashboard/frontend/coverage/lcov-report/src/components/Sidebar.svelte.html @@ -0,0 +1,1339 @@ + + + + + + Code coverage report for src/components/Sidebar.svelte + + + + + + + + + +
+
+

All files / src/components Sidebar.svelte

+
+ +
+ 0% + Statements + 0/301 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/301 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
<script>
+  let { page = $bindable("dashboard"), dark = false, toggleTheme = () => {}, logout = () => {}, sidebarOpen = $bindable(false), allowedPages = "*", allowedSidebarLinks = "*", userRole = "admin", sidebarOrder = null, onOrderChange = () => {} } = $props();
+ 
+  function canAccess(id) {
+    if (!id) return true;
+    if (allowedPages === "*") return true;
+    if (!Array.isArray(allowedPages)) return false;
+    if (id === "info-engine") return allowedPages.includes("dashboard");
+    return allowedPages.includes(id);
+  }
+ 
+  function canSeeSidebarLink(item) {
+    if (allowedSidebarLinks === "*") return true;
+    // Check by label or id
+    const key = item.label || item.id;
+    return Array.isArray(allowedSidebarLinks) && allowedSidebarLinks.includes(key);
+  }
+ 
+  const defaultLinks = [
+    { id: "dashboard", label: "Overview", icon: "grid" },
+    { id: "info-engine", label: "Info Engine", icon: "sparkles" },
+    { id: "litellm", label: "LiteLLM", icon: "bolt" },
+    { id: "opc", label: "OPC", icon: "kanban" },
+    { id: "docker", label: "Docker", icon: "box" },
+    { id: "files", label: "Files", icon: "folder" },
+    { id: "terminal", label: "Terminal", icon: "terminal" },
+    { id: "security", label: "Security", icon: "shield" },
+  ];
+ 
+  let lanIp = $state("192.168.31.222");
+  let tsIp = $state("100.78.131.124");
+  let lanReachable = $state(false);
+  let serviceUrls = $state({});
+ 
+  if (typeof window !== "undefined") {
+    fetch("/api/client-ip").then(r => r.json()).then(d => {
+      if (d.lan) lanReachable = true;
+    }).catch(() => {});
+ 
+    // Fetch external service URLs from backend config
+    fetch("/api/system/external-services").then(r => r.json()).then(d => {
+      if (d.lan_ip) lanIp = d.lan_ip;
+      if (d.ts_ip) tsIp = d.ts_ip;
+      if (d.services) serviceUrls = d.services;
+    }).catch(() => {});
+  }
+ 
+  const defaultMedia = [
+    { label: "Navidrome", remoteHref: "https://music.jimmygan.com:8443", icon: "music" },
+    { label: "Jellyfin", remoteHref: "https://media.jimmygan.com:8443", icon: "play" },
+    { label: "Audiobookshelf", remoteHref: "https://books.jimmygan.com:8443", icon: "headphones" },
+    { label: "Immich", remoteHref: "https://photos.jimmygan.com:8443", icon: "image" },
+    { id: "transmission", label: "Transmission", icon: "download" },
+  ];
+ 
+  const defaultTools = [
+    { id: "openclaw", label: "OpenClaw", icon: "openclaw" },
+    { id: "cc-connect", label: "cc-connect", icon: "users" },
+    { id: "chat-digest", label: "Chat Digest", icon: "chat" },
+    { id: "conversations", label: "Code Sessions", icon: "code" },
+    { id: "gitea", label: "Repos", icon: "git" },
+    { label: "Gitea Web", remoteHref: "https://git.jimmygan.com:8443", icon: "git", external: true },
+    { label: "Stirling PDF", remoteHref: "https://pdf.jimmygan.com:8443", icon: "pdf", external: true },
+    { label: "Vaultwarden", remoteHref: "https://vault.jimmygan.com:8443", icon: "lock", external: true },
+    { label: "n8n", remoteHref: "https://n8n.jimmygan.com:8443", icon: "n8n", external: true },
+    { label: "Speedtest", remoteHref: "https://speed.jimmygan.com:8443", icon: "speedtest", external: true },
+  ];
+ 
+  const defaultTools2 = [];
+ 
+  function itemKey(item) { return item.id || item.label; }
+ 
+  // All items indexed by key for cross-category lookup
+  const allItems = new Map([...defaultLinks, ...defaultMedia, ...defaultTools, ...defaultTools2].map(i => [itemKey(i), i]));
+ 
+  // Build category lists from saved order, supporting cross-category moves
+  const categories = ["main", "media", "tools", "tools2"];
+  const categoryDefaults = { main: defaultLinks, media: defaultMedia, tools: defaultTools, tools2: defaultTools2 };
+ 
+  function buildLists(savedOrder) {
+    if (!savedOrder) return { main: [...defaultLinks], media: [...defaultMedia], tools: [...defaultTools], tools2: [...defaultTools2] };
+ 
+    const used = new Set();
+    const result = { main: [], media: [], tools: [], tools2: [] };
+ 
+    for (const cat of categories) {
+      const keys = savedOrder[cat] || [];
+      for (const k of keys) {
+        if (allItems.has(k) && !used.has(k)) {
+          result[cat].push(allItems.get(k));
+          used.add(k);
+        }
+      }
+    }
+ 
+    // Append any new items not in saved order to their default category
+    for (const cat of categories) {
+      for (const item of categoryDefaults[cat]) {
+        if (!used.has(itemKey(item))) {
+          result[cat].push(item);
+          used.add(itemKey(item));
+        }
+      }
+    }
+ 
+    return result;
+  }
+ 
+  let categoryLists = $derived(buildLists(sidebarOrder));
+  let links = $derived(categoryLists.main);
+  let media = $derived(categoryLists.media);
+  let tools = $derived(categoryLists.tools);
+  let tools2 = $derived(categoryLists.tools2);
+ 
+  // Collapsed state for categories (persisted in sidebarOrder)
+  let collapsed = $state(sidebarOrder?.collapsed ? { ...sidebarOrder.collapsed } : {});
+ 
+  // Drag-and-drop state
+  let dragCategory = $state(null);
+  let dragIndex = $state(-1);
+  let dropCategory = $state(null);
+  let dropIndex = $state(-1);
+ 
+  function handleDragStart(category, index, e) {
+    dragCategory = category;
+    dragIndex = index;
+    e.dataTransfer.effectAllowed = "move";
+    e.dataTransfer.setData("text/plain", "");
+  }
+ 
+  function handleDragOver(category, index, e) {
+    e.preventDefault();
+    e.dataTransfer.dropEffect = "move";
+    dropCategory = category;
+    dropIndex = index;
+  }
+ 
+  function handleDragEnd() {
+    dragCategory = null;
+    dragIndex = -1;
+    dropCategory = null;
+    dropIndex = -1;
+  }
+ 
+  function handleDrop(category, index, e) {
+    e.preventDefault();
+    if (dragCategory === null) { handleDragEnd(); return; }
+    if (dragCategory === category && dragIndex === index) { handleDragEnd(); return; }
+    const lists = { main: [...links], media: [...media], tools: [...tools], tools2: [...tools2] };
+    const [moved] = lists[dragCategory].splice(dragIndex, 1);
+    lists[category].splice(index, 0, moved);
+    const newOrder = {
+      main: lists.main.map(itemKey),
+      media: lists.media.map(itemKey),
+      tools: lists.tools.map(itemKey),
+      tools2: lists.tools2.map(itemKey),
+      collapsed,
+    };
+    onOrderChange(newOrder);
+    handleDragEnd();
+  }
+ 
+  // Allow dropping at end of a category (on the section divider/header area)
+  function handleCategoryDragOver(category, e) {
+    e.preventDefault();
+    e.dataTransfer.dropEffect = "move";
+    dropCategory = category;
+    dropIndex = -1;
+  }
+ 
+  function handleCategoryDrop(category, e) {
+    e.preventDefault();
+    if (dragCategory === null) { handleDragEnd(); return; }
+    const lists = { main: [...links], media: [...media], tools: [...tools], tools2: [...tools2] };
+    const [moved] = lists[dragCategory].splice(dragIndex, 1);
+    lists[category].push(moved);
+    const newOrder = {
+      main: lists.main.map(itemKey),
+      media: lists.media.map(itemKey),
+      tools: lists.tools.map(itemKey),
+      tools2: lists.tools2.map(itemKey),
+      collapsed,
+    };
+    onOrderChange(newOrder);
+    handleDragEnd();
+  }
+ 
+  function toPublicHttpsUrl(href) {
+    if (typeof window === "undefined") return href;
+    if (!href) return href;
+ 
+    const isStandardHttps = location.protocol === "https:" && (!location.port || location.port === "443");
+    if (!isStandardHttps) return href;
+ 
+    try {
+      const url = new URL(href);
+      if (url.protocol === "https:" && url.port === "8443") {
+        url.port = "";
+        return url.toString();
+      }
+      return href;
+    } catch {
+      return href;
+    }
+  }
+ 
+  function extHref(svc) {
+    // Look up service URL from fetched config by label (lowercased, underscored)
+    const key = svc.label ? svc.label.toLowerCase().replace(/\s+/g, "_") : "";
+    const configUrl = serviceUrls[key];
+    const remoteHref = configUrl || svc.remoteHref;
+ 
+    if (lanReachable && svc.port) return `http://${lanIp}:${svc.port}`;
+    if (remoteHref) return lanReachable ? remoteHref : toPublicHttpsUrl(remoteHref);
+    if (svc.port) {
+      const host = /^\d+\.\d+\.\d+\.\d+$/.test(location.hostname) ? location.hostname : tsIp;
+      return `http://${host}:${svc.port}`;
+    }
+    return svc.href;
+  }
+ 
+  function navigate(id) {
+    page = id;
+    sidebarOpen = false;
+  }
+ 
+  function dragClasses(category, index) {
+    if (dragCategory === category && index === dragIndex) return "opacity-30";
+    if (dropCategory === category && index === dropIndex) return "border-t-2 border-primary-500";
+    return "";
+  }
+ 
+  function toggleCollapse(cat) {
+    collapsed[cat] = !collapsed[cat];
+    const newOrder = {
+      main: links.map(itemKey),
+      media: media.map(itemKey),
+      tools: tools.map(itemKey),
+      tools2: tools2.map(itemKey),
+      collapsed,
+    };
+    onOrderChange(newOrder);
+  }
+</script>
+ 
+{#snippet iconSvg(icon)}
+  <span class="w-5 h-5 flex items-center justify-center">
+    {#if icon === "grid"}
+      <svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" /></svg>
+    {:else if icon === "sparkles"}
+      <svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M12 3l1.8 4.2L18 9l-4.2 1.8L12 15l-1.8-4.2L6 9l4.2-1.8L12 3zm6 11l.9 2.1L21 17l-2.1.9L18 20l-.9-2.1L15 17l2.1-.9L18 14zM5 14l.9 2.1L8 17l-2.1.9L5 20l-.9-2.1L2 17l2.1-.9L5 14z" /></svg>
+    {:else if icon === "box"}
+      <svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" /></svg>
+    {:else if icon === "bolt"}
+      <svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M13 3L4 14h6l-1 7 9-11h-6l1-7z" /></svg>
+    {:else if icon === "folder"}
+      <svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /></svg>
+    {:else if icon === "terminal"}
+      <svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
+    {:else if icon === "shield"}
+      <svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
+    {:else if icon === "kanban"}
+      <svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2zm8 0h-2a2 2 0 00-2 2v8a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2z" /></svg>
+    {:else if icon === "music"}
+      <svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2z" /></svg>
+    {:else if icon === "play"}
+      <svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
+    {:else if icon === "headphones"}
+      <svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M3 18v-6a9 9 0 0118 0v6M3 18a3 3 0 003 3h0a3 3 0 003-3v-2a3 3 0 00-3-3h0a3 3 0 00-3 3v2zm18 0a3 3 0 01-3 3h0a3 3 0 01-3-3v-2a3 3 0 013-3h0a3 3 0 013 3v2z" /></svg>
+    {:else if icon === "image"}
+      <svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
+    {:else if icon === "download"}
+      <svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" /></svg>
+    {:else if icon === "openclaw"}
+      <svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" /></svg>
+    {:else if icon === "chat"}
+      <svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a2 2 0 01-2-2v-1m0-3V6a2 2 0 012-2h8a2 2 0 012 2v3a2 2 0 01-2 2H9l-4 4V9z" /></svg>
+    {:else if icon === "code"}
+      <svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" /></svg>
+    {:else if icon === "git"}
+      <svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" /></svg>
+    {:else if icon === "pdf"}
+      <svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" /></svg>
+    {:else if icon === "lock"}
+      <svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
+    {:else if icon === "speedtest"}
+      <svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
+    {:else if icon === "users"}
+      <svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" /></svg>
+    {:else if icon === "n8n"}
+      <svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" /><circle cx="8" cy="6" r="1.5" fill="currentColor"/><circle cx="16" cy="12" r="1.5" fill="currentColor"/><circle cx="10" cy="18" r="1.5" fill="currentColor"/></svg>
+    {/if}
+  </span>
+{/snippet}
+ 
+<!-- Mobile overlay -->
+{#if sidebarOpen}
+  <button class="fixed inset-0 bg-black/40 z-40 md:hidden" onclick={() => sidebarOpen = false} aria-label="Close sidebar"></button>
+{/if}
+ 
+<aside class="fixed inset-y-0 left-0 z-50 w-[240px] h-screen flex flex-col shrink-0 border-r border-surface-200 dark:border-surface-700 bg-white dark:bg-surface-800 transition-transform duration-200 md:static md:translate-x-0 {sidebarOpen ? 'translate-x-0' : '-translate-x-full'}">
+  <!-- Logo -->
+  <div class="px-5 py-5 flex items-center gap-3">
+    <div class="w-8 h-8 rounded-lg bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center shadow-md">
+      <svg class="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2" /></svg>
+    </div>
+    <div>
+      <h1 class="text-sm font-bold tracking-tight text-surface-900 dark:text-white">NAS Dashboard</h1>
+      <p class="text-[10px] text-surface-400 font-medium">DS224+ · Synology</p>
+    </div>
+  </div>
+ 
+  <!-- Nav Links -->
+  <nav class="flex-1 px-3 mt-2 overflow-y-auto">
+    {#each [["main", "Main", links], ["media", "Media", media], ["tools", "Tools", tools], ["tools2", "Tools 2", tools2]] as [cat, label, items], ci}
+      {#if ci > 0}
+        <div class="my-4 border-t border-surface-200 dark:border-surface-700"></div>
+      {/if}
+      <button
+        class="w-full text-left text-[10px] font-semibold uppercase tracking-wider text-surface-400 px-3 mb-2 flex items-center justify-between {cat !== 'main' ? 'cursor-pointer' : ''}"
+        ondragover={(e) => handleCategoryDragOver(cat, e)}
+        ondrop={(e) => handleCategoryDrop(cat, e)}
+        onclick={() => cat !== 'main' && toggleCollapse(cat)}
+        disabled={cat === 'main'}
+      >
+        {label}
+        {#if cat !== "main"}
+          <svg class="w-3 h-3 opacity-30 hover:opacity-50 transition-all duration-150 {collapsed[cat] ? '-rotate-90' : ''}" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" /></svg>
+        {/if}
+      </button>
+      {#if cat === "main" || !collapsed[cat]}
+      {#if items.length === 0}
+        <div
+          class="px-3 py-3 mx-1 rounded-lg border border-dashed text-[11px] text-center transition-colors duration-150
+            {dropCategory === cat ? 'border-primary-400 bg-primary-50 dark:bg-primary-900/20 text-primary-500' : 'border-surface-300 dark:border-surface-600 text-surface-400'}"
+          ondragover={(e) => handleCategoryDragOver(cat, e)}
+          ondrop={(e) => handleCategoryDrop(cat, e)}
+        >
+          Drop items here
+        </div>
+      {/if}
+      {#each items as item, i}
+        {#if canAccess(item.id) && canSeeSidebarLink(item)}
+          {#if item.external || item.remoteHref || item.port}
+            <a
+              href={extHref(item)}
+              target="_blank"
+              rel="noopener"
+              draggable="true"
+              ondragstart={(e) => handleDragStart(cat, i, e)}
+              ondragover={(e) => handleDragOver(cat, i, e)}
+              ondrop={(e) => handleDrop(cat, i, e)}
+              ondragend={handleDragEnd}
+              class="w-full text-left px-3 py-2 rounded-lg text-[13px] font-medium flex items-center gap-2.5 mb-0.5 text-surface-500 hover:bg-surface-100 dark:hover:bg-surface-700 hover:text-surface-700 dark:hover:text-surface-200 dark:text-surface-400 transition-all duration-150 group {dragClasses(cat, i)}"
+            >
+              <svg class="w-3 h-3 opacity-0 group-hover:opacity-30 -ml-1 mr-0.5 shrink-0 cursor-grab" fill="currentColor" viewBox="0 0 24 24"><circle cx="9" cy="6" r="2"/><circle cx="15" cy="6" r="2"/><circle cx="9" cy="12" r="2"/><circle cx="15" cy="12" r="2"/><circle cx="9" cy="18" r="2"/><circle cx="15" cy="18" r="2"/></svg>
+              {@render iconSvg(item.icon)}
+              {item.label}
+              <svg class="w-3 h-3 ml-auto opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
+            </a>
+          {:else}
+            <button
+              draggable="true"
+              ondragstart={(e) => handleDragStart(cat, i, e)}
+              ondragover={(e) => handleDragOver(cat, i, e)}
+              ondrop={(e) => handleDrop(cat, i, e)}
+              ondragend={handleDragEnd}
+              class="w-full text-left px-3 py-2.5 rounded-lg text-[13px] font-medium flex items-center gap-2.5 mb-0.5 transition-all duration-150 group
+                {page === item.id
+                  ? 'bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 shadow-sm'
+                  : 'text-surface-500 dark:text-surface-400 hover:bg-surface-100 dark:hover:bg-surface-700 hover:text-surface-700 dark:hover:text-surface-200'
+                } {dragClasses(cat, i)}"
+              onclick={() => navigate(item.id)}
+            >
+              <svg class="w-3 h-3 opacity-0 group-hover:opacity-30 -ml-1 mr-0.5 shrink-0 cursor-grab" fill="currentColor" viewBox="0 0 24 24"><circle cx="9" cy="6" r="2"/><circle cx="15" cy="6" r="2"/><circle cx="9" cy="12" r="2"/><circle cx="15" cy="12" r="2"/><circle cx="9" cy="18" r="2"/><circle cx="15" cy="18" r="2"/></svg>
+              {@render iconSvg(item.icon)}
+              {item.label}
+            </button>
+          {/if}
+        {/if}
+      {/each}
+      {/if}
+    {/each}
+  </nav>
+ 
+  <!-- Bottom actions -->
+  <div class="px-3 pb-3 pt-2 border-t border-surface-200 dark:border-surface-700">
+    {#if userRole === "admin"}
+    <button
+      class="w-full px-3 py-2 rounded-lg text-[13px] font-medium flex items-center gap-2.5 text-surface-500 dark:text-surface-400 hover:bg-surface-100 dark:hover:bg-surface-700 transition-all duration-150 mb-1 {page === 'settings' ? 'bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 shadow-sm' : ''}"
+      onclick={() => navigate('settings')}
+    >
+      <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
+      Settings
+    </button>
+    {/if}
+    <div class="flex items-center gap-1">
+      <button
+        onclick={logout}
+        class="flex-1 px-3 py-2 rounded-lg text-[13px] font-medium flex items-center gap-2.5 text-surface-500 dark:text-surface-400 hover:bg-surface-100 dark:hover:bg-surface-700 transition-all duration-150 hover:text-red-600 dark:hover:text-red-400"
+      >
+        <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" /></svg>
+        Sign out
+      </button>
+      <button
+        onclick={toggleTheme}
+        class="p-2 rounded-lg text-surface-400 hover:bg-surface-100 dark:hover:bg-surface-700 hover:text-surface-600 dark:hover:text-surface-300 transition-all duration-150"
+        title={dark ? 'Light Mode' : 'Dark Mode'}
+      >
+        {#if dark}
+          <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" /></svg>
+        {:else}
+          <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" /></svg>
+        {/if}
+      </button>
+    </div>
+  </div>
+</aside>
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/lcov-report/src/components/index.html b/dashboard/frontend/coverage/lcov-report/src/components/index.html new file mode 100644 index 0000000..61324a1 --- /dev/null +++ b/dashboard/frontend/coverage/lcov-report/src/components/index.html @@ -0,0 +1,116 @@ + + + + + + Code coverage report for src/components + + + + + + + + + +
+
+

All files src/components

+
+ +
+ 0% + Statements + 0/301 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/301 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
Sidebar.svelte +
+
0%0/3010%0/10%0/10%0/301
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/lcov-report/src/components/opc/AgentPanel.svelte.html b/dashboard/frontend/coverage/lcov-report/src/components/opc/AgentPanel.svelte.html new file mode 100644 index 0000000..f3301f0 --- /dev/null +++ b/dashboard/frontend/coverage/lcov-report/src/components/opc/AgentPanel.svelte.html @@ -0,0 +1,688 @@ + + + + + + Code coverage report for src/components/opc/AgentPanel.svelte + + + + + + + + + +
+
+

All files / src/components/opc AgentPanel.svelte

+
+ +
+ 0% + Statements + 0/117 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/117 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
<script>
+  import { onMount } from "svelte";
+  import { getAgents, getExecutions } from "../../lib/opc-api.js";
+ 
+  let agents = $state([]);
+  let executions = $state([]);
+  let loading = $state(true);
+  let selectedAgent = $state(null);
+ 
+  async function loadData() {
+    loading = true;
+    try {
+      const [agentsRes, executionsRes] = await Promise.all([
+        getAgents(),
+        getExecutions({ limit: 20 })
+      ]);
+      agents = agentsRes.items || [];
+      executions = executionsRes.items || [];
+    } catch (e) {
+      console.error("Failed to load agent data:", e);
+    } finally {
+      loading = false;
+    }
+  }
+ 
+  function getAgentIcon(agentId) {
+    const icons = {
+      pm: "📋",
+      cto: "💻",
+      coo: "⚙️",
+      ceo: "🎯",
+      marketing: "📢",
+      social_media: "📱"
+    };
+    return icons[agentId] || "🤖";
+  }
+ 
+  function getStatusColor(status) {
+    const colors = {
+      pending: "bg-slate-100 text-slate-700",
+      running: "bg-blue-100 text-blue-700",
+      completed: "bg-emerald-100 text-emerald-700",
+      failed: "bg-rose-100 text-rose-700",
+      pending_approval: "bg-amber-100 text-amber-700"
+    };
+    return colors[status] || colors.pending;
+  }
+ 
+  function getAgentExecutions(agentId) {
+    return executions.filter(e => e.agent_id === agentId);
+  }
+ 
+  function formatTimestamp(timestamp) {
+    if (!timestamp) return "N/A";
+    return new Date(timestamp).toLocaleString();
+  }
+ 
+  onMount(() => {
+    loadData();
+    // Refresh every 10 seconds
+    const interval = setInterval(loadData, 10000);
+    return () => clearInterval(interval);
+  });
+</script>
+ 
+<div class="space-y-6">
+  <h2 class="text-xl font-semibold text-surface-900 dark:text-surface-100">
+    AI Agents
+  </h2>
+ 
+  {#if loading}
+    <div class="text-surface-600 dark:text-surface-400">Loading agents...</div>
+  {:else}
+    <!-- Agent Cards -->
+    <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
+      {#each agents as agent}
+        {@const agentExecs = getAgentExecutions(agent.id)}
+        {@const runningExecs = agentExecs.filter(e => e.status === "running")}
+        {@const completedExecs = agentExecs.filter(e => e.status === "completed")}
+ 
+        <button
+          onclick={() => selectedAgent = selectedAgent?.id === agent.id ? null : agent}
+          class="bg-white dark:bg-surface-800 rounded-lg p-4 border-2 transition-all text-left hover:shadow-lg {selectedAgent?.id === agent.id ? 'border-primary-500' : 'border-surface-200 dark:border-surface-700'}"
+        >
+          <!-- Agent Header -->
+          <div class="flex items-start justify-between mb-3">
+            <div class="flex items-center gap-2">
+              <span class="text-3xl">{getAgentIcon(agent.id)}</span>
+              <div>
+                <h3 class="font-semibold text-surface-900 dark:text-surface-100">
+                  {agent.name}
+                </h3>
+                <p class="text-xs text-surface-600 dark:text-surface-400">
+                  {agent.role}
+                </p>
+              </div>
+            </div>
+            {#if runningExecs.length > 0}
+              <span class="flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400">
+                <span class="animate-pulse">●</span>
+                Working
+              </span>
+            {:else}
+              <span class="text-xs text-surface-500">Idle</span>
+            {/if}
+          </div>
+ 
+          <!-- Agent Stats -->
+          <div class="grid grid-cols-2 gap-2 text-sm">
+            <div class="bg-surface-50 dark:bg-surface-900 rounded p-2">
+              <div class="text-xs text-surface-600 dark:text-surface-400">Total Tasks</div>
+              <div class="text-lg font-semibold text-surface-900 dark:text-surface-100">
+                {agentExecs.length}
+              </div>
+            </div>
+            <div class="bg-surface-50 dark:bg-surface-900 rounded p-2">
+              <div class="text-xs text-surface-600 dark:text-surface-400">Completed</div>
+              <div class="text-lg font-semibold text-emerald-600 dark:text-emerald-400">
+                {completedExecs.length}
+              </div>
+            </div>
+          </div>
+ 
+          <!-- Capabilities -->
+          <div class="mt-3">
+            <div class="text-xs text-surface-600 dark:text-surface-400 mb-1">Capabilities:</div>
+            <div class="flex flex-wrap gap-1">
+              {#each agent.capabilities.slice(0, 3) as capability}
+                <span class="text-xs px-2 py-0.5 rounded bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300">
+                  {capability.replace(/_/g, ' ')}
+                </span>
+              {/each}
+              {#if agent.capabilities.length > 3}
+                <span class="text-xs text-surface-500">+{agent.capabilities.length - 3}</span>
+              {/if}
+            </div>
+          </div>
+        </button>
+      {/each}
+    </div>
+ 
+    <!-- Execution Log -->
+    <div class="bg-white dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700">
+      <div class="p-4 border-b border-surface-200 dark:border-surface-700">
+        <h3 class="font-semibold text-surface-900 dark:text-surface-100">
+          {selectedAgent ? `${selectedAgent.name} Execution Log` : "Recent Executions"}
+        </h3>
+      </div>
+ 
+      <div class="divide-y divide-surface-200 dark:divide-surface-700 max-h-96 overflow-y-auto">
+        {#each (selectedAgent ? getAgentExecutions(selectedAgent.id) : executions) as execution}
+          <div class="p-4 hover:bg-surface-50 dark:hover:bg-surface-900/50">
+            <div class="flex items-start justify-between mb-2">
+              <div class="flex items-center gap-2">
+                <span class="text-xl">{getAgentIcon(execution.agent_id)}</span>
+                <div>
+                  <div class="font-medium text-surface-900 dark:text-surface-100">
+                    Task #{execution.task_id}
+                  </div>
+                  <div class="text-xs text-surface-600 dark:text-surface-400">
+                    {formatTimestamp(execution.started_at)}
+                  </div>
+                </div>
+              </div>
+              <span class="text-xs px-2 py-1 rounded {getStatusColor(execution.status)}">
+                {execution.status.replace(/_/g, ' ')}
+              </span>
+            </div>
+ 
+            {#if execution.output_result?.reasoning}
+              <p class="text-sm text-surface-700 dark:text-surface-300 mb-2">
+                {execution.output_result.reasoning}
+              </p>
+            {/if}
+ 
+            {#if execution.actions_proposed && execution.actions_proposed.length > 0}
+              <div class="text-xs text-surface-600 dark:text-surface-400">
+                Actions: {execution.actions_proposed.length}
+                {#each execution.actions_proposed.slice(0, 2) as action}
+                  <span class="ml-2 px-1.5 py-0.5 rounded bg-surface-100 dark:bg-surface-700">
+                    {action.type}
+                  </span>
+                {/each}
+              </div>
+            {/if}
+ 
+            {#if execution.error_message}
+              <div class="mt-2 text-xs text-rose-600 dark:text-rose-400">
+                Error: {execution.error_message}
+              </div>
+            {/if}
+          </div>
+        {:else}
+          <div class="p-8 text-center text-surface-500">
+            No executions yet
+          </div>
+        {/each}
+      </div>
+    </div>
+  {/if}
+</div>
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/lcov-report/src/components/opc/KanbanBoard.svelte.html b/dashboard/frontend/coverage/lcov-report/src/components/opc/KanbanBoard.svelte.html new file mode 100644 index 0000000..eef3bc8 --- /dev/null +++ b/dashboard/frontend/coverage/lcov-report/src/components/opc/KanbanBoard.svelte.html @@ -0,0 +1,205 @@ + + + + + + Code coverage report for src/components/opc/KanbanBoard.svelte + + + + + + + + + +
+
+

All files / src/components/opc KanbanBoard.svelte

+
+ +
+ 0% + Statements + 0/26 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/26 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
<script>
+  import KanbanColumn from "./KanbanColumn.svelte";
+  import { moveTask } from "../../lib/opc-api.js";
+ 
+  let { tasks = [], agents = [], onEdit, onDelete, onAssign, onTaskMoved } = $props();
+ 
+  const columns = [
+    { id: "parking_lot", title: "Parking Lot", status: "parking_lot" },
+    { id: "in_progress", title: "In Progress", status: "in_progress" },
+    { id: "done", title: "Done", status: "done" }
+  ];
+ 
+  function getTasksByStatus(status) {
+    return tasks.filter(t => t.status === status);
+  }
+ 
+  async function handleDrop(taskId, newStatus) {
+    try {
+      await moveTask(taskId, newStatus);
+      onTaskMoved?.(taskId, newStatus);
+    } catch (e) {
+      alert("Failed to move task: " + e.message);
+    }
+  }
+</script>
+ 
+<div class="grid grid-cols-1 md:grid-cols-3 gap-4 h-full">
+  {#each columns as column}
+    <KanbanColumn
+      title={column.title}
+      status={column.status}
+      tasks={getTasksByStatus(column.status)}
+      {agents}
+      {onEdit}
+      {onDelete}
+      {onAssign}
+      onDrop={handleDrop}
+    />
+  {/each}
+</div>
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/lcov-report/src/components/opc/KanbanColumn.svelte.html b/dashboard/frontend/coverage/lcov-report/src/components/opc/KanbanColumn.svelte.html new file mode 100644 index 0000000..d87f4e5 --- /dev/null +++ b/dashboard/frontend/coverage/lcov-report/src/components/opc/KanbanColumn.svelte.html @@ -0,0 +1,340 @@ + + + + + + Code coverage report for src/components/opc/KanbanColumn.svelte + + + + + + + + + +
+
+

All files / src/components/opc KanbanColumn.svelte

+
+ +
+ 0% + Statements + 0/46 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/46 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
<script>
+  import TaskCard from "./TaskCard.svelte";
+ 
+  let {
+    title,
+    status,
+    tasks = [],
+    agents = [],
+    onEdit,
+    onDelete,
+    onAssign,
+    onDrop
+  } = $props();
+ 
+  const columnColors = {
+    parking_lot: "bg-slate-100 dark:bg-slate-800 border-slate-300",
+    in_progress: "bg-blue-50 dark:bg-blue-900/20 border-blue-300",
+    done: "bg-emerald-50 dark:bg-emerald-900/20 border-emerald-300"
+  };
+ 
+  function handleDragOver(e) {
+    e.preventDefault();
+    e.currentTarget.classList.add("ring-2", "ring-primary-400");
+  }
+ 
+  function handleDragLeave(e) {
+    e.currentTarget.classList.remove("ring-2", "ring-primary-400");
+  }
+ 
+  function handleDrop(e) {
+    e.preventDefault();
+    e.currentTarget.classList.remove("ring-2", "ring-primary-400");
+ 
+    const taskId = parseInt(e.dataTransfer.getData("taskId"));
+    const fromStatus = e.dataTransfer.getData("fromStatus");
+ 
+    if (fromStatus !== status) {
+      onDrop?.(taskId, status);
+    }
+  }
+ 
+  function handleDragStart(e, task) {
+    e.dataTransfer.setData("taskId", task.id);
+    e.dataTransfer.setData("fromStatus", task.status);
+    e.dataTransfer.effectAllowed = "move";
+  }
+</script>
+ 
+<div class="flex flex-col h-full">
+  <!-- Column header -->
+  <div class="flex items-center justify-between mb-3 pb-2 border-b border-surface-300 dark:border-surface-600">
+    <h3 class="font-semibold text-surface-900 dark:text-surface-100">
+      {title}
+    </h3>
+    <span class="text-sm text-surface-600 dark:text-surface-400 bg-surface-200 dark:bg-surface-700 px-2 py-0.5 rounded-full">
+      {tasks.length}
+    </span>
+  </div>
+ 
+  <!-- Drop zone -->
+  <div
+    class="flex-1 min-h-[200px] p-2 rounded-lg border-2 border-dashed transition-all {columnColors[status] || columnColors.parking_lot}"
+    ondragover={handleDragOver}
+    ondragleave={handleDragLeave}
+    ondrop={handleDrop}
+  >
+    {#if tasks.length === 0}
+      <div class="flex items-center justify-center h-32 text-surface-400 dark:text-surface-500 text-sm">
+        Drop tasks here
+      </div>
+    {:else}
+      {#each tasks as task (task.id)}
+        <div draggable="true" ondragstart={(e) => handleDragStart(e, task)}>
+          <TaskCard
+            {task}
+            {agents}
+            {onEdit}
+            {onDelete}
+            {onAssign}
+          />
+        </div>
+      {/each}
+    {/if}
+  </div>
+</div>
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/lcov-report/src/components/opc/TaskCard.svelte.html b/dashboard/frontend/coverage/lcov-report/src/components/opc/TaskCard.svelte.html new file mode 100644 index 0000000..59abd16 --- /dev/null +++ b/dashboard/frontend/coverage/lcov-report/src/components/opc/TaskCard.svelte.html @@ -0,0 +1,592 @@ + + + + + + Code coverage report for src/components/opc/TaskCard.svelte + + + + + + + + + +
+
+

All files / src/components/opc TaskCard.svelte

+
+ +
+ 0% + Statements + 0/94 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/94 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
<script>
+  import { deleteTask, assignTask, getTaskTime } from "../../lib/opc-api.js";
+ 
+  let { task, agents = [], onEdit, onDelete, onAssign } = $props();
+ 
+  let showActions = $state(false);
+  let timeInfo = $state(null);
+ 
+  const priorityColors = {
+    low: "bg-slate-200 text-slate-700",
+    medium: "bg-blue-100 text-blue-700",
+    high: "bg-amber-100 text-amber-700",
+    urgent: "bg-rose-100 text-rose-700"
+  };
+ 
+  async function loadTimeInfo() {
+    if (task.status === "in_progress") {
+      try {
+        timeInfo = await getTaskTime(task.id);
+      } catch (e) {
+        console.error("Failed to load time info:", e);
+      }
+    }
+  }
+ 
+  $effect(() => {
+    loadTimeInfo();
+  });
+ 
+  async function handleDelete() {
+    if (confirm(`Delete task "${task.title}"?`)) {
+      try {
+        await deleteTask(task.id);
+        onDelete?.(task.id);
+      } catch (e) {
+        alert("Failed to delete task: " + e.message);
+      }
+    }
+  }
+ 
+  async function handleAssignAgent(agentId) {
+    try {
+      await assignTask(task.id, agentId, "agent");
+      onAssign?.(task.id, agentId, "agent");
+    } catch (e) {
+      alert("Failed to assign agent: " + e.message);
+    }
+  }
+ 
+  function getAgentIcon(agentId) {
+    const icons = {
+      pm: "📋",
+      cto: "💻",
+      coo: "⚙️",
+      ceo: "🎯",
+      marketing: "📢",
+      social_media: "📱"
+    };
+    return icons[agentId] || "🤖";
+  }
+</script>
+ 
+<div
+  class="bg-white dark:bg-surface-800 rounded-lg p-3 mb-2 shadow-sm border border-surface-200 dark:border-surface-700 cursor-move hover:shadow-md transition-shadow"
+  onmouseenter={() => showActions = true}
+  onmouseleave={() => showActions = false}
+>
+  <!-- Priority badge -->
+  <div class="flex items-start justify-between mb-2">
+    <span class="text-xs px-2 py-0.5 rounded {priorityColors[task.priority] || priorityColors.medium}">
+      {task.priority}
+    </span>
+    {#if showActions}
+      <div class="flex gap-1">
+        <button
+          onclick={() => onEdit?.(task)}
+          class="text-xs text-surface-600 hover:text-primary-600 dark:text-surface-400"
+          title="Edit"
+        >
+          ✏️
+        </button>
+        <button
+          onclick={handleDelete}
+          class="text-xs text-surface-600 hover:text-rose-600 dark:text-surface-400"
+          title="Delete"
+        >
+          🗑️
+        </button>
+      </div>
+    {/if}
+  </div>
+ 
+  <!-- Title -->
+  <h4 class="font-medium text-surface-900 dark:text-surface-100 mb-1 text-sm">
+    {task.title}
+  </h4>
+ 
+  <!-- Description preview -->
+  {#if task.description}
+    <p class="text-xs text-surface-600 dark:text-surface-400 mb-2 line-clamp-2">
+      {task.description}
+    </p>
+  {/if}
+ 
+  <!-- Tags -->
+  {#if task.tags && task.tags.length > 0}
+    <div class="flex flex-wrap gap-1 mb-2">
+      {#each task.tags as tag}
+        <span class="text-xs px-1.5 py-0.5 rounded bg-surface-100 dark:bg-surface-700 text-surface-700 dark:text-surface-300">
+          {tag}
+        </span>
+      {/each}
+    </div>
+  {/if}
+ 
+  <!-- Assignee and time -->
+  <div class="flex items-center justify-between text-xs text-surface-600 dark:text-surface-400">
+    <div class="flex items-center gap-1">
+      {#if task.assigned_to}
+        {#if task.assigned_type === "agent"}
+          <span title={task.assigned_to}>
+            {getAgentIcon(task.assigned_to)}
+          </span>
+        {:else}
+          <span>👤 {task.assigned_to}</span>
+        {/if}
+      {:else}
+        <button
+          onclick={() => showActions = true}
+          class="text-surface-500 hover:text-primary-600"
+          title="Assign"
+        >
+          Unassigned
+        </button>
+      {/if}
+    </div>
+ 
+    {#if timeInfo && timeInfo.total_hours > 0}
+      <span class="text-xs text-surface-500">
+        ⏱️ {timeInfo.total_hours}h
+      </span>
+    {/if}
+  </div>
+ 
+  <!-- Due date -->
+  {#if task.due_date}
+    <div class="text-xs text-surface-500 mt-1">
+      📅 {new Date(task.due_date).toLocaleDateString()}
+    </div>
+  {/if}
+ 
+  <!-- Quick assign to agent (when hovering) -->
+  {#if showActions && !task.assigned_to}
+    <div class="mt-2 pt-2 border-t border-surface-200 dark:border-surface-700">
+      <div class="text-xs text-surface-600 mb-1">Assign to agent:</div>
+      <div class="flex gap-1">
+        {#each agents as agent}
+          <button
+            onclick={() => handleAssignAgent(agent.id)}
+            class="text-lg hover:scale-110 transition-transform"
+            title={agent.name}
+          >
+            {getAgentIcon(agent.id)}
+          </button>
+        {/each}
+      </div>
+    </div>
+  {/if}
+</div>
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/lcov-report/src/components/opc/TaskModal.svelte.html b/dashboard/frontend/coverage/lcov-report/src/components/opc/TaskModal.svelte.html new file mode 100644 index 0000000..aa7ab89 --- /dev/null +++ b/dashboard/frontend/coverage/lcov-report/src/components/opc/TaskModal.svelte.html @@ -0,0 +1,835 @@ + + + + + + Code coverage report for src/components/opc/TaskModal.svelte + + + + + + + + + +
+
+

All files / src/components/opc TaskModal.svelte

+
+ +
+ 0% + Statements + 0/110 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/110 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
<script>
+  import { createTask, updateTask } from "../../lib/opc-api.js";
+ 
+  let { task = null, agents = [], onClose, onSave } = $props();
+ 
+  let isEdit = $state(!!task);
+  let formData = $state({
+    title: task?.title || "",
+    description: task?.description || "",
+    status: task?.status || "parking_lot",
+    priority: task?.priority || "medium",
+    assigned_to: task?.assigned_to || "",
+    assigned_type: task?.assigned_type || "human",
+    tags: task?.tags || [],
+    due_date: task?.due_date ? task.due_date.split("T")[0] : ""
+  });
+ 
+  let tagInput = $state("");
+  let saving = $state(false);
+ 
+  function addTag() {
+    if (tagInput.trim() && !formData.tags.includes(tagInput.trim())) {
+      formData.tags = [...formData.tags, tagInput.trim()];
+      tagInput = "";
+    }
+  }
+ 
+  function removeTag(tag) {
+    formData.tags = formData.tags.filter(t => t !== tag);
+  }
+ 
+  async function handleSubmit(e) {
+    e.preventDefault();
+    saving = true;
+ 
+    try {
+      const payload = {
+        ...formData,
+        due_date: formData.due_date ? new Date(formData.due_date).toISOString() : null,
+        assigned_to: formData.assigned_to || null
+      };
+ 
+      if (isEdit) {
+        await updateTask(task.id, payload);
+      } else {
+        await createTask(payload);
+      }
+ 
+      onSave?.();
+      onClose?.();
+    } catch (e) {
+      alert("Failed to save task: " + e.message);
+    } finally {
+      saving = false;
+    }
+  }
+</script>
+ 
+<!-- Modal backdrop -->
+<div
+  class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
+  onclick={(e) => e.target === e.currentTarget && onClose?.()}
+>
+  <!-- Modal content -->
+  <div class="bg-white dark:bg-surface-800 rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
+    <!-- Header -->
+    <div class="flex items-center justify-between p-4 border-b border-surface-200 dark:border-surface-700">
+      <h2 class="text-xl font-semibold text-surface-900 dark:text-surface-100">
+        {isEdit ? "Edit Task" : "Create Task"}
+      </h2>
+      <button
+        onclick={onClose}
+        class="text-surface-500 hover:text-surface-700 dark:hover:text-surface-300"
+      >
+        ✕
+      </button>
+    </div>
+ 
+    <!-- Form -->
+    <form onsubmit={handleSubmit} class="p-4 space-y-4">
+      <!-- Title -->
+      <div>
+        <label class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
+          Title *
+        </label>
+        <input
+          type="text"
+          bind:value={formData.title}
+          required
+          class="w-full px-3 py-2 border border-surface-300 dark:border-surface-600 rounded-lg bg-white dark:bg-surface-900 text-surface-900 dark:text-surface-100"
+          placeholder="Task title"
+        />
+      </div>
+ 
+      <!-- Description -->
+      <div>
+        <label class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
+          Description
+        </label>
+        <textarea
+          bind:value={formData.description}
+          rows="4"
+          class="w-full px-3 py-2 border border-surface-300 dark:border-surface-600 rounded-lg bg-white dark:bg-surface-900 text-surface-900 dark:text-surface-100"
+          placeholder="Task description"
+        ></textarea>
+      </div>
+ 
+      <!-- Status and Priority -->
+      <div class="grid grid-cols-2 gap-4">
+        <div>
+          <label class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
+            Status
+          </label>
+          <select
+            bind:value={formData.status}
+            class="w-full px-3 py-2 border border-surface-300 dark:border-surface-600 rounded-lg bg-white dark:bg-surface-900 text-surface-900 dark:text-surface-100"
+          >
+            <option value="parking_lot">Parking Lot</option>
+            <option value="in_progress">In Progress</option>
+            <option value="done">Done</option>
+          </select>
+        </div>
+ 
+        <div>
+          <label class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
+            Priority
+          </label>
+          <select
+            bind:value={formData.priority}
+            class="w-full px-3 py-2 border border-surface-300 dark:border-surface-600 rounded-lg bg-white dark:bg-surface-900 text-surface-900 dark:text-surface-100"
+          >
+            <option value="low">Low</option>
+            <option value="medium">Medium</option>
+            <option value="high">High</option>
+            <option value="urgent">Urgent</option>
+          </select>
+        </div>
+      </div>
+ 
+      <!-- Assignee -->
+      <div class="grid grid-cols-2 gap-4">
+        <div>
+          <label class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
+            Assign to
+          </label>
+          <select
+            bind:value={formData.assigned_type}
+            class="w-full px-3 py-2 border border-surface-300 dark:border-surface-600 rounded-lg bg-white dark:bg-surface-900 text-surface-900 dark:text-surface-100"
+          >
+            <option value="human">Human</option>
+            <option value="agent">Agent</option>
+          </select>
+        </div>
+ 
+        <div>
+          <label class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
+            {formData.assigned_type === "agent" ? "Agent" : "Username"}
+          </label>
+          {#if formData.assigned_type === "agent"}
+            <select
+              bind:value={formData.assigned_to}
+              class="w-full px-3 py-2 border border-surface-300 dark:border-surface-600 rounded-lg bg-white dark:bg-surface-900 text-surface-900 dark:text-surface-100"
+            >
+              <option value="">Unassigned</option>
+              {#each agents as agent}
+                <option value={agent.id}>{agent.name}</option>
+              {/each}
+            </select>
+          {:else}
+            <input
+              type="text"
+              bind:value={formData.assigned_to}
+              class="w-full px-3 py-2 border border-surface-300 dark:border-surface-600 rounded-lg bg-white dark:bg-surface-900 text-surface-900 dark:text-surface-100"
+              placeholder="Username"
+            />
+          {/if}
+        </div>
+      </div>
+ 
+      <!-- Due date -->
+      <div>
+        <label class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
+          Due Date
+        </label>
+        <input
+          type="date"
+          bind:value={formData.due_date}
+          class="w-full px-3 py-2 border border-surface-300 dark:border-surface-600 rounded-lg bg-white dark:bg-surface-900 text-surface-900 dark:text-surface-100"
+        />
+      </div>
+ 
+      <!-- Tags -->
+      <div>
+        <label class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
+          Tags
+        </label>
+        <div class="flex gap-2 mb-2">
+          <input
+            type="text"
+            bind:value={tagInput}
+            onkeydown={(e) => e.key === "Enter" && (e.preventDefault(), addTag())}
+            class="flex-1 px-3 py-2 border border-surface-300 dark:border-surface-600 rounded-lg bg-white dark:bg-surface-900 text-surface-900 dark:text-surface-100"
+            placeholder="Add tag and press Enter"
+          />
+          <button
+            type="button"
+            onclick={addTag}
+            class="px-4 py-2 bg-surface-200 dark:bg-surface-700 text-surface-700 dark:text-surface-300 rounded-lg hover:bg-surface-300 dark:hover:bg-surface-600"
+          >
+            Add
+          </button>
+        </div>
+        {#if formData.tags.length > 0}
+          <div class="flex flex-wrap gap-2">
+            {#each formData.tags as tag}
+              <span class="inline-flex items-center gap-1 px-2 py-1 bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 rounded text-sm">
+                {tag}
+                <button
+                  type="button"
+                  onclick={() => removeTag(tag)}
+                  class="hover:text-primary-900 dark:hover:text-primary-100"
+                >
+                  ✕
+                </button>
+              </span>
+            {/each}
+          </div>
+        {/if}
+      </div>
+ 
+      <!-- Actions -->
+      <div class="flex justify-end gap-2 pt-4 border-t border-surface-200 dark:border-surface-700">
+        <button
+          type="button"
+          onclick={onClose}
+          class="px-4 py-2 text-surface-700 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-700 rounded-lg"
+        >
+          Cancel
+        </button>
+        <button
+          type="submit"
+          disabled={saving}
+          class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
+        >
+          {saving ? "Saving..." : isEdit ? "Update" : "Create"}
+        </button>
+      </div>
+    </form>
+  </div>
+</div>
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/lcov-report/src/components/opc/index.html b/dashboard/frontend/coverage/lcov-report/src/components/opc/index.html new file mode 100644 index 0000000..c610c13 --- /dev/null +++ b/dashboard/frontend/coverage/lcov-report/src/components/opc/index.html @@ -0,0 +1,176 @@ + + + + + + Code coverage report for src/components/opc + + + + + + + + + +
+
+

All files src/components/opc

+
+ +
+ 0% + Statements + 0/393 +
+ + +
+ 0% + Branches + 0/5 +
+ + +
+ 0% + Functions + 0/5 +
+ + +
+ 0% + Lines + 0/393 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
AgentPanel.svelte +
+
0%0/1170%0/10%0/10%0/117
KanbanBoard.svelte +
+
0%0/260%0/10%0/10%0/26
KanbanColumn.svelte +
+
0%0/460%0/10%0/10%0/46
TaskCard.svelte +
+
0%0/940%0/10%0/10%0/94
TaskModal.svelte +
+
0%0/1100%0/10%0/10%0/110
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/lcov-report/src/index.html b/dashboard/frontend/coverage/lcov-report/src/index.html new file mode 100644 index 0000000..e0d864b --- /dev/null +++ b/dashboard/frontend/coverage/lcov-report/src/index.html @@ -0,0 +1,131 @@ + + + + + + Code coverage report for src + + + + + + + + + +
+
+

All files src

+
+ +
+ 0% + Statements + 0/182 +
+ + +
+ 0% + Branches + 0/2 +
+ + +
+ 0% + Functions + 0/2 +
+ + +
+ 0% + Lines + 0/182 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
App.svelte +
+
0%0/1770%0/10%0/10%0/177
main.js +
+
0%0/50%0/10%0/10%0/5
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/lcov-report/src/lib/api.js.html b/dashboard/frontend/coverage/lcov-report/src/lib/api.js.html new file mode 100644 index 0000000..21ed412 --- /dev/null +++ b/dashboard/frontend/coverage/lcov-report/src/lib/api.js.html @@ -0,0 +1,787 @@ + + + + + + Code coverage report for src/lib/api.js + + + + + + + + + +
+
+

All files / src/lib api.js

+
+ +
+ 64.92% + Statements + 124/191 +
+ + +
+ 90.69% + Branches + 39/43 +
+ + +
+ 57.69% + Functions + 15/26 +
+ + +
+ 64.92% + Lines + 124/191 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +2351x +1x +  +1x +1x +  +1x +1x +1x +  +1x +30x +30x +12x +30x +  +1x +5x +5x +  +1x +1x +  +1x +  +3x +3x +3x +  +3x +3x +3x +  +6x +6x +6x +6x +6x +6x +6x +6x +3x +6x +6x +6x +  +1x +5x +5x +5x +5x +5x +2x +2x +2x +  +3x +5x +1x +1x +1x +1x +1x +  +  +  +  +5x +5x +5x +5x +5x +5x +  +20x +20x +20x +  +20x +3x +3x +3x +3x +  +20x +20x +  +20x +20x +  +20x +2x +2x +1x +1x +1x +2x +  +20x +1x +1x +1x +  +20x +2x +2x +2x +2x +2x +16x +20x +4x +4x +4x +20x +  +1x +13x +13x +  +1x +2x +2x +  +1x +1x +1x +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +1x +  +1x +1x +1x +  +1x +1x +1x +  +1x +  +  +  +1x +  +  +  +1x +  +  +  +1x +  +  +  +1x +  +  +  +1x +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +1x +1x +1x +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
const BASE = "/api";
+let token = "";
+ 
+// Current user info populated after auth
+export let currentUser = { username: "", role: "", pages: [] };
+ 
+export function setCurrentUser(u) {
+  currentUser = u;
+}
+ 
+export function setToken(t) {
+  token = t;
+  if (t) localStorage.setItem("token", t);
+  else localStorage.removeItem("token");
+}
+ 
+export function getToken() {
+  return token;
+}
+ 
+// Refresh token is cookie-managed server-side.
+export function setRefreshToken() {}
+ 
+let refreshPromise = null;
+ 
+function getLegacyRefreshToken() {
+  return localStorage.getItem("refresh_token") || "";
+}
+ 
+function clearLegacyRefreshToken() {
+  localStorage.removeItem("refresh_token");
+}
+ 
+async function requestRefresh(body) {
+  const r = await fetch(BASE + "/auth/refresh", {
+    method: "POST",
+    headers: { "Content-Type": "application/json" },
+    credentials: "same-origin",
+    body: body ? JSON.stringify(body) : undefined,
+  });
+  if (!r.ok) return false;
+  const data = await r.json();
+  setToken(data.access_token || "");
+  return !!data.access_token;
+}
+ 
+export async function tryRefreshSession() {
+  if (refreshPromise) return refreshPromise;
+  refreshPromise = (async () => {
+    try {
+      const cookieRefreshed = await requestRefresh();
+      if (cookieRefreshed) {
+        clearLegacyRefreshToken();
+        return true;
+      }
+ 
+      const legacyRefreshToken = getLegacyRefreshToken();
+      if (!legacyRefreshToken) return false;
+      const legacyRefreshed = await requestRefresh({ refresh_token: legacyRefreshToken });
+      if (legacyRefreshed) {
+        clearLegacyRefreshToken();
+        return true;
+      }
+      clearLegacyRefreshToken();
+      return false;
+    } catch {
+      return false;
+    } finally {
+      refreshPromise = null;
+    }
+  })();
+  return refreshPromise;
+}
+ 
+async function request(path, opts = {}) {
+  const headers = opts.headers || {};
+  if (token) headers["Authorization"] = `Bearer ${token}`;
+ 
+  if (opts.json) {
+    headers["Content-Type"] = "application/json";
+    opts.body = JSON.stringify(opts.json);
+    delete opts.json;
+  }
+ 
+  const fetchOpts = { ...opts, headers };
+  if (path.startsWith("/auth/")) fetchOpts.credentials = "same-origin";
+ 
+  try {
+    let r = await fetch(BASE + path, fetchOpts);
+ 
+    if (r.status === 401 && !path.includes("/auth/refresh")) {
+      const refreshed = await tryRefreshSession();
+      if (refreshed) {
+        headers["Authorization"] = `Bearer ${token}`;
+        r = await fetch(BASE + path, fetchOpts);
+      }
+    }
+ 
+    if (r.status === 401) {
+      setToken("");
+      throw new Error("Unauthorized");
+    }
+ 
+    if (!r.ok) {
+      const error = new Error(`HTTP ${r.status}`);
+      error.status = r.status;
+      try { error.body = await r.json(); } catch {}
+      throw error;
+    }
+    return r.json();
+  } catch (e) {
+    console.error(`API ${opts.method || "GET"} ${path}:`, e);
+    throw e;
+  }
+}
+ 
+export function get(path) {
+  return request(path);
+}
+ 
+export function post(path, data) {
+  return request(path, { method: "POST", json: data });
+}
+ 
+export function del(path) {
+  return request(path, { method: "DELETE" });
+}
+ 
+export async function upload(path, file) {
+  const headers = {};
+  if (token) headers["Authorization"] = `Bearer ${token}`;
+ 
+  const form = new FormData();
+  form.append("file", file);
+ 
+  const r = await fetch(`${BASE}/files/upload?path=${encodeURIComponent(path)}`, {
+    method: "POST",
+    headers,
+    body: form,
+  });
+ 
+  if (!r.ok) throw new Error(`HTTP ${r.status}`);
+  return r.json();
+}
+ 
+export function login(creds) {
+  return request("/auth/login", { method: "POST", json: creds });
+}
+ 
+export function checkAuth() {
+  return request("/auth/me");
+}
+ 
+export function logout() {
+  return request("/auth/logout", { method: "POST" });
+}
+ 
+export function getPreferences() {
+  return get("/auth/preferences");
+}
+ 
+export function savePreferences(data) {
+  return request("/auth/preferences", { method: "PUT", json: data });
+}
+ 
+export function getLiteLLMHealth() {
+  return get("/litellm/health");
+}
+ 
+export function getCcConnectHealth() {
+  return get("/cc-connect/health");
+}
+ 
+export function startCcConnect() {
+  return post("/cc-connect/start");
+}
+ 
+export function stopCcConnect() {
+  return post("/cc-connect/stop");
+}
+ 
+export function getInfoEngineItems(params = {}) {
+  const query = new URLSearchParams();
+  if (params.limit !== undefined) query.set("limit", String(params.limit));
+  if (params.offset !== undefined) query.set("offset", String(params.offset));
+  if (params.source) query.set("source", params.source);
+  if (params.tag) query.set("tag", params.tag);
+  if (params.since) query.set("since", params.since);
+  const suffix = query.toString() ? `?${query.toString()}` : "";
+  return get(`/info-engine/items${suffix}`);
+}
+ 
+export function getInfoEngineItem(id) {
+  return get(`/info-engine/items/${id}`);
+}
+ 
+export function put(path, data) {
+  return request(path, { method: "PUT", json: data });
+}
+ 
+export async function download(path, filename) {
+  const headers = {};
+  if (token) headers["Authorization"] = `Bearer ${token}`;
+ 
+  try {
+    // Get a temporary download token
+    const tokenResponse = await fetch(`${BASE}/files/download-token?path=${encodeURIComponent(path)}`, {
+      method: "POST",
+      headers,
+    });
+ 
+    if (!tokenResponse.ok) {
+      const errorText = await tokenResponse.text();
+      console.error(`Download token failed: ${tokenResponse.status} - ${errorText}`);
+      throw new Error(`Download failed: ${tokenResponse.status}`);
+    }
+ 
+    const { token: downloadToken } = await tokenResponse.json();
+ 
+    // Use hidden iframe for download - avoids page navigation and popup blockers
+    const iframe = document.createElement('iframe');
+    iframe.style.display = 'none';
+    iframe.src = `${BASE}/files/download?token=${downloadToken}`;
+    document.body.appendChild(iframe);
+ 
+    // Clean up iframe after download starts
+    setTimeout(() => {
+      document.body.removeChild(iframe);
+    }, 5000);
+  } catch (e) {
+    console.error("Download error:", e);
+    throw e;
+  }
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/lcov-report/src/lib/index.html b/dashboard/frontend/coverage/lcov-report/src/lib/index.html new file mode 100644 index 0000000..0176ae0 --- /dev/null +++ b/dashboard/frontend/coverage/lcov-report/src/lib/index.html @@ -0,0 +1,161 @@ + + + + + + Code coverage report for src/lib + + + + + + + + + +
+
+

All files src/lib

+
+ +
+ 22.06% + Statements + 124/562 +
+ + +
+ 84.78% + Branches + 39/46 +
+ + +
+ 51.72% + Functions + 15/29 +
+ + +
+ 22.06% + Lines + 124/562 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
api.js +
+
64.92%124/19190.69%39/4357.69%15/2664.92%124/191
opc-api.js +
+
0%0/640%0/10%0/10%0/64
opc-ws.js +
+
0%0/750%0/10%0/10%0/75
voice.js +
+
0%0/2320%0/10%0/10%0/232
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/lcov-report/src/lib/opc-api.js.html b/dashboard/frontend/coverage/lcov-report/src/lib/opc-api.js.html new file mode 100644 index 0000000..8cd5fc5 --- /dev/null +++ b/dashboard/frontend/coverage/lcov-report/src/lib/opc-api.js.html @@ -0,0 +1,325 @@ + + + + + + Code coverage report for src/lib/opc-api.js + + + + + + + + + +
+
+

All files / src/lib opc-api.js

+
+ +
+ 0% + Statements + 0/64 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/64 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
/**
+ * OPC API Client
+ */
+import { get, post, put, del } from "./api.js";
+ 
+// Tasks
+export async function getTasks(filters = {}) {
+  const params = new URLSearchParams();
+  if (filters.status) params.append("status", filters.status);
+  if (filters.assigned_to) params.append("assigned_to", filters.assigned_to);
+  if (filters.project_id) params.append("project_id", filters.project_id);
+  if (filters.priority) params.append("priority", filters.priority);
+  if (filters.tags) params.append("tags", filters.tags.join(","));
+  if (filters.limit) params.append("limit", filters.limit);
+  if (filters.offset) params.append("offset", filters.offset);
+ 
+  const query = params.toString();
+  return get(`/opc/tasks${query ? "?" + query : ""}`);
+}
+ 
+export async function createTask(task) {
+  return post("/opc/tasks", task);
+}
+ 
+export async function updateTask(taskId, updates) {
+  return put(`/opc/tasks/${taskId}`, updates);
+}
+ 
+export async function deleteTask(taskId) {
+  return del(`/opc/tasks/${taskId}`);
+}
+ 
+export async function moveTask(taskId, status) {
+  return put(`/opc/tasks/${taskId}/move`, { status });
+}
+ 
+export async function assignTask(taskId, assigned_to, assigned_type = "human") {
+  return post(`/opc/tasks/${taskId}/assign`, { assigned_to, assigned_type });
+}
+ 
+export async function getTaskHistory(taskId) {
+  return get(`/opc/tasks/${taskId}/history`);
+}
+ 
+export async function getTaskTime(taskId) {
+  return get(`/opc/tasks/${taskId}/time`);
+}
+ 
+// Agents
+export async function getAgents(enabledOnly = true) {
+  return get(`/opc/agents?enabled_only=${enabledOnly}`);
+}
+ 
+export async function getAgent(agentId) {
+  return get(`/opc/agents/${agentId}`);
+}
+ 
+export async function triggerAgent(agentId, taskId) {
+  return post(`/opc/agents/${agentId}/execute?task_id=${taskId}`);
+}
+ 
+// Executions
+export async function getExecutions(filters = {}) {
+  const params = new URLSearchParams();
+  if (filters.task_id) params.append("task_id", filters.task_id);
+  if (filters.agent_id) params.append("agent_id", filters.agent_id);
+  if (filters.status) params.append("status", filters.status);
+  if (filters.limit) params.append("limit", filters.limit);
+ 
+  const query = params.toString();
+  return get(`/opc/executions${query ? "?" + query : ""}`);
+}
+ 
+export async function getExecution(executionId) {
+  return get(`/opc/executions/${executionId}`);
+}
+ 
+export async function approveExecution(executionId, approved) {
+  return post(`/opc/executions/${executionId}/approve`, { approved });
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/lcov-report/src/lib/opc-ws.js.html b/dashboard/frontend/coverage/lcov-report/src/lib/opc-ws.js.html new file mode 100644 index 0000000..fa89c0d --- /dev/null +++ b/dashboard/frontend/coverage/lcov-report/src/lib/opc-ws.js.html @@ -0,0 +1,352 @@ + + + + + + Code coverage report for src/lib/opc-ws.js + + + + + + + + + +
+
+

All files / src/lib opc-ws.js

+
+ +
+ 0% + Statements + 0/75 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/75 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
/**
+ * OPC WebSocket Client - Real-time updates
+ */
+ 
+import { getToken } from "./api.js";
+ 
+let ws = null;
+let reconnectTimer = null;
+let listeners = new Set();
+ 
+export function connect() {
+  if (ws && ws.readyState === WebSocket.OPEN) {
+    return;
+  }
+ 
+  const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
+  const token = getToken();
+  const tokenParam = token ? `?token=${encodeURIComponent(token)}` : "";
+  const wsUrl = `${protocol}//${window.location.host}/ws/opc${tokenParam}`;
+ 
+  ws = new WebSocket(wsUrl);
+ 
+  ws.onopen = () => {
+    console.log("OPC WebSocket connected");
+    if (reconnectTimer) {
+      clearTimeout(reconnectTimer);
+      reconnectTimer = null;
+    }
+ 
+    // Send ping every 30 seconds to keep connection alive
+    const pingInterval = setInterval(() => {
+      if (ws && ws.readyState === WebSocket.OPEN) {
+        ws.send("ping");
+      } else {
+        clearInterval(pingInterval);
+      }
+    }, 30000);
+  };
+ 
+  ws.onmessage = (event) => {
+    try {
+      const message = JSON.parse(event.data);
+      notifyListeners(message);
+    } catch (e) {
+      console.error("Failed to parse WebSocket message:", e);
+    }
+  };
+ 
+  ws.onerror = (error) => {
+    console.error("WebSocket error:", error);
+  };
+ 
+  ws.onclose = () => {
+    console.log("OPC WebSocket disconnected");
+    ws = null;
+ 
+    // Reconnect after 5 seconds
+    reconnectTimer = setTimeout(() => {
+      console.log("Reconnecting WebSocket...");
+      connect();
+    }, 5000);
+  };
+}
+ 
+export function disconnect() {
+  if (ws) {
+    ws.close();
+    ws = null;
+  }
+  if (reconnectTimer) {
+    clearTimeout(reconnectTimer);
+    reconnectTimer = null;
+  }
+}
+ 
+export function subscribe(callback) {
+  listeners.add(callback);
+  return () => listeners.delete(callback);
+}
+ 
+function notifyListeners(message) {
+  listeners.forEach((callback) => {
+    try {
+      callback(message);
+    } catch (e) {
+      console.error("Listener error:", e);
+    }
+  });
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/lcov-report/src/lib/voice.js.html b/dashboard/frontend/coverage/lcov-report/src/lib/voice.js.html new file mode 100644 index 0000000..c7a8dc5 --- /dev/null +++ b/dashboard/frontend/coverage/lcov-report/src/lib/voice.js.html @@ -0,0 +1,847 @@ + + + + + + Code coverage report for src/lib/voice.js + + + + + + + + + +
+
+

All files / src/lib voice.js

+
+ +
+ 0% + Statements + 0/232 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/232 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
const LANGS = ["en-US", "zh-CN", "zh-HK"];
+const LANG_LABELS = ["EN", "普", "粤"];
+const TALK_MODES = ["auto", "tap", "hold"];
+const MODE_LABELS = { auto: "Auto", tap: "Tap", hold: "Hold" };
+const NOISE = /^[\s│┃┆┇┊┋─━┄┅┈┉═╌╍╔╗╚╝╠╣╦╩╬├┤┬┴┼╭╮╯╰░▒▓█▌▐▀▄⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏◐◑◒◓⣾⣽⣻⢿⡿⣟⣯⣷⠁⠂⠄⡀⢀⠠⠐⠈\-–—_=+*#~<>/.\\|,;:!?(){}\[\]]+$/;
+const SKIP = /shortcuts|\/help|\/exit|press esc|ctrl[+-]|⌘|⎋|❯|^\$\s|^root@|^claude|^jimmy|^\[.*\]$/i;
+const MODE_COMMANDS = {
+  "hold mode": "hold", "tap mode": "tap", "auto mode": "auto",
+  "按住模式": "hold", "点击模式": "tap", "自动模式": "auto",
+};
+ 
+export class VoiceSession {
+  constructor(onChange) {
+    this.onChange = onChange;
+    this.ttsEnabled = false;
+    this.speaking = false;
+    this.listening = false;
+    this.sttAvailable = !!(window.SpeechRecognition || window.webkitSpeechRecognition);
+    this.langIdx = 0;
+    this.voiceMode = false;
+    this.transcript = "";
+    this.talkMode = "auto";
+    this.voices = [];
+    this.voiceIdx = -1;
+    this.term = null;
+    this.ws = null;
+    this._lastLine = 0;
+    this._debounce = null;
+    this._recog = null;
+    this._onWriteDispose = null;
+    this._ttsUnlocked = false;
+    this._silenceTimer = null;
+  }
+ 
+  get lang() { return LANGS[this.langIdx]; }
+  get langLabel() { return LANG_LABELS[this.langIdx]; }
+  get modeLabel() { return MODE_LABELS[this.talkMode]; }
+  get voiceName() { return this.voices[this.voiceIdx]?.name || "Default"; }
+ 
+  loadVoices() {
+    const all = speechSynthesis.getVoices();
+    const prefix = this.lang.split("-")[0];
+    this.voices = all.filter(v => v.lang.startsWith(prefix));
+    // Try to keep current selection
+    if (this.voiceIdx >= this.voices.length) this.voiceIdx = 0;
+    if (!this.voices.length) this.voiceIdx = -1;
+    this._notify();
+  }
+ 
+  setVoice(idx) {
+    this.voiceIdx = idx;
+    this._notify();
+  }
+ 
+  attach(term, ws) {
+    this.term = term;
+    this.ws = ws;
+    this._lastLine = term.buffer.active.cursorY + term.buffer.active.baseY;
+    this._onWriteDispose = term.onWriteParsed(() => this._onOutput());
+  }
+ 
+  _notify() { this.onChange?.(); }
+ 
+  // --- Voice Mode ---
+  enterVoiceMode() {
+    this.voiceMode = true;
+    this.ttsEnabled = true;
+    if (!this._ttsUnlocked) {
+      speechSynthesis.speak(new SpeechSynthesisUtterance(""));
+      this._ttsUnlocked = true;
+    }
+    this.loadVoices();
+    speechSynthesis.onvoiceschanged = () => this.loadVoices();
+    if (this.term) {
+      this._lastLine = this.term.buffer.active.baseY + this.term.buffer.active.cursorY;
+    }
+    if (this.talkMode === "auto") this._startSTT(true);
+    this._notify();
+  }
+ 
+  exitVoiceMode() {
+    this.voiceMode = false;
+    this.ttsEnabled = false;
+    this.transcript = "";
+    speechSynthesis.cancel();
+    this.speaking = false;
+    this._stopSTT();
+    this._notify();
+  }
+ 
+  setTalkMode(mode) {
+    this.talkMode = mode;
+    this._stopSTT();
+    this.transcript = "";
+    if (mode === "auto" && this.voiceMode) this._startSTT(true);
+    this._notify();
+  }
+ 
+  cycleTalkMode() {
+    const i = (TALK_MODES.indexOf(this.talkMode) + 1) % TALK_MODES.length;
+    this.setTalkMode(TALK_MODES[i]);
+  }
+ 
+  // Hold mode
+  startHold() {
+    if (this.talkMode !== "hold") return;
+    this._startSTT(false);
+  }
+ 
+  releaseHold() {
+    if (this.talkMode !== "hold") return;
+    this._sendAndStop();
+  }
+ 
+  // Tap mode
+  tapMic() {
+    if (this.talkMode !== "tap") return;
+    if (this.listening) { this._sendAndStop(); }
+    else { this._startSTT(false); }
+  }
+ 
+  _sendAndStop() {
+    const text = this.transcript.trim();
+    this._stopSTT();
+    if (text && this.ws?.readyState === 1) this._typeText(text + "\r");
+    this.transcript = "";
+    this._notify();
+  }
+ 
+  // --- STT ---
+  _startSTT(continuous) {
+    const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
+    if (!SR) return;
+    speechSynthesis.cancel();
+    this.speaking = false;
+    this._recog = new SR();
+    this._recog.continuous = continuous;
+    this._recog.interimResults = true;
+    this._recog.lang = this.lang;
+    this._finalText = "";
+    this._recog.onresult = (e) => {
+      let interim = "";
+      for (let i = e.resultIndex; i < e.results.length; i++) {
+        const t = e.results[i][0].transcript;
+        if (e.results[i].isFinal) {
+          // Check voice commands
+          const cmd = MODE_COMMANDS[t.trim().toLowerCase()];
+          if (cmd) { this.setTalkMode(cmd); return; }
+          this._finalText += t;
+        } else { interim = t; }
+      }
+      this.transcript = (this._finalText + interim).trim();
+      this._notify();
+      // Auto mode: reset silence timer on new results
+      if (this.talkMode === "auto" && this._finalText) {
+        clearTimeout(this._silenceTimer);
+        this._silenceTimer = setTimeout(() => this._autoSend(), 2000);
+      }
+    };
+    this._recog.onerror = () => { this.listening = false; this._notify(); };
+    this._recog.onend = () => {
+      if (this.listening && continuous) {
+        // Auto mode: send accumulated text, restart
+        if (this.talkMode === "auto" && this._finalText.trim()) this._autoSend();
+        try { this._recog.start(); } catch { this.listening = false; this._notify(); }
+      } else { this.listening = false; this._notify(); }
+    };
+    this._recog.start();
+    this.listening = true;
+    this._notify();
+  }
+ 
+  _stopSTT() {
+    this.listening = false;
+    clearTimeout(this._silenceTimer);
+    this._recog?.stop();
+    this._recog = null;
+    this._finalText = "";
+  }
+ 
+  _autoSend() {
+    clearTimeout(this._silenceTimer);
+    const text = this._finalText.trim();
+    this._finalText = "";
+    this.transcript = "";
+    if (text && this.ws?.readyState === 1) this._typeText(text + "\r");
+    this._notify();
+  }
+ 
+  // --- TTS ---
+  _onOutput() {
+    if (!this.ttsEnabled || this.transcript) return;
+    clearTimeout(this._debounce);
+    this._debounce = setTimeout(() => this._readBuffer(), 2000);
+  }
+ 
+  _readBuffer() {
+    const buf = this.term?.buffer?.active;
+    if (!buf || buf.type !== "normal") return;
+    const curLine = buf.baseY + buf.cursorY;
+    const lines = [];
+    for (let y = this._lastLine + 1; y <= curLine; y++) {
+      const row = buf.getLine(y);
+      if (!row) continue;
+      const text = row.translateToString(true).trim();
+      if (text.length >= 4 && !NOISE.test(text) && !SKIP.test(text)) lines.push(text);
+    }
+    this._lastLine = curLine;
+    if (lines.length) this._speak(lines.join(". "));
+  }
+ 
+  _speak(text) {
+    if (!text) return;
+    speechSynthesis.cancel();
+    const u = new SpeechSynthesisUtterance(text);
+    u.lang = this.lang;
+    if (this.voices[this.voiceIdx]) u.voice = this.voices[this.voiceIdx];
+    u.onstart = () => { this.speaking = true; this._notify(); };
+    u.onend = () => { this.speaking = false; this._notify(); };
+    u.onerror = () => { this.speaking = false; this._notify(); };
+    speechSynthesis.speak(u);
+  }
+ 
+  _typeText(text) {
+    let i = 0;
+    const next = () => {
+      if (i < text.length && this.ws?.readyState === 1) {
+        this.ws.send(new TextEncoder().encode(text[i++]));
+        setTimeout(next, 20);
+      }
+    };
+    next();
+  }
+ 
+  cycleLang() {
+    this.langIdx = (this.langIdx + 1) % LANGS.length;
+    this.loadVoices();
+    if (this.listening) {
+      const wasContinuous = this._recog?.continuous;
+      this._stopSTT();
+      this._startSTT(!!wasContinuous);
+    }
+    this._notify();
+  }
+ 
+  destroy() {
+    clearTimeout(this._debounce);
+    clearTimeout(this._silenceTimer);
+    this._onWriteDispose?.dispose();
+    this._stopSTT();
+    speechSynthesis.cancel();
+    this.term = null;
+    this.ws = null;
+  }
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/lcov-report/src/main.js.html b/dashboard/frontend/coverage/lcov-report/src/main.js.html new file mode 100644 index 0000000..c6b8749 --- /dev/null +++ b/dashboard/frontend/coverage/lcov-report/src/main.js.html @@ -0,0 +1,103 @@ + + + + + + Code coverage report for src/main.js + + + + + + + + + +
+
+

All files / src main.js

+
+ +
+ 0% + Statements + 0/5 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/5 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7  +  +  +  +  +  + 
import "./app.css";
+import App from "./App.svelte";
+import { mount } from "svelte";
+ 
+const app = mount(App, { target: document.getElementById("app") });
+export default app;
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/lcov-report/src/routes/CcConnect.svelte.html b/dashboard/frontend/coverage/lcov-report/src/routes/CcConnect.svelte.html new file mode 100644 index 0000000..b7914f3 --- /dev/null +++ b/dashboard/frontend/coverage/lcov-report/src/routes/CcConnect.svelte.html @@ -0,0 +1,550 @@ + + + + + + Code coverage report for src/routes/CcConnect.svelte + + + + + + + + + +
+
+

All files / src/routes CcConnect.svelte

+
+ +
+ 0% + Statements + 0/87 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/87 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
<script>
+  import { onMount } from "svelte";
+  import { getCcConnectHealth, startCcConnect, stopCcConnect } from "../lib/api.js";
+ 
+  let loading = $state(true);
+  let health = $state(null);
+  let requestError = $state("");
+  let actionLoading = $state("");
+ 
+  async function loadHealth() {
+    loading = true;
+    requestError = "";
+    try {
+      health = await getCcConnectHealth();
+    } catch (e) {
+      requestError = e?.body?.detail || e?.message || "Failed to load cc-connect health";
+      health = null;
+    } finally {
+      loading = false;
+    }
+  }
+ 
+  async function runAction(action) {
+    actionLoading = action;
+    requestError = "";
+    try {
+      const result = action === "start" ? await startCcConnect() : await stopCcConnect();
+      if (!result?.ok) {
+        requestError = result?.error || `Failed to ${action} cc-connect`;
+      }
+    } catch (e) {
+      requestError = e?.body?.detail || e?.message || `Failed to ${action} cc-connect`;
+    } finally {
+      actionLoading = "";
+      await loadHealth();
+    }
+  }
+ 
+  function statusBadgeClass(status) {
+    if (status === "up") {
+      return "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300";
+    }
+    return "bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300";
+  }
+ 
+  function valueOrDash(value) {
+    return value === null || value === undefined || value === "" ? "—" : value;
+  }
+ 
+  onMount(loadHealth);
+</script>
+ 
+<div class="space-y-6">
+  <div class="flex items-center justify-between">
+    <div>
+      <h1 class="text-2xl font-bold text-surface-900 dark:text-white tracking-tight">cc-connect</h1>
+      <p class="text-sm text-surface-400 mt-1">Mobile bridge operational status</p>
+      <div class="mt-2 flex flex-wrap items-center gap-2 text-[11px] font-medium">
+        <span class="px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">up</span>
+        <span class="px-2 py-0.5 rounded-full bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300">down</span>
+      </div>
+    </div>
+    <div class="flex items-center gap-2">
+      {#if health?.container_status === "running"}
+        <button
+          onclick={() => runAction("stop")}
+          disabled={loading || actionLoading === "stop"}
+          class="px-3 py-1.5 text-xs font-medium text-rose-600 bg-rose-50 hover:bg-rose-100 rounded-lg transition-colors disabled:opacity-50"
+        >
+          {actionLoading === "stop" ? "Stopping..." : "Stop"}
+        </button>
+      {:else}
+        <button
+          onclick={() => runAction("start")}
+          disabled={loading || actionLoading === "start"}
+          class="px-3 py-1.5 text-xs font-medium text-emerald-600 bg-emerald-50 hover:bg-emerald-100 rounded-lg transition-colors disabled:opacity-50"
+        >
+          {actionLoading === "start" ? "Starting..." : "Start"}
+        </button>
+      {/if}
+      <button onclick={loadHealth} disabled={loading || Boolean(actionLoading)} class="px-3 py-1.5 text-xs font-medium text-primary-600 bg-primary-50 hover:bg-primary-100 rounded-lg transition-colors disabled:opacity-50 dark:bg-primary-900/30 dark:text-primary-300">
+        {loading ? "Loading..." : "Refresh"}
+      </button>
+    </div>
+  </div>
+ 
+  {#if requestError}
+    <div class="bg-rose-50 dark:bg-rose-900/20 border border-rose-200 dark:border-rose-700 rounded-xl p-4 shadow-sm">
+      <p class="text-sm font-medium text-rose-700 dark:text-rose-300">cc-connect action failed</p>
+      <p class="text-xs text-rose-600 dark:text-rose-400 mt-1">{requestError}</p>
+    </div>
+  {/if}
+ 
+  <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
+    {#if loading && !health}
+      <div class="space-y-3">
+        <div class="h-6 w-36 rounded bg-surface-100 dark:bg-surface-700 animate-pulse"></div>
+        <div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
+          {#each Array(8) as _}
+            <div class="h-16 rounded-lg bg-surface-100 dark:bg-surface-700 animate-pulse"></div>
+          {/each}
+        </div>
+      </div>
+    {:else if health}
+      <div class="flex items-center justify-between mb-4">
+        <h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200">Health Status</h3>
+        <span class="px-2.5 py-1 text-xs font-semibold rounded-full {statusBadgeClass(health.status)}">{valueOrDash(health.status)}</span>
+      </div>
+ 
+      <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
+        <div class="rounded-lg border border-surface-200 dark:border-surface-700 p-3">
+          <p class="text-xs font-medium text-surface-400 uppercase tracking-wider">OK</p>
+          <p class="text-sm font-semibold text-surface-800 dark:text-surface-100 mt-1">{String(Boolean(health.ok))}</p>
+        </div>
+ 
+        <div class="rounded-lg border border-surface-200 dark:border-surface-700 p-3">
+          <p class="text-xs font-medium text-surface-400 uppercase tracking-wider">Source</p>
+          <p class="text-sm font-semibold text-surface-800 dark:text-surface-100 mt-1">{valueOrDash(health.source)}</p>
+        </div>
+ 
+        <div class="rounded-lg border border-surface-200 dark:border-surface-700 p-3">
+          <p class="text-xs font-medium text-surface-400 uppercase tracking-wider">Container</p>
+          <p class="text-sm font-semibold text-surface-800 dark:text-surface-100 mt-1">{valueOrDash(health.container_name)}</p>
+        </div>
+ 
+        <div class="rounded-lg border border-surface-200 dark:border-surface-700 p-3">
+          <p class="text-xs font-medium text-surface-400 uppercase tracking-wider">Container Status</p>
+          <p class="text-sm font-semibold text-surface-800 dark:text-surface-100 mt-1">{valueOrDash(health.container_status)}</p>
+        </div>
+ 
+        <div class="rounded-lg border border-surface-200 dark:border-surface-700 p-3">
+          <p class="text-xs font-medium text-surface-400 uppercase tracking-wider">Endpoint</p>
+          <p class="text-sm font-semibold text-surface-800 dark:text-surface-100 mt-1 break-all">{valueOrDash(health.endpoint)}</p>
+        </div>
+ 
+        <div class="rounded-lg border border-surface-200 dark:border-surface-700 p-3">
+          <p class="text-xs font-medium text-surface-400 uppercase tracking-wider">HTTP Status</p>
+          <p class="text-sm font-semibold text-surface-800 dark:text-surface-100 mt-1">{valueOrDash(health.http_status)}</p>
+        </div>
+ 
+        <div class="rounded-lg border border-surface-200 dark:border-surface-700 p-3">
+          <p class="text-xs font-medium text-surface-400 uppercase tracking-wider">Latency</p>
+          <p class="text-sm font-semibold text-surface-800 dark:text-surface-100 mt-1">{health.latency_ms === null || health.latency_ms === undefined ? "—" : `${health.latency_ms} ms`}</p>
+        </div>
+ 
+        <div class="rounded-lg border border-surface-200 dark:border-surface-700 p-3 sm:col-span-2 lg:col-span-2">
+          <p class="text-xs font-medium text-surface-400 uppercase tracking-wider">Error</p>
+          <p class="text-sm font-semibold text-surface-800 dark:text-surface-100 mt-1 break-all">{valueOrDash(health.error)}</p>
+        </div>
+      </div>
+    {:else}
+      <p class="text-sm text-surface-400">No health data available.</p>
+    {/if}
+  </div>
+</div>
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/lcov-report/src/routes/ChatSummary.svelte.html b/dashboard/frontend/coverage/lcov-report/src/routes/ChatSummary.svelte.html new file mode 100644 index 0000000..6b02e82 --- /dev/null +++ b/dashboard/frontend/coverage/lcov-report/src/routes/ChatSummary.svelte.html @@ -0,0 +1,454 @@ + + + + + + Code coverage report for src/routes/ChatSummary.svelte + + + + + + + + + +
+
+

All files / src/routes ChatSummary.svelte

+
+ +
+ 0% + Statements + 0/91 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/91 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
<script>
+  import { onMount } from "svelte";
+  import { get, post } from "../lib/api.js";
+ 
+  let dates = $state([]);
+  let selectedDate = $state("");
+  let summary = $state(null);
+  let messages = $state([]);
+  let showMessages = $state(false);
+  let loading = $state(false);
+  let triggering = $state(false);
+  let error = $state("");
+ 
+  onMount(async () => {
+    try {
+      dates = await get("/api/chat-summary/dates");
+      if (dates.length) selectDate(dates[0]);
+    } catch (e) {
+      error = e.body?.detail || "Failed to load dates";
+    }
+  });
+ 
+  async function selectDate(d) {
+    selectedDate = d;
+    showMessages = false;
+    loading = true;
+    error = "";
+    try {
+      summary = await get(`/api/chat-summary/summary/${d}`);
+    } catch (e) {
+      summary = null;
+      if (e.status !== 404) error = e.body?.detail || "Failed to load summary";
+    } finally {
+      loading = false;
+    }
+  }
+ 
+  async function loadMessages() {
+    if (showMessages) { showMessages = false; return; }
+    try {
+      messages = await get(`/api/chat-summary/messages/${selectedDate}`);
+      showMessages = true;
+    } catch (e) {
+      error = "Failed to load messages";
+    }
+  }
+ 
+  async function trigger() {
+    triggering = true;
+    try {
+      await post("/api/chat-summary/trigger");
+    } catch (e) {
+      error = "Trigger failed";
+    } finally {
+      triggering = false;
+    }
+  }
+ 
+  function markdownToHtml(md) {
+    if (!md) return "";
+    return md
+      .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
+      .replace(/^### (.+)$/gm, '<h3 class="text-lg font-semibold mt-4 mb-2">$1</h3>')
+      .replace(/^## (.+)$/gm, '<h2 class="text-xl font-bold mt-5 mb-2">$1</h2>')
+      .replace(/^# (.+)$/gm, '<h1 class="text-2xl font-bold mt-6 mb-3">$1</h1>')
+      .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
+      .replace(/\*(.+?)\*/g, '<em>$1</em>')
+      .replace(/^- (.+)$/gm, '<li class="ml-4">$1</li>')
+      .replace(/\n{2,}/g, '<br><br>')
+      .replace(/\n/g, '<br>');
+  }
+</script>
+ 
+<div>
+  <div class="flex items-center justify-between mb-6">
+    <h2 class="text-xl font-bold text-surface-800 dark:text-surface-100">Chat Digest</h2>
+    <button onclick={trigger} disabled={triggering}
+      class="px-3 py-1.5 text-sm font-medium rounded-lg bg-primary-600 text-white hover:bg-primary-700 disabled:opacity-50">
+      {triggering ? "Generating..." : "Generate Now"}
+    </button>
+  </div>
+ 
+  {#if error}
+    <div class="mb-4 p-3 rounded-lg bg-red-50 text-red-700 text-sm dark:bg-red-900/20 dark:text-red-400">{error}</div>
+  {/if}
+ 
+  <div class="flex gap-2 mb-6 flex-wrap">
+    {#each dates as d}
+      <button onclick={() => selectDate(d)}
+        class="px-3 py-1.5 text-sm rounded-lg transition-colors {selectedDate === d ? 'bg-primary-600 text-white' : 'bg-surface-100 text-surface-600 hover:bg-surface-200 dark:bg-surface-700 dark:text-surface-300 dark:hover:bg-surface-600'}">
+        {d}
+      </button>
+    {/each}
+    {#if !dates.length && !error}
+      <p class="text-sm text-surface-400">No summaries yet.</p>
+    {/if}
+  </div>
+ 
+  {#if loading}
+    <div class="flex justify-center py-12"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div></div>
+  {:else if summary}
+    <div class="prose dark:prose-invert max-w-none bg-white dark:bg-surface-800 rounded-xl p-6 shadow-sm border border-surface-200 dark:border-surface-700">
+      {@html markdownToHtml(summary.content)}
+    </div>
+ 
+    <button onclick={loadMessages}
+      class="mt-4 text-sm text-primary-600 hover:text-primary-700 dark:text-primary-400">
+      {showMessages ? "Hide raw messages" : "Show raw messages"}
+    </button>
+ 
+    {#if showMessages}
+      <div class="mt-3 bg-white dark:bg-surface-800 rounded-xl p-4 shadow-sm border border-surface-200 dark:border-surface-700 max-h-96 overflow-y-auto">
+        {#each messages as m}
+          <div class="py-1.5 border-b border-surface-100 dark:border-surface-700 last:border-0 text-sm">
+            <span class="text-surface-400 text-xs">{m.time?.slice(11,16)}</span>
+            <span class="font-medium text-primary-600 dark:text-primary-400 ml-1">[{m.group}] {m.sender}:</span>
+            <span class="text-surface-700 dark:text-surface-300 ml-1">{m.text}</span>
+          </div>
+        {/each}
+      </div>
+    {/if}
+  {/if}
+</div>
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/lcov-report/src/routes/Conversations.svelte.html b/dashboard/frontend/coverage/lcov-report/src/routes/Conversations.svelte.html new file mode 100644 index 0000000..91b9cc8 --- /dev/null +++ b/dashboard/frontend/coverage/lcov-report/src/routes/Conversations.svelte.html @@ -0,0 +1,1003 @@ + + + + + + Code coverage report for src/routes/Conversations.svelte + + + + + + + + + +
+
+

All files / src/routes Conversations.svelte

+
+ +
+ 0% + Statements + 0/201 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/201 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
<script>
+  import { onMount } from "svelte";
+  import { get, post } from "../lib/api.js";
+ 
+  let dates = $state([]);
+  let selectedDate = $state("");
+  let summary = $state(null);
+  let conversations = $state([]);
+  let selectedConversation = $state(null);
+  let messages = $state([]);
+  let searchQuery = $state("");
+  let searchResults = $state([]);
+  let stats = $state(null);
+  let loading = $state(false);
+  let triggering = $state(false);
+  let error = $state("");
+  let view = $state("summary"); // summary, list, detail, search, stats
+ 
+  onMount(async () => {
+    try {
+      dates = await get("/conversations/dates");
+      if (dates.length) selectDate(dates[0]);
+      loadStats();
+    } catch (e) {
+      error = e.body?.detail || "Failed to load dates";
+    }
+  });
+ 
+  async function selectDate(d) {
+    selectedDate = d;
+    view = "summary";
+    loading = true;
+    error = "";
+    try {
+      summary = await get(`/conversations/summary/${d}`);
+      conversations = await get(`/conversations/list?date=${d}`);
+    } catch (e) {
+      summary = null;
+      if (e.status !== 404) error = e.body?.detail || "Failed to load summary";
+    } finally {
+      loading = false;
+    }
+  }
+ 
+  async function selectConversation(sessionId) {
+    view = "detail";
+    loading = true;
+    try {
+      selectedConversation = await get(`/conversations/${sessionId}`);
+      messages = await get(`/conversations/${sessionId}/messages`);
+    } catch (e) {
+      error = "Failed to load conversation";
+    } finally {
+      loading = false;
+    }
+  }
+ 
+  async function search() {
+    if (!searchQuery || searchQuery.length < 2) return;
+    view = "search";
+    loading = true;
+    try {
+      searchResults = await get(`/conversations/search?q=${encodeURIComponent(searchQuery)}`);
+    } catch (e) {
+      error = "Search failed";
+    } finally {
+      loading = false;
+    }
+  }
+ 
+  async function loadStats() {
+    try {
+      stats = await get("/conversations/stats");
+    } catch (e) {
+      console.error("Failed to load stats", e);
+    }
+  }
+ 
+  async function trigger() {
+    triggering = true;
+    try {
+      await post("/conversations/trigger");
+      setTimeout(() => selectDate(selectedDate), 5000);
+    } catch (e) {
+      error = "Trigger failed";
+    } finally {
+      triggering = false;
+    }
+  }
+ 
+  function markdownToHtml(md) {
+    if (!md) return "";
+    return md
+      .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
+      .replace(/^### (.+)$/gm, '<h3 class="text-lg font-semibold mt-4 mb-2">$1</h3>')
+      .replace(/^## (.+)$/gm, '<h2 class="text-xl font-bold mt-5 mb-2">$1</h2>')
+      .replace(/^# (.+)$/gm, '<h1 class="text-2xl font-bold mt-6 mb-3">$1</h1>')
+      .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
+      .replace(/\*(.+?)\*/g, '<em>$1</em>')
+      .replace(/^- (.+)$/gm, '<li class="ml-4">$1</li>')
+      .replace(/\n{2,}/g, '<br><br>')
+      .replace(/\n/g, '<br>');
+  }
+ 
+  function formatTokens(n) {
+    return n ? n.toLocaleString() : "0";
+  }
+</script>
+ 
+<div>
+  <div class="flex items-center justify-between mb-6">
+    <h2 class="text-xl font-bold text-surface-800 dark:text-surface-100">Code Sessions</h2>
+    <div class="flex gap-2">
+      <button onclick={() => view = "stats"} class="px-3 py-1.5 text-sm font-medium rounded-lg bg-surface-100 hover:bg-surface-200 dark:bg-surface-700 dark:hover:bg-surface-600 transition-colors">
+        Stats
+      </button>
+      <button onclick={trigger} disabled={triggering}
+        class="px-3 py-1.5 text-sm font-medium rounded-lg bg-primary-600 text-white hover:bg-primary-700 disabled:opacity-50">
+        {triggering ? "Generating..." : "Generate Summary"}
+      </button>
+    </div>
+  </div>
+ 
+  {#if error}
+    <div class="mb-4 p-3 rounded-lg bg-red-50 text-red-700 text-sm dark:bg-red-900/20 dark:text-red-400">{error}</div>
+  {/if}
+ 
+  <!-- Search Bar -->
+  <div class="mb-4">
+    <input
+      type="text"
+      bind:value={searchQuery}
+      onkeydown={(e) => e.key === "Enter" && search()}
+      placeholder="Search conversations..."
+      class="w-full px-4 py-2 text-sm border border-surface-200 dark:border-surface-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-surface-700 dark:text-white"
+    />
+  </div>
+ 
+  <!-- Date Selector -->
+  {#if view !== "search" && view !== "stats"}
+    <div class="flex gap-2 mb-6 flex-wrap">
+      {#each dates as d}
+        <button onclick={() => selectDate(d)}
+          class="px-3 py-1.5 text-sm rounded-lg transition-colors {selectedDate === d ? 'bg-primary-600 text-white' : 'bg-surface-100 text-surface-600 hover:bg-surface-200 dark:bg-surface-700 dark:text-surface-300 dark:hover:bg-surface-600'}">
+          {d}
+        </button>
+      {/each}
+      {#if !dates.length && !error}
+        <p class="text-sm text-surface-400">No conversations yet.</p>
+      {/if}
+    </div>
+  {/if}
+ 
+  {#if loading}
+    <div class="flex justify-center py-12"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div></div>
+  {:else if view === "summary" && summary}
+    <!-- Summary View -->
+    <div class="mb-4 grid grid-cols-4 gap-3">
+      <div class="bg-white dark:bg-surface-800 rounded-lg p-3 border border-surface-200 dark:border-surface-700">
+        <div class="text-xs text-surface-400">Conversations</div>
+        <div class="text-xl font-bold text-surface-900 dark:text-white">{summary.conversation_count}</div>
+      </div>
+      <div class="bg-white dark:bg-surface-800 rounded-lg p-3 border border-surface-200 dark:border-surface-700">
+        <div class="text-xs text-surface-400">Messages</div>
+        <div class="text-xl font-bold text-surface-900 dark:text-white">{summary.total_messages}</div>
+      </div>
+      <div class="bg-white dark:bg-surface-800 rounded-lg p-3 border border-surface-200 dark:border-surface-700">
+        <div class="text-xs text-surface-400">Tokens</div>
+        <div class="text-xl font-bold text-surface-900 dark:text-white">{formatTokens(summary.total_tokens)}</div>
+      </div>
+      <div class="bg-white dark:bg-surface-800 rounded-lg p-3 border border-surface-200 dark:border-surface-700">
+        <div class="text-xs text-surface-400">Projects</div>
+        <div class="text-sm font-medium text-surface-900 dark:text-white truncate">{summary.projects?.split(',')[0] || 'N/A'}</div>
+      </div>
+    </div>
+ 
+    <div class="prose dark:prose-invert max-w-none bg-white dark:bg-surface-800 rounded-xl p-6 shadow-sm border border-surface-200 dark:border-surface-700 mb-4">
+      {@html markdownToHtml(summary.content)}
+    </div>
+ 
+    <!-- Conversation List -->
+    <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 shadow-sm overflow-hidden">
+      <div class="px-4 py-2 text-xs font-semibold uppercase tracking-wider text-surface-400 border-b border-surface-100 dark:border-surface-700">
+        Conversations ({conversations.length})
+      </div>
+      {#each conversations as conv}
+        <button
+          onclick={() => selectConversation(conv.session_id)}
+          class="w-full px-4 py-3 hover:bg-surface-50 dark:hover:bg-surface-700 border-b border-surface-100 dark:border-surface-700 last:border-0 transition-colors text-left">
+          <div class="flex items-center justify-between">
+            <div class="flex-1 min-w-0">
+              <div class="text-sm font-medium text-surface-700 dark:text-surface-200 truncate">{conv.slug || 'Untitled'}</div>
+              <div class="text-xs text-surface-400 truncate">{conv.project_path?.split('/').pop() || 'unknown'}</div>
+            </div>
+            <div class="flex items-center gap-3 text-xs text-surface-400">
+              <span>{conv.message_count} msgs</span>
+              <span>{formatTokens(conv.total_tokens)} tokens</span>
+            </div>
+          </div>
+        </button>
+      {/each}
+    </div>
+ 
+  {:else if view === "detail" && selectedConversation}
+    <!-- Conversation Detail -->
+    <button onclick={() => view = "summary"} class="mb-4 text-sm text-primary-600 hover:text-primary-700">← Back to summary</button>
+ 
+    <div class="bg-white dark:bg-surface-800 rounded-xl p-4 border border-surface-200 dark:border-surface-700 mb-4">
+      <h3 class="text-lg font-bold text-surface-900 dark:text-white mb-2">{selectedConversation.slug || 'Untitled'}</h3>
+      <div class="grid grid-cols-2 gap-2 text-sm">
+        <div><span class="text-surface-400">Project:</span> <span class="text-surface-700 dark:text-surface-300">{selectedConversation.project_path?.split('/').pop()}</span></div>
+        <div><span class="text-surface-400">Branch:</span> <span class="text-surface-700 dark:text-surface-300">{selectedConversation.git_branch || 'N/A'}</span></div>
+        <div><span class="text-surface-400">Messages:</span> <span class="text-surface-700 dark:text-surface-300">{selectedConversation.message_count}</span></div>
+        <div><span class="text-surface-400">Tokens:</span> <span class="text-surface-700 dark:text-surface-300">{formatTokens(selectedConversation.total_input_tokens + selectedConversation.total_output_tokens)}</span></div>
+      </div>
+    </div>
+ 
+    <!-- Messages -->
+    <div class="space-y-3">
+      {#each messages as msg}
+        <div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
+          <div class="flex items-center justify-between mb-2">
+            <span class="text-xs font-semibold uppercase {msg.role === 'user' ? 'text-blue-600' : 'text-green-600'}">{msg.role}</span>
+            <span class="text-xs text-surface-400">{msg.timestamp?.slice(11, 19)}</span>
+          </div>
+          <div class="text-sm text-surface-700 dark:text-surface-300 whitespace-pre-wrap">{msg.content?.slice(0, 500)}{msg.content?.length > 500 ? '...' : ''}</div>
+          {#if msg.tool_calls?.length}
+            <div class="mt-2 pt-2 border-t border-surface-100 dark:border-surface-700">
+              <div class="text-xs font-medium text-surface-400 mb-1">Tools: {msg.tool_calls.map(t => t.name).join(', ')}</div>
+            </div>
+          {/if}
+          {#if msg.input_tokens || msg.output_tokens}
+            <div class="mt-2 text-xs text-surface-400">
+              {msg.input_tokens ? `${msg.input_tokens} in` : ''} {msg.output_tokens ? `${msg.output_tokens} out` : ''}
+            </div>
+          {/if}
+        </div>
+      {/each}
+    </div>
+ 
+  {:else if view === "search"}
+    <!-- Search Results -->
+    <button onclick={() => view = "summary"} class="mb-4 text-sm text-primary-600 hover:text-primary-700">← Back</button>
+    <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 shadow-sm overflow-hidden">
+      <div class="px-4 py-2 text-xs font-semibold uppercase tracking-wider text-surface-400 border-b border-surface-100 dark:border-surface-700">
+        Search Results ({searchResults.length})
+      </div>
+      {#each searchResults as conv}
+        <button
+          onclick={() => selectConversation(conv.session_id)}
+          class="w-full px-4 py-3 hover:bg-surface-50 dark:hover:bg-surface-700 border-b border-surface-100 dark:border-surface-700 last:border-0 transition-colors text-left">
+          <div class="flex items-center justify-between">
+            <div class="flex-1 min-w-0">
+              <div class="text-sm font-medium text-surface-700 dark:text-surface-200 truncate">{conv.slug || 'Untitled'}</div>
+              <div class="text-xs text-surface-400">{conv.started_at?.slice(0, 10)} • {conv.project_path?.split('/').pop()}</div>
+            </div>
+            <div class="text-xs text-surface-400">{conv.message_count} msgs</div>
+          </div>
+        </button>
+      {/each}
+      {#if searchResults.length === 0}
+        <div class="px-4 py-8 text-center text-sm text-surface-400">No results found</div>
+      {/if}
+    </div>
+ 
+  {:else if view === "stats" && stats}
+    <!-- Statistics -->
+    <button onclick={() => view = "summary"} class="mb-4 text-sm text-primary-600 hover:text-primary-700">← Back</button>
+    <div class="grid grid-cols-3 gap-4 mb-6">
+      <div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
+        <div class="text-sm text-surface-400">Total Conversations</div>
+        <div class="text-2xl font-bold text-surface-900 dark:text-white">{stats.total_conversations}</div>
+      </div>
+      <div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
+        <div class="text-sm text-surface-400">Total Messages</div>
+        <div class="text-2xl font-bold text-surface-900 dark:text-white">{stats.total_messages}</div>
+      </div>
+      <div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
+        <div class="text-sm text-surface-400">Total Tokens</div>
+        <div class="text-2xl font-bold text-surface-900 dark:text-white">{formatTokens(stats.total_tokens)}</div>
+      </div>
+    </div>
+ 
+    <div class="grid grid-cols-2 gap-4">
+      <div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
+        <h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200 mb-3">Top Projects</h3>
+        {#each stats.top_projects as proj}
+          <div class="flex justify-between text-sm py-1">
+            <span class="text-surface-600 dark:text-surface-300 truncate">{proj.path?.split('/').pop()}</span>
+            <span class="text-surface-400">{proj.count}</span>
+          </div>
+        {/each}
+      </div>
+ 
+      <div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
+        <h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200 mb-3">Top Tools</h3>
+        {#each stats.top_tools as tool}
+          <div class="flex justify-between text-sm py-1">
+            <span class="text-surface-600 dark:text-surface-300">{tool.name}</span>
+            <span class="text-surface-400">{tool.count}</span>
+          </div>
+        {/each}
+      </div>
+    </div>
+  {/if}
+</div>
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/lcov-report/src/routes/Dashboard.svelte.html b/dashboard/frontend/coverage/lcov-report/src/routes/Dashboard.svelte.html new file mode 100644 index 0000000..da1611f --- /dev/null +++ b/dashboard/frontend/coverage/lcov-report/src/routes/Dashboard.svelte.html @@ -0,0 +1,622 @@ + + + + + + Code coverage report for src/routes/Dashboard.svelte + + + + + + + + + +
+
+

All files / src/routes Dashboard.svelte

+
+ +
+ 0% + Statements + 0/87 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/87 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
<script>
+  import { onMount, onDestroy } from "svelte";
+  import { get } from "../lib/api.js";
+ 
+  let containers = $state([]);
+  let repos = $state([]);
+  let stats = $state(null);
+  let loading = $state(true);
+ 
+  async function load() {
+    const [c, g, s] = await Promise.all([
+      get("/docker/containers"),
+      get("/gitea/repos"),
+      get("/system/stats"),
+    ]);
+    containers = c || [];
+    repos = Array.isArray(g) ? g : g?.data || [];
+    stats = s;
+    loading = false;
+  }
+ 
+  function fmtBytes(b) {
+    if (!b) return "0 B";
+    const u = ["B", "KB", "MB", "GB", "TB"];
+    const i = Math.floor(Math.log(b) / Math.log(1024));
+    return (b / Math.pow(1024, i)).toFixed(1) + " " + u[i];
+  }
+ 
+  function pctColor(pct) {
+    if (pct > 90) return "text-rose-500";
+    if (pct > 70) return "text-amber-500";
+    return "text-emerald-500";
+  }
+ 
+  let interval;
+ 
+  onMount(() => { load(); interval = setInterval(load, 10000); });
+  onDestroy(() => clearInterval(interval));
+</script>
+ 
+<div class="space-y-8">
+  <div>
+    <h1 class="text-2xl font-bold text-surface-900 dark:text-white tracking-tight">Overview</h1>
+    <p class="text-sm text-surface-400 mt-1">System status at a glance</p>
+  </div>
+ 
+  {#if loading}
+    <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
+      {#each Array(4) as _}
+        <div class="h-[120px] rounded-xl bg-surface-100 dark:bg-surface-800 animate-pulse"></div>
+      {/each}
+    </div>
+  {:else}
+    <!-- Stats Cards -->
+    <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
+      <!-- CPU -->
+      <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm hover:shadow-md transition-shadow duration-200">
+        <div class="flex items-center justify-between mb-3">
+          <p class="text-xs font-semibold uppercase tracking-wider text-surface-400">CPU</p>
+          <div class="w-8 h-8 rounded-lg bg-sky-50 flex items-center justify-center">
+            <svg class="w-4 h-4 text-sky-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
+          </div>
+        </div>
+        <p class="text-2xl font-bold {pctColor(stats?.cpu_percent || 0)}">{stats?.cpu_percent || 0}%</p>
+        <p class="text-xs text-surface-400 mt-1">{stats?.cpu_count || 0} cores · Load {stats?.load_avg?.[0] || 0}</p>
+      </div>
+ 
+      <!-- Memory -->
+      <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm hover:shadow-md transition-shadow duration-200">
+        <div class="flex items-center justify-between mb-3">
+          <p class="text-xs font-semibold uppercase tracking-wider text-surface-400">Memory</p>
+          <div class="w-8 h-8 rounded-lg bg-violet-50 flex items-center justify-center">
+            <svg class="w-4 h-4 text-violet-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
+          </div>
+        </div>
+        <p class="text-2xl font-bold {pctColor(stats?.memory?.percent || 0)}">{stats?.memory?.percent || 0}%</p>
+        <p class="text-xs text-surface-400 mt-1">{fmtBytes(stats?.memory?.used)} / {fmtBytes(stats?.memory?.total)}</p>
+      </div>
+ 
+      <!-- Disk -->
+      <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm hover:shadow-md transition-shadow duration-200">
+        <div class="flex items-center justify-between mb-3">
+          <p class="text-xs font-semibold uppercase tracking-wider text-surface-400">Disk</p>
+          <div class="w-8 h-8 rounded-lg bg-amber-50 flex items-center justify-center">
+            <svg class="w-4 h-4 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" /></svg>
+          </div>
+        </div>
+        <p class="text-2xl font-bold {pctColor(stats?.disk?.percent || 0)}">{stats?.disk?.percent || 0}%</p>
+        <p class="text-xs text-surface-400 mt-1">{fmtBytes(stats?.disk?.free)} free of {fmtBytes(stats?.disk?.total)}</p>
+      </div>
+ 
+      <!-- Uptime -->
+      <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm hover:shadow-md transition-shadow duration-200">
+        <div class="flex items-center justify-between mb-3">
+          <p class="text-xs font-semibold uppercase tracking-wider text-surface-400">Uptime</p>
+          <div class="w-8 h-8 rounded-lg bg-emerald-50 flex items-center justify-center">
+            <svg class="w-4 h-4 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
+          </div>
+        </div>
+        <p class="text-2xl font-bold text-surface-800 dark:text-surface-100">{stats?.uptime || "—"}</p>
+        <p class="text-xs text-surface-400 mt-1 truncate" title={stats?.platform}>{stats?.platform || "—"}</p>
+      </div>
+    </div>
+ 
+    <!-- Services Grid -->
+    <div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
+      <!-- Docker Summary -->
+      <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
+        <div class="flex items-center justify-between mb-4">
+          <h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200">Docker Containers</h3>
+          <span class="text-xs font-medium text-emerald-600 bg-emerald-50 px-2 py-0.5 rounded-full">{containers.filter(c => c.status === "running").length}/{containers.length} running</span>
+        </div>
+        <div class="space-y-2">
+          {#each containers.slice(0, 5) as c}
+            <div class="flex items-center justify-between py-1.5 text-sm">
+              <div class="flex items-center gap-2">
+                <span class="w-1.5 h-1.5 rounded-full {c.status !== 'running' ? 'bg-surface-300' : c.health === 'unhealthy' ? 'bg-rose-400' : c.health === 'healthy' ? 'bg-emerald-400' : 'bg-sky-400'}"></span>
+                <span class="font-medium text-surface-700 dark:text-surface-200">{c.name}</span>
+              </div>
+              <span class="text-xs text-surface-400 truncate max-w-[180px]">{c.image}</span>
+            </div>
+          {/each}
+          {#if containers.length > 5}
+            <p class="text-xs text-primary-500 font-medium pt-1">+{containers.length - 5} more</p>
+          {/if}
+        </div>
+      </div>
+ 
+      <!-- Repos Summary -->
+      <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
+        <div class="flex items-center justify-between mb-4">
+          <h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200">Git Repositories</h3>
+          <span class="text-xs font-medium text-primary-600 bg-primary-50 px-2 py-0.5 rounded-full">{repos.length} repos</span>
+        </div>
+        <div class="space-y-2">
+          {#each repos.slice(0, 5) as r}
+            <div class="flex items-center justify-between py-1.5 text-sm">
+              <div class="flex items-center gap-2">
+                <svg class="w-3.5 h-3.5 text-surface-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /></svg>
+                <span class="font-medium text-surface-700 dark:text-surface-200">{r.name}</span>
+              </div>
+              <span class="text-xs text-surface-400">{r.language || "—"}</span>
+            </div>
+          {/each}
+          {#if repos.length > 5}
+            <p class="text-xs text-primary-500 font-medium pt-1">+{repos.length - 5} more</p>
+          {/if}
+        </div>
+      </div>
+    </div>
+ 
+    <!-- Storage Usage -->
+    {#if stats?.volumes?.length}
+      <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
+        <h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200 mb-3">Storage Usage</h3>
+        <div class="space-y-3">
+          {#each stats.volumes as vol}
+            <div>
+              <div class="flex justify-between text-xs text-surface-500 mb-1">
+                <span class="font-medium">{vol.mount}</span>
+                <span>{vol.percent}%</span>
+              </div>
+              <div class="w-full h-3 bg-surface-100 dark:bg-surface-700 rounded-full overflow-hidden">
+                <div
+                  class="h-full rounded-full transition-all duration-700 ease-out {vol.percent > 90 ? 'bg-gradient-to-r from-rose-400 to-rose-500' : vol.percent > 70 ? 'bg-gradient-to-r from-amber-400 to-amber-500' : 'bg-gradient-to-r from-emerald-400 to-emerald-500'}"
+                  style="width: {vol.percent}%"
+                ></div>
+              </div>
+              <div class="flex justify-between mt-1 text-xs text-surface-400">
+                <span>{fmtBytes(vol.used)} used</span>
+                <span>{fmtBytes(vol.free)} free</span>
+              </div>
+            </div>
+          {/each}
+        </div>
+      </div>
+    {/if}
+  {/if}
+</div>
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/lcov-report/src/routes/Docker.svelte.html b/dashboard/frontend/coverage/lcov-report/src/routes/Docker.svelte.html new file mode 100644 index 0000000..a252427 --- /dev/null +++ b/dashboard/frontend/coverage/lcov-report/src/routes/Docker.svelte.html @@ -0,0 +1,565 @@ + + + + + + Code coverage report for src/routes/Docker.svelte + + + + + + + + + +
+
+

All files / src/routes Docker.svelte

+
+ +
+ 0% + Statements + 0/94 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/94 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
<script>
+  import { onMount } from "svelte";
+  import { get, post } from "../lib/api.js";
+ 
+  let containers = $state([]);
+  let loading = $state(true);
+  let error = $state("");
+  let logTarget = $state("");
+  let logContent = $state("");
+  let loadingLogs = $state(false);
+  let actionLoading = $state("");
+ 
+  async function load() {
+    loading = true;
+    error = "";
+    try {
+      containers = (await get("/docker/containers")) || [];
+    } catch (e) {
+      error = e.message || "Docker API unavailable";
+      containers = [];
+    }
+    loading = false;
+  }
+ 
+  async function action(id, act) {
+    actionLoading = id + act;
+    try {
+      await post(`/docker/containers/${id}/${act}`);
+      await load();
+    } catch (e) {
+      error = e.message || "Action failed";
+    }
+    actionLoading = "";
+  }
+ 
+  async function showLogs(id, name) {
+    logTarget = name;
+    loadingLogs = true;
+    try {
+      const r = await get(`/docker/containers/${id}/logs?tail=200`);
+      logContent = r?.logs || "No logs available";
+    } catch (e) {
+      logContent = "Failed to load logs";
+    }
+    loadingLogs = false;
+  }
+ 
+  onMount(load);
+</script>
+ 
+<div class="space-y-6">
+  <div class="flex items-center justify-between">
+    <div>
+      <h1 class="text-2xl font-bold text-surface-900 dark:text-white tracking-tight">Docker</h1>
+      <p class="text-sm text-surface-400 mt-1">{containers.length} containers · {containers.filter(c => c.status === "running").length} running</p>
+    </div>
+    <button onclick={load} class="text-xs font-medium text-primary-600 bg-primary-50 hover:bg-primary-100 px-3 py-1.5 rounded-lg transition-colors">
+      Refresh
+    </button>
+  </div>
+ 
+  {#if loading}
+    <div class="space-y-3">
+      {#each Array(5) as _}
+        <div class="h-[72px] rounded-xl bg-surface-100 dark:bg-surface-800 animate-pulse"></div>
+      {/each}
+    </div>
+  {:else if error}
+    <div class="bg-rose-50 dark:bg-rose-900/20 border border-rose-200 dark:border-rose-800 rounded-xl p-4">
+      <div class="flex items-center justify-between">
+        <div class="flex items-center gap-2">
+          <svg class="w-5 h-5 text-rose-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" /></svg>
+          <p class="text-sm font-medium text-rose-700 dark:text-rose-300">{error}</p>
+        </div>
+        <button onclick={load} class="text-xs font-medium text-rose-600 bg-rose-100 hover:bg-rose-200 px-3 py-1.5 rounded-lg transition-colors">
+          Retry
+        </button>
+      </div>
+    </div>
+  {:else}
+    <div class="space-y-2">
+      {#each containers as c}
+        <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-4 flex items-center justify-between shadow-sm hover:shadow-md transition-all duration-200 group">
+          <div class="flex items-center gap-3 min-w-0">
+            <span class="relative flex h-2.5 w-2.5 shrink-0">
+              {#if c.status === "running"}
+                <span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
+                <span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500"></span>
+              {:else}
+                <span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-surface-300"></span>
+              {/if}
+            </span>
+            <div class="min-w-0">
+              <p class="text-sm font-semibold text-surface-800 dark:text-surface-100 truncate">{c.name}</p>
+              <p class="text-xs text-surface-400 truncate mt-0.5">{c.image}</p>
+            </div>
+          </div>
+          <div class="flex items-center gap-1.5 opacity-70 group-hover:opacity-100 transition-opacity">
+            {#if c.status === "running"}
+              <button
+                class="text-xs font-medium px-2.5 py-1.5 rounded-lg text-rose-600 bg-rose-50 hover:bg-rose-100 transition-colors disabled:opacity-50"
+                onclick={() => action(c.id, "stop")}
+                disabled={actionLoading === c.id + "stop"}
+              >
+                {actionLoading === c.id + "stop" ? "..." : "Stop"}
+              </button>
+              <button
+                class="text-xs font-medium px-2.5 py-1.5 rounded-lg text-amber-600 bg-amber-50 hover:bg-amber-100 transition-colors disabled:opacity-50"
+                onclick={() => action(c.id, "restart")}
+                disabled={actionLoading === c.id + "restart"}
+              >
+                {actionLoading === c.id + "restart" ? "..." : "Restart"}
+              </button>
+            {:else}
+              <button
+                class="text-xs font-medium px-2.5 py-1.5 rounded-lg text-emerald-600 bg-emerald-50 hover:bg-emerald-100 transition-colors disabled:opacity-50"
+                onclick={() => action(c.id, "start")}
+                disabled={actionLoading === c.id + "start"}
+              >
+                {actionLoading === c.id + "start" ? "..." : "Start"}
+              </button>
+            {/if}
+            <button
+              class="text-xs font-medium px-2.5 py-1.5 rounded-lg text-surface-600 bg-surface-100 hover:bg-surface-200 transition-colors"
+              onclick={() => showLogs(c.id, c.name)}
+            >
+              Logs
+            </button>
+          </div>
+        </div>
+      {/each}
+    </div>
+  {/if}
+ 
+  <!-- Log Modal -->
+  {#if logTarget}
+    <div class="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50" role="dialog" aria-modal="true" onclick={() => { logTarget = ""; logContent = ""; }} onkeydown={(e) => e.key === "Escape" && (logTarget = "", logContent = "")}>
+      <div class="bg-white dark:bg-surface-800 rounded-2xl shadow-2xl w-[90vw] max-w-4xl max-h-[80vh] flex flex-col" role="document" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
+        <div class="flex items-center justify-between px-5 py-4 border-b border-surface-200 dark:border-surface-700">
+          <div>
+            <h3 class="text-sm font-bold text-surface-800 dark:text-surface-100">Container Logs</h3>
+            <p class="text-xs text-surface-400 mt-0.5">{logTarget}</p>
+          </div>
+          <button aria-label="Close logs" class="text-surface-400 hover:text-surface-600 transition-colors" onclick={() => { logTarget = ""; logContent = ""; }}>
+            <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
+          </button>
+        </div>
+        <div class="flex-1 overflow-auto p-1">
+          {#if loadingLogs}
+            <div class="flex items-center justify-center h-40">
+              <div class="w-6 h-6 border-2 border-primary-500 border-t-transparent rounded-full animate-spin"></div>
+            </div>
+          {:else}
+            <pre class="bg-surface-900 text-emerald-400 text-xs p-4 rounded-xl whitespace-pre-wrap leading-relaxed font-mono min-h-[200px]">{logContent}</pre>
+          {/if}
+        </div>
+      </div>
+    </div>
+  {/if}
+</div>
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/lcov-report/src/routes/Files.svelte.html b/dashboard/frontend/coverage/lcov-report/src/routes/Files.svelte.html new file mode 100644 index 0000000..cd8884f --- /dev/null +++ b/dashboard/frontend/coverage/lcov-report/src/routes/Files.svelte.html @@ -0,0 +1,616 @@ + + + + + + Code coverage report for src/routes/Files.svelte + + + + + + + + + +
+
+

All files / src/routes Files.svelte

+
+ +
+ 0% + Statements + 0/105 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/105 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
<script>
+  import { onMount } from "svelte";
+  import { get, del, upload, download } from "../lib/api.js";
+ 
+  let entries = $state([]);
+  let currentPath = $state("");
+  let loading = $state(true);
+  let uploading = $state(false);
+  let error = $state("");
+  let fileInput;
+ 
+  async function browse(path) {
+    loading = true;
+    currentPath = path;
+    const data = await get(`/files/browse?path=${encodeURIComponent(path)}`);
+    entries = data?.entries || [];
+    loading = false;
+  }
+ 
+  function goUp() {
+    const parts = currentPath.split("/").filter(Boolean);
+    parts.pop();
+    browse(parts.join("/"));
+  }
+ 
+  async function remove(name) {
+    if (!confirm(`Delete "${name}"? This cannot be undone.`)) return;
+    const path = currentPath ? `${currentPath}/${name}` : name;
+    await del(`/files/delete?path=${encodeURIComponent(path)}`);
+    browse(currentPath);
+  }
+ 
+  async function handleDownload(name) {
+    const path = currentPath ? `${currentPath}/${name}` : name;
+    error = "";
+    try {
+      await download(path, name);
+    } catch (e) {
+      error = `Failed to download "${name}": ${e.message}`;
+      console.error("Download failed:", e);
+    }
+  }
+ 
+  async function handleUpload() {
+    const file = fileInput?.files?.[0];
+    if (!file) return;
+    uploading = true;
+    await upload(currentPath, file);
+    fileInput.value = "";
+    uploading = false;
+    browse(currentPath);
+  }
+ 
+  function formatSize(bytes) {
+    if (!bytes) return "—";
+    const u = ["B", "KB", "MB", "GB", "TB"];
+    const i = Math.floor(Math.log(bytes) / Math.log(1024));
+    return (bytes / Math.pow(1024, i)).toFixed(1) + " " + u[i];
+  }
+ 
+  function fileIcon(name) {
+    const ext = name.split(".").pop().toLowerCase();
+    if (["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(ext)) return "image";
+    if (["mp4", "mkv", "avi", "mov"].includes(ext)) return "video";
+    if (["mp3", "flac", "wav", "m4a", "ogg"].includes(ext)) return "audio";
+    if (["zip", "tar", "gz", "7z", "rar"].includes(ext)) return "archive";
+    if (["py", "js", "ts", "go", "rs", "sh", "yml", "yaml", "json", "md"].includes(ext)) return "code";
+    return "file";
+  }
+ 
+  const crumbs = $derived(currentPath.split("/").filter(Boolean));
+ 
+  onMount(() => browse(""));
+</script>
+ 
+<div class="space-y-5">
+  <div class="flex items-center justify-between">
+    <div>
+      <h1 class="text-2xl font-bold text-surface-900 dark:text-white tracking-tight">Files</h1>
+      <p class="text-sm text-surface-400 mt-1">/volume1{currentPath ? "/" + currentPath : ""}</p>
+    </div>
+    <div class="flex items-center gap-2">
+      <input type="file" bind:this={fileInput} onchange={handleUpload} class="hidden" />
+      <button
+        onclick={() => fileInput.click()}
+        class="text-xs font-medium text-primary-600 bg-primary-50 hover:bg-primary-100 px-3 py-1.5 rounded-lg transition-colors disabled:opacity-50"
+        disabled={uploading}
+      >
+        {uploading ? "Uploading..." : "Upload"}
+      </button>
+    </div>
+  </div>
+ 
+  <!-- Error message -->
+  {#if error}
+    <div class="bg-rose-50 dark:bg-rose-900/20 border border-rose-200 dark:border-rose-800 rounded-lg px-4 py-3 flex items-start gap-3">
+      <svg class="w-5 h-5 text-rose-500 shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
+        <path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
+      </svg>
+      <div class="flex-1">
+        <p class="text-sm text-rose-700 dark:text-rose-300">{error}</p>
+      </div>
+      <button onclick={() => error = ""} class="text-rose-400 hover:text-rose-600 transition-colors">
+        <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
+          <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
+        </svg>
+      </button>
+    </div>
+  {/if}
+ 
+  <!-- Breadcrumbs -->
+  <div class="flex items-center gap-1 text-sm flex-wrap">
+    <button class="text-primary-600 hover:text-primary-700 font-medium transition-colors" onclick={() => browse("")}>volume1</button>
+    {#each crumbs as part, i}
+      <svg class="w-3.5 h-3.5 text-surface-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" /></svg>
+      <button
+        class="text-primary-600 hover:text-primary-700 font-medium transition-colors"
+        onclick={() => browse(crumbs.slice(0, i + 1).join("/"))}
+      >{part}</button>
+    {/each}
+    {#if currentPath}
+      <button
+        class="ml-auto text-xs font-medium text-surface-500 bg-surface-100 dark:bg-surface-700 hover:bg-surface-200 dark:hover:bg-surface-600 px-2.5 py-1 rounded-lg transition-colors flex items-center gap-1"
+        onclick={goUp}
+      >
+        <svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M5 10l7-7m0 0l7 7m-7-7v18" /></svg>
+        Up
+      </button>
+    {/if}
+  </div>
+ 
+  <!-- File List -->
+  {#if loading}
+    <div class="space-y-1">
+      {#each Array(8) as _}
+        <div class="h-11 rounded-lg bg-surface-100 dark:bg-surface-800 animate-pulse"></div>
+      {/each}
+    </div>
+  {:else}
+    <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 shadow-sm overflow-hidden">
+      <!-- Header -->
+      <div class="grid grid-cols-[1fr_100px_100px] px-4 py-2 text-[11px] font-semibold uppercase tracking-wider text-surface-400 border-b border-surface-100 dark:border-surface-700">
+        <span>Name</span>
+        <span class="text-right">Size</span>
+        <span class="text-right">Actions</span>
+      </div>
+      <!-- Entries -->
+      {#each entries as e}
+        <div class="grid grid-cols-[1fr_100px_100px] items-center px-4 py-2.5 hover:bg-surface-50 dark:hover:bg-surface-700 border-b border-surface-100 dark:border-surface-700 last:border-0 transition-colors group">
+          {#if e.is_dir}
+            <button class="flex items-center gap-2.5 text-sm font-medium text-surface-700 dark:text-surface-200 hover:text-primary-600 transition-colors text-left truncate" onclick={() => browse(currentPath ? `${currentPath}/${e.name}` : e.name)}>
+              <svg class="w-4 h-4 text-amber-400 shrink-0" fill="currentColor" viewBox="0 0 20 20"><path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" /></svg>
+              {e.name}
+            </button>
+          {:else}
+            <span class="flex items-center gap-2.5 text-sm text-surface-600 dark:text-surface-300 truncate">
+              <svg class="w-4 h-4 text-surface-300 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" /></svg>
+              {e.name}
+            </span>
+          {/if}
+          <span class="text-xs text-surface-400 text-right">{e.is_dir ? "—" : formatSize(e.size)}</span>
+          <div class="flex items-center justify-end gap-1 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity">
+            {#if !e.is_dir}
+              <button onclick={() => handleDownload(e.name)} class="text-[11px] text-primary-500 hover:text-primary-700 font-medium transition-colors">
+                DL
+              </button>
+            {/if}
+            <button class="text-[11px] text-rose-400 hover:text-rose-600 font-medium transition-colors" onclick={() => remove(e.name)}>Del</button>
+          </div>
+        </div>
+      {/each}
+      {#if entries.length === 0}
+        <div class="px-4 py-8 text-center text-sm text-surface-400">Empty directory</div>
+      {/if}
+    </div>
+  {/if}
+</div>
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/lcov-report/src/routes/Gitea.svelte.html b/dashboard/frontend/coverage/lcov-report/src/routes/Gitea.svelte.html new file mode 100644 index 0000000..da9aded --- /dev/null +++ b/dashboard/frontend/coverage/lcov-report/src/routes/Gitea.svelte.html @@ -0,0 +1,457 @@ + + + + + + Code coverage report for src/routes/Gitea.svelte + + + + + + + + + +
+
+

All files / src/routes Gitea.svelte

+
+ +
+ 0% + Statements + 0/74 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/74 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
<script>
+  import { onMount } from "svelte";
+  import { get } from "../lib/api.js";
+ 
+  let repos = $state([]);
+  let commits = $state([]);
+  let selectedRepo = $state("");
+  let loading = $state(true);
+  let loadingCommits = $state(false);
+ 
+  async function load() {
+    loading = true;
+    const data = await get("/gitea/repos");
+    repos = Array.isArray(data) ? data : data?.data || [];
+    loading = false;
+  }
+ 
+  async function showCommits(owner, repo) {
+    selectedRepo = `${owner}/${repo}`;
+    loadingCommits = true;
+    const data = await get(`/gitea/repos/${owner}/${repo}/commits`);
+    commits = Array.isArray(data) ? data : [];
+    loadingCommits = false;
+  }
+ 
+  function timeAgo(d) {
+    const s = Math.floor((Date.now() - new Date(d)) / 1000);
+    if (s < 60) return "just now";
+    if (s < 3600) return Math.floor(s / 60) + "m ago";
+    if (s < 86400) return Math.floor(s / 3600) + "h ago";
+    return Math.floor(s / 86400) + "d ago";
+  }
+ 
+  const langColors = {
+    Python: "#3572A5", JavaScript: "#f1e05a", Shell: "#89e051", Go: "#00ADD8",
+    TypeScript: "#3178c6", Markdown: "#083fa1", CSS: "#563d7c", HTML: "#e34c26",
+  };
+ 
+  onMount(load);
+</script>
+ 
+<div class="space-y-6">
+  <div>
+    <h1 class="text-2xl font-bold text-surface-900 dark:text-white tracking-tight">Repositories</h1>
+    <p class="text-sm text-surface-400 mt-1">{repos.length} repositories on Gitea</p>
+  </div>
+ 
+  {#if loading}
+    <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
+      {#each Array(4) as _}
+        <div class="h-[100px] rounded-xl bg-surface-100 dark:bg-surface-800 animate-pulse"></div>
+      {/each}
+    </div>
+  {:else}
+    <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
+      {#each repos as r}
+        <button
+          class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-4 text-left shadow-sm hover:shadow-md hover:border-primary-200 dark:hover:border-primary-700 transition-all duration-200 group {selectedRepo === `${r.owner?.login}/${r.name}` ? 'ring-2 ring-primary-500 border-primary-300' : ''}"
+          onclick={() => showCommits(r.owner?.login, r.name)}
+        >
+          <div class="flex items-start justify-between">
+            <div class="min-w-0">
+              <p class="text-sm font-semibold text-surface-800 dark:text-surface-100 group-hover:text-primary-600 transition-colors truncate">{r.name}</p>
+              <p class="text-xs text-surface-400 mt-1 line-clamp-1">{r.description || "No description"}</p>
+            </div>
+            {#if r.language}
+              <span class="flex items-center gap-1 text-[11px] text-surface-500 shrink-0 ml-2">
+                <span class="w-2 h-2 rounded-full" style="background: {langColors[r.language] || '#888'}"></span>
+                {r.language}
+              </span>
+            {/if}
+          </div>
+          <p class="text-[11px] text-surface-400 mt-2.5">Updated {timeAgo(r.updated_at)}</p>
+        </button>
+      {/each}
+    </div>
+  {/if}
+ 
+  <!-- Commits Panel -->
+  {#if selectedRepo}
+    <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
+      <div class="flex items-center justify-between mb-4">
+        <div>
+          <h3 class="text-sm font-bold text-surface-800 dark:text-surface-100">Recent Commits</h3>
+          <p class="text-xs text-surface-400 mt-0.5">{selectedRepo}</p>
+        </div>
+        <button
+          aria-label="Close commits"
+          class="text-xs text-surface-400 hover:text-surface-600 transition-colors"
+          onclick={() => { selectedRepo = ""; commits = []; }}
+        >
+          <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
+        </button>
+      </div>
+      {#if loadingCommits}
+        <div class="space-y-3">
+          {#each Array(3) as _}
+            <div class="h-12 rounded-lg bg-surface-100 animate-pulse"></div>
+          {/each}
+        </div>
+      {:else}
+        <div class="space-y-0">
+          {#each commits.slice(0, 15) as c, i}
+            <div class="flex gap-3 py-2.5 {i < commits.length - 1 ? 'border-b border-surface-100 dark:border-surface-700' : ''}">
+              <div class="flex flex-col items-center pt-1">
+                <div class="w-2 h-2 rounded-full bg-primary-400 shrink-0"></div>
+                {#if i < commits.length - 1}
+                  <div class="w-px flex-1 bg-surface-200 mt-1"></div>
+                {/if}
+              </div>
+              <div class="min-w-0 flex-1">
+                <p class="text-sm text-surface-700 dark:text-surface-200 truncate">{c.commit?.message?.split("\n")[0]}</p>
+                <p class="text-[11px] text-surface-400 mt-0.5">
+                  <span class="font-medium">{c.commit?.author?.name}</span> · {timeAgo(c.commit?.author?.date)}
+                </p>
+              </div>
+              <span class="text-[10px] font-mono text-surface-400 bg-surface-50 dark:bg-surface-700 px-1.5 py-0.5 rounded h-fit shrink-0">{c.sha?.slice(0, 7)}</span>
+            </div>
+          {/each}
+        </div>
+      {/if}
+    </div>
+  {/if}
+</div>
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/lcov-report/src/routes/InfoEngine.svelte.html b/dashboard/frontend/coverage/lcov-report/src/routes/InfoEngine.svelte.html new file mode 100644 index 0000000..169bac1 --- /dev/null +++ b/dashboard/frontend/coverage/lcov-report/src/routes/InfoEngine.svelte.html @@ -0,0 +1,502 @@ + + + + + + Code coverage report for src/routes/InfoEngine.svelte + + + + + + + + + +
+
+

All files / src/routes InfoEngine.svelte

+
+ +
+ 0% + Statements + 0/92 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/92 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
<script>
+  import { onMount } from "svelte";
+  import { getInfoEngineItems, getInfoEngineItem } from "../lib/api.js";
+ 
+  let loading = $state(true);
+  let loadingDetail = $state(false);
+  let requestError = $state("");
+  let items = $state([]);
+  let total = $state(0);
+  let selectedId = $state(null);
+  let selectedItem = $state(null);
+ 
+  async function loadItems() {
+    loading = true;
+    requestError = "";
+    try {
+      const res = await getInfoEngineItems({ limit: 50, offset: 0 });
+      items = res.items || [];
+      total = res.total || 0;
+      if (items.length > 0) {
+        await selectItem(items[0].id);
+      } else {
+        selectedId = null;
+        selectedItem = null;
+      }
+    } catch (e) {
+      requestError = e?.body?.detail || e?.message || "Failed to load items";
+      items = [];
+      total = 0;
+      selectedId = null;
+      selectedItem = null;
+    } finally {
+      loading = false;
+    }
+  }
+ 
+  async function selectItem(id) {
+    selectedId = id;
+    loadingDetail = true;
+    requestError = "";
+    try {
+      selectedItem = await getInfoEngineItem(id);
+    } catch (e) {
+      selectedItem = null;
+      requestError = e?.body?.detail || e?.message || "Failed to load item";
+    } finally {
+      loadingDetail = false;
+    }
+  }
+ 
+  function formatDate(value) {
+    if (!value) return "—";
+    try {
+      return new Date(value).toLocaleString();
+    } catch {
+      return value;
+    }
+  }
+ 
+  onMount(loadItems);
+</script>
+ 
+<div class="space-y-6">
+  <div class="flex items-center justify-between">
+    <div>
+      <h1 class="text-2xl font-bold text-surface-900 dark:text-white tracking-tight">Info Engine</h1>
+      <p class="text-sm text-surface-400 mt-1">Context-aware stream of curated signals</p>
+    </div>
+    <button onclick={loadItems} disabled={loading} class="px-3 py-1.5 text-xs font-medium text-primary-600 bg-primary-50 hover:bg-primary-100 rounded-lg transition-colors disabled:opacity-50 dark:bg-primary-900/30 dark:text-primary-300">
+      {loading ? "Loading..." : "Refresh"}
+    </button>
+  </div>
+ 
+  {#if requestError}
+    <div class="bg-rose-50 dark:bg-rose-900/20 border border-rose-200 dark:border-rose-700 rounded-xl p-4 shadow-sm">
+      <p class="text-sm font-medium text-rose-700 dark:text-rose-300">{requestError}</p>
+    </div>
+  {/if}
+ 
+  <div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
+    <div class="lg:col-span-1 bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 shadow-sm">
+      <div class="px-4 py-3 border-b border-surface-200 dark:border-surface-700 text-xs text-surface-400">
+        Latest items ({total})
+      </div>
+      {#if loading}
+        <div class="p-4 space-y-2">
+          {#each Array(6) as _}
+            <div class="h-12 rounded-lg bg-surface-100 dark:bg-surface-700 animate-pulse"></div>
+          {/each}
+        </div>
+      {:else if items.length === 0}
+        <div class="p-4 text-sm text-surface-400">No items yet.</div>
+      {:else}
+        <div class="max-h-[560px] overflow-y-auto p-2">
+          {#each items as item}
+            <button onclick={() => selectItem(item.id)} class="w-full text-left px-3 py-2 rounded-lg mb-1 transition-colors {selectedId === item.id ? 'bg-primary-50 dark:bg-primary-900/30' : 'hover:bg-surface-100 dark:hover:bg-surface-700'}">
+              <p class="text-sm font-semibold text-surface-800 dark:text-surface-100 line-clamp-2">{item.title}</p>
+              <p class="text-xs text-surface-400 mt-1">{item.source}</p>
+            </button>
+          {/each}
+        </div>
+      {/if}
+    </div>
+ 
+    <div class="lg:col-span-2 bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
+      {#if loadingDetail}
+        <div class="space-y-3">
+          <div class="h-7 w-3/4 rounded bg-surface-100 dark:bg-surface-700 animate-pulse"></div>
+          <div class="h-4 w-full rounded bg-surface-100 dark:bg-surface-700 animate-pulse"></div>
+          <div class="h-4 w-5/6 rounded bg-surface-100 dark:bg-surface-700 animate-pulse"></div>
+          <div class="h-24 w-full rounded bg-surface-100 dark:bg-surface-700 animate-pulse"></div>
+        </div>
+      {:else if selectedItem}
+        <h2 class="text-xl font-bold text-surface-900 dark:text-white">{selectedItem.title}</h2>
+        <div class="mt-2 text-xs text-surface-400 space-y-1">
+          <p>Source: {selectedItem.source}</p>
+          <p>Published: {formatDate(selectedItem.published_at)}</p>
+          <p>Collected: {formatDate(selectedItem.collected_at)}</p>
+        </div>
+ 
+        {#if selectedItem.tags?.length}
+          <div class="mt-3 flex flex-wrap gap-2">
+            {#each selectedItem.tags as tag}
+              <span class="px-2 py-0.5 text-xs rounded-full bg-surface-100 text-surface-600 dark:bg-surface-700 dark:text-surface-300">{tag}</span>
+            {/each}
+          </div>
+        {/if}
+ 
+        <p class="mt-4 text-sm text-surface-700 dark:text-surface-300 whitespace-pre-wrap">{selectedItem.summary || selectedItem.content_text || "No summary available."}</p>
+ 
+        <a href={selectedItem.url} target="_blank" rel="noopener" class="inline-flex mt-4 px-3 py-1.5 text-xs font-medium rounded-lg bg-primary-600 text-white hover:bg-primary-700">
+          Open source
+        </a>
+      {:else}
+        <p class="text-sm text-surface-400">Select an item to view details.</p>
+      {/if}
+    </div>
+  </div>
+</div>
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/lcov-report/src/routes/LiteLLM.svelte.html b/dashboard/frontend/coverage/lcov-report/src/routes/LiteLLM.svelte.html new file mode 100644 index 0000000..5959fdb --- /dev/null +++ b/dashboard/frontend/coverage/lcov-report/src/routes/LiteLLM.svelte.html @@ -0,0 +1,409 @@ + + + + + + Code coverage report for src/routes/LiteLLM.svelte + + + + + + + + + +
+
+

All files / src/routes LiteLLM.svelte

+
+ +
+ 0% + Statements + 0/58 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/58 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
<script>
+  import { onMount } from "svelte";
+  import { getLiteLLMHealth } from "../lib/api.js";
+ 
+  let loading = $state(true);
+  let health = $state(null);
+  let requestError = $state("");
+ 
+  async function loadHealth() {
+    loading = true;
+    requestError = "";
+    try {
+      health = await getLiteLLMHealth();
+    } catch (e) {
+      requestError = e?.body?.detail || e?.message || "Failed to load LiteLLM health";
+      health = null;
+    } finally {
+      loading = false;
+    }
+  }
+ 
+  function statusBadgeClass(status) {
+    if (status === "up") {
+      return "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300";
+    }
+    if (status === "auth_required") {
+      return "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300";
+    }
+    return "bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300";
+  }
+ 
+  function valueOrDash(value) {
+    return value === null || value === undefined || value === "" ? "—" : value;
+  }
+ 
+  onMount(loadHealth);
+</script>
+ 
+<div class="space-y-6">
+  <div class="flex items-center justify-between">
+    <div>
+      <h1 class="text-2xl font-bold text-surface-900 dark:text-white tracking-tight">LiteLLM</h1>
+      <p class="text-sm text-surface-400 mt-1">Gateway health and connectivity status</p>
+      <div class="mt-2 flex flex-wrap items-center gap-2 text-[11px] font-medium">
+        <span class="px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">up</span>
+        <span class="px-2 py-0.5 rounded-full bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">auth_required</span>
+        <span class="px-2 py-0.5 rounded-full bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300">down</span>
+      </div>
+    </div>
+    <button onclick={loadHealth} disabled={loading} class="px-3 py-1.5 text-xs font-medium text-primary-600 bg-primary-50 hover:bg-primary-100 rounded-lg transition-colors disabled:opacity-50 dark:bg-primary-900/30 dark:text-primary-300">
+      {loading ? "Loading..." : "Refresh"}
+    </button>
+  </div>
+ 
+  {#if requestError}
+    <div class="bg-rose-50 dark:bg-rose-900/20 border border-rose-200 dark:border-rose-700 rounded-xl p-4 shadow-sm">
+      <p class="text-sm font-medium text-rose-700 dark:text-rose-300">Failed to fetch LiteLLM health</p>
+      <p class="text-xs text-rose-600 dark:text-rose-400 mt-1">{requestError}</p>
+    </div>
+  {/if}
+ 
+  <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
+    {#if loading && !health}
+      <div class="space-y-3">
+        <div class="h-6 w-36 rounded bg-surface-100 dark:bg-surface-700 animate-pulse"></div>
+        <div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
+          {#each Array(6) as _}
+            <div class="h-16 rounded-lg bg-surface-100 dark:bg-surface-700 animate-pulse"></div>
+          {/each}
+        </div>
+      </div>
+    {:else if health}
+      <div class="flex items-center justify-between mb-4">
+        <h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200">Health Status</h3>
+        <span class="px-2.5 py-1 text-xs font-semibold rounded-full {statusBadgeClass(health.status)}">{valueOrDash(health.status)}</span>
+      </div>
+ 
+      <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
+        <div class="rounded-lg border border-surface-200 dark:border-surface-700 p-3">
+          <p class="text-xs font-medium text-surface-400 uppercase tracking-wider">OK</p>
+          <p class="text-sm font-semibold text-surface-800 dark:text-surface-100 mt-1">{String(Boolean(health.ok))}</p>
+        </div>
+ 
+        <div class="rounded-lg border border-surface-200 dark:border-surface-700 p-3">
+          <p class="text-xs font-medium text-surface-400 uppercase tracking-wider">Latency</p>
+          <p class="text-sm font-semibold text-surface-800 dark:text-surface-100 mt-1">{health.latency_ms === null || health.latency_ms === undefined ? "—" : `${health.latency_ms} ms`}</p>
+        </div>
+ 
+        <div class="rounded-lg border border-surface-200 dark:border-surface-700 p-3">
+          <p class="text-xs font-medium text-surface-400 uppercase tracking-wider">Endpoint</p>
+          <p class="text-sm font-semibold text-surface-800 dark:text-surface-100 mt-1">{valueOrDash(health.endpoint)}</p>
+        </div>
+ 
+        <div class="rounded-lg border border-surface-200 dark:border-surface-700 p-3">
+          <p class="text-xs font-medium text-surface-400 uppercase tracking-wider">HTTP Status</p>
+          <p class="text-sm font-semibold text-surface-800 dark:text-surface-100 mt-1">{valueOrDash(health.http_status)}</p>
+        </div>
+ 
+        <div class="rounded-lg border border-surface-200 dark:border-surface-700 p-3 sm:col-span-2 lg:col-span-2">
+          <p class="text-xs font-medium text-surface-400 uppercase tracking-wider">Error</p>
+          <p class="text-sm font-semibold text-surface-800 dark:text-surface-100 mt-1 break-all">{valueOrDash(health.error)}</p>
+        </div>
+      </div>
+    {:else}
+      <p class="text-sm text-surface-400">No health data available.</p>
+    {/if}
+  </div>
+</div>
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/lcov-report/src/routes/Login.svelte.html b/dashboard/frontend/coverage/lcov-report/src/routes/Login.svelte.html new file mode 100644 index 0000000..1bf06cf --- /dev/null +++ b/dashboard/frontend/coverage/lcov-report/src/routes/Login.svelte.html @@ -0,0 +1,775 @@ + + + + + + Code coverage report for src/routes/Login.svelte + + + + + + + + + +
+
+

All files / src/routes Login.svelte

+
+ +
+ 41.02% + Statements + 48/117 +
+ + +
+ 30% + Branches + 3/10 +
+ + +
+ 33.33% + Functions + 2/6 +
+ + +
+ 41.02% + Lines + 48/117 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +2313x +  +  +  +3x +3x +3x +3x +3x +3x +3x +  +3x +  +  +  +  +  +3x +  +  +  +  +  +  +3x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +3x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +3x +  +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +1x +  +1x +1x +  +  +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +1x +1x +1x +1x +1x +  +1x +  +  +  +  +1x +  +  +  +  +1x +  +1x +  +  +  +  +1x +  +  +  +  +  +1x +  +1x +  +  +  +  +  +  +  +  +1x +  +  +  +  +1x +1x +  +  +  +  +  +  +  +1x +  +1x +  +  +1x +1x +  +  +  +  +1x +  +  +  +  +1x +1x +1x +  +  +  +  +  +1x +1x +  +  +  +  +  +  +  + 
<script>
+  import { login, setToken } from "../lib/api.js";
+  import { fade, fly } from "svelte/transition";
+ 
+  let username = $state("");
+  let password = $state("");
+  let totp_code = $state("");
+  let error = $state("");
+  let loading = $state(false);
+  let require2FA = $state(false);
+  let showPasswordForm = $state(!window.isSecureContext);
+ 
+  function base64urlToBuffer(b64) {
+    const s = b64.replace(/-/g, '+').replace(/_/g, '/');
+    const raw = atob(s);
+    return Uint8Array.from(raw, c => c.charCodeAt(0)).buffer;
+  }
+ 
+  function bufferToBase64url(buf) {
+    const bytes = new Uint8Array(buf);
+    let s = '';
+    bytes.forEach(b => s += String.fromCharCode(b));
+    return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+  }
+ 
+  async function loginWithPasskey() {
+    loading = true;
+    error = "";
+    try {
+      const r = await fetch("/api/auth/passkey/login/options", { method: "POST" });
+      if (!r.ok) throw new Error("No passkeys registered");
+      const opts = await r.json();
+      opts.challenge = base64urlToBuffer(opts.challenge);
+      if (opts.allowCredentials) {
+        opts.allowCredentials = opts.allowCredentials.map(c => ({
+          ...c, id: base64urlToBuffer(c.id)
+        }));
+      }
+      const cred = await navigator.credentials.get({ publicKey: opts });
+      const body = {
+        id: cred.id,
+        rawId: bufferToBase64url(cred.rawId),
+        type: cred.type,
+        response: {
+          authenticatorData: bufferToBase64url(cred.response.authenticatorData),
+          clientDataJSON: bufferToBase64url(cred.response.clientDataJSON),
+          signature: bufferToBase64url(cred.response.signature),
+          userHandle: cred.response.userHandle ? bufferToBase64url(cred.response.userHandle) : null,
+        },
+      };
+      const res = await fetch("/api/auth/passkey/login/verify", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify(body),
+      });
+      if (!res.ok) { const e = await res.json(); throw new Error(e.detail || "Passkey verification failed"); }
+      const data = await res.json();
+      setToken(data.access_token);
+      window.location.reload();
+    } catch (e) {
+      if (e.name === "NotAllowedError") { error = "Passkey authentication cancelled"; }
+      else { error = e.message || "Passkey login failed"; }
+      showPasswordForm = true;
+    } finally {
+      loading = false;
+    }
+  }
+ 
+  async function handleSubmit(e) {
+    e.preventDefault();
+    loading = true;
+    error = "";
+    
+    try {
+      const payload = { username, password };
+      if (require2FA) payload.totp_code = totp_code;
+      
+      const res = await login(payload); // api.js helper sends JSON
+      
+      setToken(res.access_token);
+      // Determine if we need to reload or just update state. 
+      // Reload is safest to clear any stale state.
+      window.location.reload(); 
+      
+    } catch (e) {
+      if (e.status === 403 && e.body?.detail === "2FA code required") {
+         require2FA = true;
+         // Clear error if any
+         error = "";
+      } else {
+         error = e.body?.detail || "Login failed. Please check your credentials.";
+         // Reset 2FA state on general failure (maybe password was wrong this time)
+         if (!require2FA) {
+            password = "";
+         }
+      }
+    } finally {
+      loading = false;
+    }
+  }
+</script>
+ 
+<div class="flex min-h-screen items-center justify-center bg-surface-50 dark:bg-surface-950 px-4">
+  <div class="w-full max-w-sm space-y-8 bg-white dark:bg-surface-900 p-8 rounded-2xl shadow-xl border border-surface-200 dark:border-surface-800" in:fly={{ y: 20, duration: 400 }}>
+    <div class="text-center">
+      <div class="mx-auto h-12 w-12 bg-gradient-to-br from-primary-500 to-primary-600 rounded-xl flex items-center justify-center shadow-lg mb-4">
+        <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
+        </svg>
+      </div>
+      <h2 class="text-2xl font-bold tracking-tight text-surface-900 dark:text-white">Sign in to NAS</h2>
+      <p class="mt-2 text-sm text-surface-600 dark:text-surface-400">
+        Enter your credentials to access the dashboard
+      </p>
+    </div>
+ 
+    <div class="mt-8 space-y-6">
+      {#if !showPasswordForm && !require2FA}
+        <button
+          type="button"
+          disabled={loading}
+          onclick={loginWithPasskey}
+          class="flex w-full justify-center rounded-lg bg-surface-800 dark:bg-surface-700 px-3 py-2.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-surface-700 dark:hover:bg-surface-600 disabled:opacity-70 transition-all active:scale-[0.98]"
+        >
+          {#if loading}
+            <svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
+              <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
+              <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
+            </svg>
+          {/if}
+          Sign in with Passkey
+        </button>
+ 
+        <div class="relative">
+          <div class="absolute inset-0 flex items-center"><div class="w-full border-t border-surface-200 dark:border-surface-700"></div></div>
+          <div class="relative flex justify-center text-xs"><span class="bg-white dark:bg-surface-900 px-2 text-surface-400">or</span></div>
+        </div>
+ 
+        <button type="button" onclick={() => showPasswordForm = true} class="flex w-full justify-center text-sm text-primary-500 hover:text-primary-400">
+          Use password instead
+        </button>
+      {/if}
+ 
+      {#if showPasswordForm || require2FA}
+      <form class="space-y-6" onsubmit={handleSubmit}>
+      <div class="space-y-4">
+        {#if !require2FA}
+          <div>
+            <label for="username" class="sr-only">Username</label>
+            <input
+              id="username"
+              name="username"
+              type="text"
+              required
+              bind:value={username}
+              class="relative block w-full rounded-lg border-0 py-2.5 px-3 text-surface-900 dark:text-white ring-1 ring-inset ring-surface-300 dark:ring-surface-700 placeholder:text-surface-400 focus:z-10 focus:ring-2 focus:ring-inset focus:ring-primary-600 dark:bg-surface-800 sm:text-sm sm:leading-6"
+              placeholder="Username"
+            />
+          </div>
+          <div>
+            <label for="password" class="sr-only">Password</label>
+            <input
+              id="password"
+              name="password"
+              type="password"
+              required
+              bind:value={password}
+              class="relative block w-full rounded-lg border-0 py-2.5 px-3 text-surface-900 dark:text-white ring-1 ring-inset ring-surface-300 dark:ring-surface-700 placeholder:text-surface-400 focus:z-10 focus:ring-2 focus:ring-inset focus:ring-primary-600 dark:bg-surface-800 sm:text-sm sm:leading-6"
+              placeholder="Password"
+            />
+          </div>
+        {:else}
+          <div in:fade>
+            <label for="totp" class="block text-sm font-medium leading-6 text-surface-900 dark:text-white text-center mb-2">Authenticator Code</label>
+            <input
+              id="totp"
+              name="totp"
+              type="text"
+              inputmode="numeric"
+              pattern="[0-9]*"
+              autocomplete="one-time-code"
+              required
+              autofocus
+              bind:value={totp_code}
+              class="block w-full text-center tracking-[0.5em] text-2xl font-mono rounded-lg border-0 py-3 text-surface-900 dark:text-white ring-1 ring-inset ring-surface-300 dark:ring-surface-700 placeholder:text-surface-400 focus:ring-2 focus:ring-inset focus:ring-primary-600 dark:bg-surface-800"
+              placeholder="000000"
+              maxlength="6"
+            />
+            <p class="mt-4 text-center text-sm text-surface-500">
+              <button type="button" class="text-primary-500 hover:text-primary-400" onclick={() => require2FA = false}>
+                Back to credentials
+              </button>
+            </p>
+          </div>
+        {/if}
+      </div>
+ 
+        <button
+          type="submit"
+          disabled={loading}
+          class="flex w-full justify-center rounded-lg bg-primary-600 px-3 py-2.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-primary-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 disabled:opacity-70 transition-all active:scale-[0.98]"
+        >
+          {#if loading}
+            <svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
+              <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
+              <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
+            </svg>
+          {/if}
+          {require2FA ? "Verify Code" : "Sign in"}
+        </button>
+      </form>
+      {/if}
+ 
+      {#if error}
+        <div class="rounded-md bg-red-50 dark:bg-red-900/30 p-3" in:fade>
+          <div class="flex">
+            <div class="flex-shrink-0">
+              <svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
+                <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
+              </svg>
+            </div>
+            <div class="ml-3">
+              <h3 class="text-sm font-medium text-red-800 dark:text-red-200">{error}</h3>
+            </div>
+          </div>
+        </div>
+      {/if}
+    </div>
+  </div>
+</div>
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/lcov-report/src/routes/OPC.svelte.html b/dashboard/frontend/coverage/lcov-report/src/routes/OPC.svelte.html new file mode 100644 index 0000000..519fb34 --- /dev/null +++ b/dashboard/frontend/coverage/lcov-report/src/routes/OPC.svelte.html @@ -0,0 +1,709 @@ + + + + + + Code coverage report for src/routes/OPC.svelte + + + + + + + + + +
+
+

All files / src/routes OPC.svelte

+
+ +
+ 0% + Statements + 0/129 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/129 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
<script>
+  import { onMount, onDestroy } from "svelte";
+  import KanbanBoard from "../components/opc/KanbanBoard.svelte";
+  import TaskModal from "../components/opc/TaskModal.svelte";
+  import AgentPanel from "../components/opc/AgentPanel.svelte";
+  import { getTasks, getAgents } from "../lib/opc-api.js";
+  import * as opcWs from "../lib/opc-ws.js";
+ 
+  let tasks = $state([]);
+  let agents = $state([]);
+  let loading = $state(true);
+  let showTaskModal = $state(false);
+  let editingTask = $state(null);
+  let showAgentPanel = $state(
+    typeof localStorage !== 'undefined'
+      ? localStorage.getItem('opc_showAgentPanel') !== 'false'
+      : true
+  );
+  let unsubscribe = null;
+ 
+  // Persist agent panel visibility
+  $effect(() => {
+    if (typeof localStorage !== 'undefined') {
+      localStorage.setItem('opc_showAgentPanel', showAgentPanel);
+    }
+  });
+ 
+  async function loadData() {
+    loading = true;
+    try {
+      const [tasksRes, agentsRes] = await Promise.all([
+        getTasks(),
+        getAgents()
+      ]);
+      tasks = tasksRes.items || [];
+      agents = agentsRes.items || [];
+    } catch (e) {
+      console.error("Failed to load data:", e);
+    } finally {
+      loading = false;
+    }
+  }
+ 
+  function handleWebSocketMessage(message) {
+    if (message.type === "task_update") {
+      const { task_id, action, data } = message;
+ 
+      if (action === "created") {
+        tasks = [...tasks, data];
+      } else if (action === "updated" || action === "moved") {
+        tasks = tasks.map(t => t.id === task_id ? data : t);
+      } else if (action === "deleted") {
+        tasks = tasks.filter(t => t.id !== task_id);
+      }
+    } else if (message.type === "agent_execution") {
+      // Could show agent status in UI
+      console.log("Agent execution update:", message);
+    }
+  }
+ 
+  function handleCreateTask() {
+    editingTask = null;
+    showTaskModal = true;
+  }
+ 
+  function handleEditTask(task) {
+    editingTask = task;
+    showTaskModal = true;
+  }
+ 
+  function handleDeleteTask(taskId) {
+    tasks = tasks.filter(t => t.id !== taskId);
+  }
+ 
+  function handleAssignTask(taskId, assignedTo, assignedType) {
+    const task = tasks.find(t => t.id === taskId);
+    if (task) {
+      task.assigned_to = assignedTo;
+      task.assigned_type = assignedType;
+      tasks = [...tasks];
+    }
+  }
+ 
+  function handleTaskMoved(taskId, newStatus) {
+    const task = tasks.find(t => t.id === taskId);
+    if (task) {
+      task.status = newStatus;
+      tasks = [...tasks];
+    }
+  }
+ 
+  function handleModalClose() {
+    showTaskModal = false;
+    editingTask = null;
+  }
+ 
+  function handleModalSave() {
+    loadData();
+  }
+ 
+  onMount(() => {
+    loadData();
+ 
+    // Connect WebSocket
+    opcWs.connect();
+    unsubscribe = opcWs.subscribe(handleWebSocketMessage);
+  });
+ 
+  onDestroy(() => {
+    if (unsubscribe) {
+      unsubscribe();
+    }
+    opcWs.disconnect();
+  });
+</script>
+ 
+<div class="space-y-6 h-full flex flex-col">
+  <!-- Header -->
+  <div class="flex items-center justify-between">
+    <div>
+      <h1 class="text-2xl font-bold text-surface-900 dark:text-surface-100">
+        OPC Management
+      </h1>
+      <p class="text-surface-600 dark:text-surface-400 mt-1">
+        Manage your tasks with AI agents
+      </p>
+    </div>
+    <div class="flex gap-2">
+      <button
+        onclick={() => showAgentPanel = !showAgentPanel}
+        class="px-4 py-2 bg-surface-200 dark:bg-surface-700 text-surface-900 dark:text-surface-100 rounded-lg hover:bg-surface-300 dark:hover:bg-surface-600 flex items-center gap-2"
+      >
+        <span>🤖</span>
+        <span>{showAgentPanel ? "Hide" : "Show"} Agents</span>
+      </button>
+      <button
+        onclick={handleCreateTask}
+        class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 flex items-center gap-2"
+      >
+        <span>➕</span>
+        <span>New Task</span>
+      </button>
+    </div>
+  </div>
+ 
+  <!-- Stats -->
+  <div class="grid grid-cols-1 md:grid-cols-4 gap-4">
+    <div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
+      <div class="text-sm text-surface-600 dark:text-surface-400">Total Tasks</div>
+      <div class="text-2xl font-bold text-surface-900 dark:text-surface-100 mt-1">
+        {tasks.length}
+      </div>
+    </div>
+    <div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
+      <div class="text-sm text-surface-600 dark:text-surface-400">Parking Lot</div>
+      <div class="text-2xl font-bold text-slate-600 dark:text-slate-400 mt-1">
+        {tasks.filter(t => t.status === "parking_lot").length}
+      </div>
+    </div>
+    <div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
+      <div class="text-sm text-surface-600 dark:text-surface-400">In Progress</div>
+      <div class="text-2xl font-bold text-blue-600 dark:text-blue-400 mt-1">
+        {tasks.filter(t => t.status === "in_progress").length}
+      </div>
+    </div>
+    <div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
+      <div class="text-sm text-surface-600 dark:text-surface-400">Done</div>
+      <div class="text-2xl font-bold text-emerald-600 dark:text-emerald-400 mt-1">
+        {tasks.filter(t => t.status === "done").length}
+      </div>
+    </div>
+  </div>
+ 
+  <!-- Kanban Board -->
+  {#if loading}
+    <div class="flex-1 flex items-center justify-center">
+      <div class="text-surface-600 dark:text-surface-400">Loading...</div>
+    </div>
+  {:else}
+    <div class="flex-1 overflow-hidden">
+      <KanbanBoard
+        {tasks}
+        {agents}
+        onEdit={handleEditTask}
+        onDelete={handleDeleteTask}
+        onAssign={handleAssignTask}
+        onTaskMoved={handleTaskMoved}
+      />
+    </div>
+  {/if}
+ 
+  <!-- Agent Panel -->
+  {#if showAgentPanel}
+    <div class="mt-6">
+      <AgentPanel />
+    </div>
+  {/if}
+</div>
+ 
+<!-- Task Modal -->
+{#if showTaskModal}
+  <TaskModal
+    task={editingTask}
+    {agents}
+    onClose={handleModalClose}
+    onSave={handleModalSave}
+  />
+{/if}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/lcov-report/src/routes/OpenClaw.svelte.html b/dashboard/frontend/coverage/lcov-report/src/routes/OpenClaw.svelte.html new file mode 100644 index 0000000..e27d18a --- /dev/null +++ b/dashboard/frontend/coverage/lcov-report/src/routes/OpenClaw.svelte.html @@ -0,0 +1,283 @@ + + + + + + Code coverage report for src/routes/OpenClaw.svelte + + + + + + + + + +
+
+

All files / src/routes OpenClaw.svelte

+
+ +
+ 0% + Statements + 0/22 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/22 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
<script>
+  import { onMount } from "svelte";
+  import { get } from "../lib/api.js";
+ 
+  let isTailscale = location.hostname.startsWith("100.");
+  let isLocal = location.hostname === "localhost" || location.hostname === "127.0.0.1" || location.hostname.startsWith("192.168.");
+  let canAccess = isTailscale || isLocal;
+  let token = $state("");
+ 
+  onMount(async () => {
+    if (canAccess) {
+      const res = await get("/system/openclaw-token");
+      token = res?.token || "";
+    }
+  });
+ 
+  function clawUrl() {
+    const base = `${location.protocol}//${location.hostname}:3100`;
+    return token ? `${base}/#token=${token}` : base;
+  }
+</script>
+ 
+<div class="space-y-4">
+  <div>
+    <h1 class="text-2xl font-bold text-surface-900 tracking-tight">OpenClaw</h1>
+    <p class="text-sm text-surface-400 mt-1">AI Assistant</p>
+  </div>
+ 
+  {#if canAccess}
+    <div class="flex flex-col items-center justify-center p-12 text-center bg-surface-50 dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 h-[calc(100vh-10rem)]">
+      <div class="h-16 w-16 bg-primary-100 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400 rounded-2xl flex items-center justify-center mb-6">
+        <svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
+        </svg>
+      </div>
+      <h2 class="text-xl font-semibold text-surface-900 dark:text-white mb-2">OpenClaw Ready</h2>
+      <p class="text-surface-600 dark:text-surface-400 max-w-md mb-8">
+        OpenClaw's security settings prevent it from being embedded inside the dashboard. Click below to launch the assistant securely in a new tab.
+      </p>
+      <a
+        href={clawUrl()}
+        target="_blank"
+        rel="noopener noreferrer"
+        class="inline-flex items-center justify-center px-6 py-3 border border-transparent text-base font-medium rounded-lg text-white bg-primary-600 hover:bg-primary-700 shadow-sm transition-colors"
+      >
+        Launch OpenClaw
+        <svg xmlns="http://www.w3.org/2000/svg" class="ml-2 -mr-1 h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
+        </svg>
+      </a>
+    </div>
+  {:else}
+    <div class="flex flex-col items-center justify-center p-12 text-center bg-surface-50 dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 h-[calc(100vh-10rem)]">
+      <svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-surface-400 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
+      </svg>
+      <h2 class="text-xl font-semibold text-surface-900 dark:text-white mb-2">Tailscale Network Required</h2>
+      <p class="text-surface-600 dark:text-surface-400 max-w-md">
+        For security reasons, OpenClaw is only accessible when connected directly to the NAS via the internal Tailscale network.
+      </p>
+      <p class="mt-4 text-sm text-surface-500">
+        Connect to Tailscale and visit <a href="http://100.78.131.124:4000" class="text-primary-500 hover:underline font-medium">http://100.78.131.124:4000</a> to use the AI Assistant.
+      </p>
+    </div>
+  {/if}
+</div>
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/lcov-report/src/routes/Security.svelte.html b/dashboard/frontend/coverage/lcov-report/src/routes/Security.svelte.html new file mode 100644 index 0000000..f999709 --- /dev/null +++ b/dashboard/frontend/coverage/lcov-report/src/routes/Security.svelte.html @@ -0,0 +1,934 @@ + + + + + + Code coverage report for src/routes/Security.svelte + + + + + + + + + +
+
+

All files / src/routes Security.svelte

+
+ +
+ 0% + Statements + 0/171 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/171 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
<script>
+  import { get } from "../lib/api.js";
+  import { onMount } from "svelte";
+ 
+  let logs = $state([]);
+  let stats = $state(null);
+  let loading = $state(false);
+  let filter = $state("all");
+  let ipFilter = $state("");
+  let startDate = $state("");
+  let endDate = $state("");
+  let filterError = $state("");
+ 
+  onMount(loadAll);
+ 
+  async function loadAll() {
+    loading = true;
+    await Promise.all([loadStats(), loadLogs()]);
+    loading = false;
+  }
+ 
+  async function loadStats() {
+    try {
+      stats = await get("/security/stats");
+    } catch (e) {
+      console.error("Failed to load security stats:", e);
+    }
+  }
+ 
+  function buildLogsQuery() {
+    const params = new URLSearchParams({ limit: "300" });
+    if (filter !== "all") params.set("type", filter);
+    if (ipFilter.trim()) params.set("ip", ipFilter.trim());
+    if (startDate) params.set("start_date", startDate);
+    if (endDate) params.set("end_date", endDate);
+    return params.toString();
+  }
+ 
+  function validateFilters() {
+    if (startDate && endDate && startDate > endDate) {
+      filterError = "From date must be on or before To date";
+      return false;
+    }
+    filterError = "";
+    return true;
+  }
+ 
+  async function loadLogs() {
+    if (!validateFilters()) {
+      logs = [];
+      return;
+    }
+ 
+    try {
+      const res = await get(`/security/logs?${buildLogsQuery()}`);
+      logs = res.logs;
+    } catch (e) {
+      console.error("Failed to load security logs:", e);
+    }
+  }
+ 
+  async function applyTypeFilter(nextFilter) {
+    filter = nextFilter;
+    await loadLogs();
+  }
+ 
+  async function applyIpFilter(ip) {
+    ipFilter = ip;
+    await loadLogs();
+  }
+ 
+  async function clearFilters() {
+    filter = "all";
+    ipFilter = "";
+    startDate = "";
+    endDate = "";
+    filterError = "";
+    await loadLogs();
+  }
+ 
+  let maxTimelineCount = $derived(
+    stats ? Math.max(1, ...stats.timeline.map(t => t.count)) : 1
+  );
+ 
+  let hasActiveFilters = $derived(
+    filter !== "all" || !!ipFilter.trim() || !!startDate || !!endDate
+  );
+ 
+  function isBlockedScannerEvent(entry) {
+    if (entry.type !== "unauthorized") return false;
+ 
+    const message = (entry.message || "").toLowerCase();
+    return [
+      "/.git/",
+      "phpinfo",
+      "debug.log",
+      "laravel.log",
+      "stripe",
+      "swagger.json",
+      "wp-config",
+      "credentials",
+      "secrets",
+      "config",
+      "@vite",
+      "manifest.json",
+    ].some((needle) => message.includes(needle));
+  }
+ 
+  const logTypeFilters = ["all", "auth_failure", "suspicious_request", "unauthorized"];
+  const logTypeButtonLabels = {
+    all: "All",
+    auth_failure: "Auth",
+    suspicious_request: "Requests",
+    unauthorized: "Blocked",
+  };
+ 
+  const typeLabels = { auth_failure: "Auth", suspicious_request: "Request", unauthorized: "Blocked", error: "Error" };
+  const typeColors = {
+    auth_failure: "text-red-400",
+    suspicious_request: "text-amber-400",
+    unauthorized: "text-orange-400",
+    error: "text-surface-500"
+  };
+</script>
+ 
+<div class="space-y-6">
+  <div class="flex items-center justify-between">
+    <h2 class="text-lg font-bold text-surface-800 dark:text-surface-100">Security</h2>
+    <button onclick={loadAll} disabled={loading} class="px-3 py-1.5 text-xs font-medium text-primary-600 bg-primary-50 hover:bg-primary-100 rounded-lg transition-colors disabled:opacity-50 dark:bg-primary-900/30 dark:text-primary-300">
+      {loading ? "Loading..." : "Refresh"}
+    </button>
+  </div>
+ 
+  <!-- Stats cards -->
+  {#if stats}
+    <div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
+      <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
+        <p class="text-xs font-medium text-surface-400 uppercase tracking-wider">Failed Logins (24h)</p>
+        <p class="text-2xl font-bold text-red-600 dark:text-red-400 mt-1">{stats.failed_logins_24h}</p>
+      </div>
+      <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
+        <p class="text-xs font-medium text-surface-400 uppercase tracking-wider">Suspicious Requests</p>
+        <p class="text-2xl font-bold text-amber-600 dark:text-amber-400 mt-1">{stats.suspicious_requests}</p>
+      </div>
+      <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
+        <p class="text-xs font-medium text-surface-400 uppercase tracking-wider">Unique IPs</p>
+        <p class="text-2xl font-bold text-surface-700 dark:text-surface-200 mt-1">{stats.unique_ips}</p>
+      </div>
+    </div>
+  {/if}
+ 
+  <!-- Timeline chart -->
+  {#if stats?.timeline}
+    <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
+      <h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200 mb-3">Activity (Last 24h)</h3>
+      <div class="flex items-end gap-1 h-24">
+        {#each [...stats.timeline].reverse() as bar}
+          <div class="flex-1 flex flex-col items-center justify-end h-full">
+            <div
+              class="w-full rounded-t transition-all {bar.count > 0 ? 'bg-primary-500 dark:bg-primary-400' : 'bg-surface-100 dark:bg-surface-700'}"
+              style="height: {bar.count > 0 ? Math.max(8, (bar.count / maxTimelineCount) * 100) : 4}%"
+              title="{bar.hours_ago}h ago: {bar.count} events"
+            ></div>
+          </div>
+        {/each}
+      </div>
+      <div class="flex justify-between mt-1">
+        <span class="text-[10px] text-surface-400">24h ago</span>
+        <span class="text-[10px] text-surface-400">now</span>
+      </div>
+    </div>
+  {/if}
+ 
+  <!-- Top IPs -->
+  {#if stats?.top_ips?.length}
+    <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
+      <h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200 mb-3">Top IPs</h3>
+      <div class="space-y-2">
+        {#each stats.top_ips as entry}
+          <div class="flex items-center gap-3 text-sm">
+            <button onclick={() => applyIpFilter(entry.ip)} class="text-xs font-mono text-surface-600 hover:text-primary-600 dark:text-surface-300 dark:hover:text-primary-300 w-36 shrink-0 text-left transition-colors">{entry.ip}</button>
+            <div class="flex-1 bg-surface-100 dark:bg-surface-700 rounded-full h-2 overflow-hidden">
+              <div class="bg-red-500 dark:bg-red-400 h-full rounded-full" style="width: {(entry.count / stats.top_ips[0].count) * 100}%"></div>
+            </div>
+            <span class="text-xs text-surface-500 w-8 text-right">{entry.count}</span>
+          </div>
+        {/each}
+      </div>
+    </div>
+  {/if}
+ 
+  <!-- Log table -->
+  <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
+    <div class="flex items-start justify-between gap-3 mb-3">
+      <div>
+        <h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200">Event Log</h3>
+        <p class="text-xs text-surface-400 mt-1">Filter recent events by type, IP, or date range.</p>
+      </div>
+      {#if hasActiveFilters}
+        <button onclick={clearFilters} class="px-2.5 py-1 text-xs rounded-md transition-colors bg-surface-100 text-surface-500 hover:bg-surface-200 dark:bg-surface-700 dark:text-surface-300 dark:hover:bg-surface-600">
+          Clear filters
+        </button>
+      {/if}
+    </div>
+ 
+    <div class="flex flex-wrap gap-1.5 mb-3">
+      {#each logTypeFilters as f}
+        <button onclick={() => applyTypeFilter(f)} class="px-2.5 py-1 text-xs rounded-md transition-colors {filter === f ? 'bg-surface-800 text-white dark:bg-surface-200 dark:text-surface-900' : 'bg-surface-100 text-surface-500 hover:bg-surface-200 dark:bg-surface-700 dark:text-surface-300 dark:hover:bg-surface-600'}">
+          {logTypeButtonLabels[f]}
+        </button>
+      {/each}
+    </div>
+ 
+    <div class="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-3">
+      <label class="block">
+        <span class="block text-xs font-medium text-surface-500 mb-1">Filter by IP</span>
+        <input
+          type="text"
+          bind:value={ipFilter}
+          placeholder="e.g. 1.2.3.4"
+          onkeydown={async (e) => { if (e.key === 'Enter') await loadLogs(); }}
+          class="w-full px-3 py-2 text-sm border border-surface-200 dark:border-surface-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-surface-700 dark:text-white"
+        />
+      </label>
+      <label class="block">
+        <span class="block text-xs font-medium text-surface-500 mb-1">From</span>
+        <input
+          type="date"
+          bind:value={startDate}
+          onchange={loadLogs}
+          class="w-full px-3 py-2 text-sm border border-surface-200 dark:border-surface-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-surface-700 dark:text-white"
+        />
+      </label>
+      <label class="block">
+        <span class="block text-xs font-medium text-surface-500 mb-1">To</span>
+        <input
+          type="date"
+          bind:value={endDate}
+          onchange={loadLogs}
+          class="w-full px-3 py-2 text-sm border border-surface-200 dark:border-surface-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-surface-700 dark:text-white"
+        />
+      </label>
+    </div>
+ 
+    <div class="flex items-center gap-2 mb-3">
+      <button onclick={loadLogs} class="px-3 py-1.5 text-xs font-medium text-primary-600 bg-primary-50 hover:bg-primary-100 rounded-lg transition-colors dark:bg-primary-900/30 dark:text-primary-300">
+        Apply filters
+      </button>
+      {#if ipFilter.trim()}
+        <button onclick={() => applyIpFilter(ipFilter.trim())} class="px-2.5 py-1 text-xs rounded-md transition-colors bg-surface-100 text-surface-500 hover:bg-surface-200 dark:bg-surface-700 dark:text-surface-300 dark:hover:bg-surface-600">
+          Use typed IP
+        </button>
+      {/if}
+    </div>
+ 
+    {#if filterError}
+      <p class="text-sm text-rose-500 mb-3">{filterError}</p>
+    {/if}
+ 
+    {#if logs.length > 0}
+      <div class="max-h-96 overflow-auto rounded-lg bg-surface-950 p-3 space-y-0.5">
+        {#each logs as e}
+          <div class="text-xs font-mono flex gap-2 leading-5">
+            <span class="{typeColors[e.type] || 'text-surface-500'} w-12 shrink-0">{typeLabels[e.type] || e.type}</span>
+            <span class="text-surface-500 shrink-0">{e.timestamp}</span>
+            {#if e.ip}
+              <button onclick={() => applyIpFilter(e.ip)} class="text-surface-400 hover:text-primary-300 shrink-0 w-28 text-left transition-colors">{e.ip}</button>
+            {/if}
+            {#if e.username}
+              <span class="text-red-400 shrink-0">{e.username}</span>
+            {/if}
+            {#if isBlockedScannerEvent(e)}
+              <span class="shrink-0 rounded-md bg-orange-500/15 px-1.5 py-0.5 text-[10px] font-medium text-orange-300 border border-orange-500/30">scanner</span>
+            {/if}
+            <span class="text-surface-300 truncate">{e.message}</span>
+          </div>
+        {/each}
+      </div>
+    {:else if !loading}
+      <p class="text-sm text-surface-400">{hasActiveFilters ? "No security events match the current filters" : "No security events found"}</p>
+    {/if}
+  </div>
+</div>
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/lcov-report/src/routes/Settings.svelte.html b/dashboard/frontend/coverage/lcov-report/src/routes/Settings.svelte.html new file mode 100644 index 0000000..79728ca --- /dev/null +++ b/dashboard/frontend/coverage/lcov-report/src/routes/Settings.svelte.html @@ -0,0 +1,1597 @@ + + + + + + Code coverage report for src/routes/Settings.svelte + + + + + + + + + +
+
+

All files / src/routes Settings.svelte

+
+ +
+ 0% + Statements + 0/362 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/362 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504 +505  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
<script>
+  import { post, get, del, put } from "../lib/api.js";
+  import { onMount } from "svelte";
+  import { currentUser } from "../lib/api.js";
+ 
+  let currentPassword = $state("");
+  let newPassword = $state("");
+  let confirmPassword = $state("");
+  let message = $state("");
+  let error = $state("");
+  let saving = $state(false);
+ 
+  let passkeys = $state([]);
+  let passkeyMsg = $state("");
+  let passkeyErr = $state("");
+  let passkeyLoading = $state(false);
+ 
+  async function loadPasskeys() {
+    try {
+      const res = await get("/auth/passkey/list");
+      passkeys = res.passkeys;
+    } catch {}
+  }
+ 
+  function base64urlToBuffer(b64) {
+    const s = b64.replace(/-/g, '+').replace(/_/g, '/');
+    const raw = atob(s);
+    return Uint8Array.from(raw, c => c.charCodeAt(0)).buffer;
+  }
+ 
+  function bufferToBase64url(buf) {
+    const bytes = new Uint8Array(buf);
+    let s = '';
+    bytes.forEach(b => s += String.fromCharCode(b));
+    return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+  }
+ 
+  async function registerPasskey() {
+    alert("registerPasskey called!");
+    passkeyLoading = true;
+    passkeyMsg = ""; passkeyErr = "";
+    try {
+      alert("Checking PublicKeyCredential support...");
+      if (!window.PublicKeyCredential) {
+        alert("PublicKeyCredential NOT supported!");
+        throw new Error("Passkeys are not supported in this browser or context");
+      }
+      alert("Fetching registration options...");
+      const opts = await post("/auth/passkey/register/options");
+      alert("Got options: " + JSON.stringify(opts).substring(0, 100));
+      opts.challenge = base64urlToBuffer(opts.challenge);
+      opts.user.id = base64urlToBuffer(opts.user.id);
+      if (opts.excludeCredentials) {
+        opts.excludeCredentials = opts.excludeCredentials.map(c => ({ ...c, id: base64urlToBuffer(c.id) }));
+      }
+      const cred = await navigator.credentials.create({ publicKey: opts });
+      if (!cred) {
+        throw new Error("Passkey creation was cancelled");
+      }
+      const name = prompt("Name this passkey (e.g. MacBook, iPhone):", "Passkey") || "Passkey";
+      const body = {
+        id: cred.id,
+        rawId: bufferToBase64url(cred.rawId),
+        type: cred.type,
+        name,
+        response: {
+          attestationObject: bufferToBase64url(cred.response.attestationObject),
+          clientDataJSON: bufferToBase64url(cred.response.clientDataJSON),
+        },
+      };
+      await post("/auth/passkey/register/verify", body);
+      passkeyMsg = "Passkey registered successfully";
+      await loadPasskeys();
+    } catch (e) {
+      console.error("Passkey registration error:", e);
+      const errorMsg = e.body?.detail || e.message || "Failed to register passkey";
+      passkeyErr = errorMsg;
+      alert("Passkey Error: " + errorMsg + "\n\nFull error: " + JSON.stringify(e, null, 2));
+    } finally {
+      passkeyLoading = false;
+    }
+  }
+ 
+  async function clearPasskeys() {
+    if (!confirm("Remove all passkeys?")) return;
+    passkeyLoading = true;
+    passkeyMsg = ""; passkeyErr = "";
+    try {
+      await post("/auth/passkey/clear");
+      passkeyMsg = "All passkeys removed";
+      passkeys = [];
+    } catch (e) {
+      passkeyErr = e.body?.detail || "Failed to remove passkeys";
+    }
+    passkeyLoading = false;
+  }
+ 
+  async function deletePasskey(id, name) {
+    if (!confirm(`Remove passkey "${name}"?`)) return;
+    passkeyLoading = true;
+    passkeyMsg = ""; passkeyErr = "";
+    try {
+      await post("/auth/passkey/delete", { id });
+      passkeyMsg = `Passkey "${name}" removed`;
+      await loadPasskeys();
+    } catch (e) {
+      passkeyErr = e.body?.detail || "Failed to remove passkey";
+    }
+    passkeyLoading = false;
+  }
+ 
+  async function changePassword() {
+    error = "";
+    message = "";
+    if (newPassword !== confirmPassword) { error = "Passwords do not match"; return; }
+    if (newPassword.length < 8) { error = "Password must be at least 8 characters"; return; }
+    saving = true;
+    try {
+      await post("/auth/change-password", { current_password: currentPassword, new_password: newPassword });
+      message = "Password changed successfully";
+      currentPassword = ""; newPassword = ""; confirmPassword = "";
+    } catch (e) {
+      error = e.body?.detail || "Failed to change password";
+    }
+    saving = false;
+  }
+ 
+  let auditEntries = $state([]);
+  let auditLoading = $state(false);
+  let auditFilter = $state("all");
+ 
+  const levelColors = { high: "text-rose-400", medium: "text-amber-400", low: "text-emerald-400", info: "text-surface-400" };
+  const levelLabels = { high: "HIGH", medium: "MED", low: "LOW", info: "INFO" };
+ 
+  let filteredEntries = $derived(auditFilter === "all" ? auditEntries : auditEntries.filter(e => e.level === auditFilter));
+ 
+  async function loadAuditLog() {
+    auditLoading = true;
+    try {
+      const res = await get("/system/audit-log?lines=200");
+      auditEntries = res.entries.reverse();
+    } catch {}
+    auditLoading = false;
+  }
+ 
+  // Access Control
+  const allPages = ["dashboard", "docker", "files", "terminal", "security", "openclaw", "chat-digest", "gitea", "settings"];
+  const allSidebarLinks = ["Overview", "Docker", "Files", "Terminal", "Security", "Navidrome", "Jellyfin", "Audiobookshelf", "Immich", "OpenClaw", "Chat Digest", "Repos", "Gitea Web", "Stirling PDF", "Vaultwarden", "n8n", "Speedtest"];
+  let rbacConfig = $state(null);
+  let rbacLoading = $state(false);
+  let rbacMsg = $state("");
+  let rbacErr = $state("");
+  let editingUser = $state("");
+  let editPages = $state([]);
+  let newUsername = $state("");
+  let editingRole = $state("");
+  let editRolePages = $state([]);
+  let editRoleSidebarLinks = $state([]);
+ 
+  async function loadRbac() {
+    rbacLoading = true;
+    try {
+      rbacConfig = await get("/auth/rbac/config");
+    } catch {}
+    rbacLoading = false;
+  }
+ 
+  function startEdit(username, pages) {
+    editingUser = username;
+    editPages = [...pages];
+    newUsername = "";
+    editingRole = "";
+  }
+ 
+  function startAdd() {
+    editingUser = "__new__";
+    editPages = ["dashboard"];
+    newUsername = "";
+    editingRole = "";
+  }
+ 
+  function cancelEdit() {
+    editingUser = "";
+    editPages = [];
+    newUsername = "";
+    editingRole = "";
+    editRolePages = [];
+    editRoleSidebarLinks = [];
+  }
+ 
+  function startEditRole(role, pages, sidebarLinks) {
+    editingRole = role;
+    editRolePages = pages === "*" ? ["*"] : [...pages];
+    editRoleSidebarLinks = sidebarLinks === "*" ? ["*"] : (sidebarLinks ? [...sidebarLinks] : ["*"]);
+    editingUser = "";
+  }
+ 
+  function toggleRolePage(p) {
+    if (p === "*") {
+      editRolePages = ["*"];
+    } else {
+      if (editRolePages.includes("*")) editRolePages = [];
+      if (editRolePages.includes(p)) editRolePages = editRolePages.filter(x => x !== p);
+      else editRolePages = [...editRolePages, p];
+    }
+  }
+ 
+  function toggleRoleSidebarLink(link) {
+    if (link === "*") {
+      editRoleSidebarLinks = ["*"];
+    } else {
+      if (editRoleSidebarLinks.includes("*")) editRoleSidebarLinks = [];
+      if (editRoleSidebarLinks.includes(link)) editRoleSidebarLinks = editRoleSidebarLinks.filter(x => x !== link);
+      else editRoleSidebarLinks = [...editRoleSidebarLinks, link];
+    }
+  }
+ 
+  function togglePage(p) {
+    if (editPages.includes(p)) editPages = editPages.filter(x => x !== p);
+    else editPages = [...editPages, p];
+  }
+ 
+  async function saveOverride() {
+    rbacMsg = ""; rbacErr = "";
+    const uname = editingUser === "__new__" ? newUsername.trim() : editingUser;
+    if (!uname) { rbacErr = "Username required"; return; }
+    if (!editPages.length) { rbacErr = "Select at least one page"; return; }
+    try {
+      await put(`/auth/rbac/overrides/${encodeURIComponent(uname)}`, { pages: editPages });
+      rbacMsg = `Saved override for ${uname}`;
+      cancelEdit();
+      await loadRbac();
+    } catch (e) {
+      rbacErr = e.body?.detail || "Failed to save";
+    }
+  }
+ 
+  async function deleteOverride(username) {
+    if (!confirm(`Remove override for "${username}"?`)) return;
+    rbacMsg = ""; rbacErr = "";
+    try {
+      await del(`/auth/rbac/overrides/${encodeURIComponent(username)}`);
+      rbacMsg = `Removed override for ${username}`;
+      await loadRbac();
+    } catch (e) {
+      rbacErr = e.body?.detail || "Failed to delete";
+    }
+  }
+ 
+  async function saveRoleDefault() {
+    rbacMsg = ""; rbacErr = "";
+    if (!editingRole) return;
+    if (!editRolePages.length) { rbacErr = "Select at least one page or '*' for all"; return; }
+    if (!editRoleSidebarLinks.length) { rbacErr = "Select at least one sidebar link or '*' for all"; return; }
+    try {
+      const pages = editRolePages.includes("*") ? "*" : editRolePages;
+      const sidebar_links = editRoleSidebarLinks.includes("*") ? "*" : editRoleSidebarLinks;
+      await put(`/auth/rbac/roles/${encodeURIComponent(editingRole)}`, { pages, sidebar_links });
+      rbacMsg = `Updated role default for ${editingRole}`;
+      cancelEdit();
+      await loadRbac();
+    } catch (e) {
+      rbacErr = e.body?.detail || "Failed to save role";
+    }
+  }
+ 
+  onMount(() => {
+    loadPasskeys();
+    if (currentUser.role === "admin") loadRbac();
+  });
+</script>
+ 
+<div class="space-y-6">
+  <div>
+    <h1 class="text-2xl font-bold text-surface-900 dark:text-white tracking-tight">Settings</h1>
+    <p class="text-sm text-surface-400 mt-1">Account and security settings</p>
+  </div>
+ 
+  <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-6 shadow-sm max-w-md">
+    <h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200 mb-4">Change Password</h3>
+ 
+    {#if message}
+      <div class="text-sm text-emerald-600 bg-emerald-50 px-3 py-2 rounded-lg mb-4">{message}</div>
+    {/if}
+    {#if error}
+      <div class="text-sm text-rose-600 bg-rose-50 px-3 py-2 rounded-lg mb-4">{error}</div>
+    {/if}
+ 
+    <form onsubmit={(e) => { e.preventDefault(); changePassword(); }} class="space-y-3">
+      <div>
+        <label class="block text-xs font-medium text-surface-500 mb-1">Current Password</label>
+        <input type="password" bind:value={currentPassword} required class="w-full px-3 py-2 text-sm border border-surface-200 dark:border-surface-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-surface-700 dark:text-white" />
+      </div>
+      <div>
+        <label class="block text-xs font-medium text-surface-500 mb-1">New Password</label>
+        <input type="password" bind:value={newPassword} required class="w-full px-3 py-2 text-sm border border-surface-200 dark:border-surface-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-surface-700 dark:text-white" />
+      </div>
+      <div>
+        <label class="block text-xs font-medium text-surface-500 mb-1">Confirm New Password</label>
+        <input type="password" bind:value={confirmPassword} required class="w-full px-3 py-2 text-sm border border-surface-200 dark:border-surface-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-surface-700 dark:text-white" />
+      </div>
+      <button type="submit" disabled={saving} class="w-full px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 rounded-lg transition-colors disabled:opacity-50">
+        {saving ? "Saving..." : "Change Password"}
+      </button>
+    </form>
+  </div>
+ 
+  <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-6 shadow-sm max-w-md">
+    <h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200 mb-4">Passkeys</h3>
+ 
+    {#if passkeyMsg}
+      <div class="text-sm text-emerald-600 bg-emerald-50 px-3 py-2 rounded-lg mb-4">{passkeyMsg}</div>
+    {/if}
+    {#if passkeyErr}
+      <div class="text-sm text-rose-600 bg-rose-50 px-3 py-2 rounded-lg mb-4">{passkeyErr}</div>
+    {/if}
+ 
+    {#if passkeys.length > 0}
+      <div class="space-y-2 mb-4">
+        {#each passkeys as pk}
+          <div class="flex items-center justify-between px-3 py-2 bg-surface-50 dark:bg-surface-700 rounded-lg">
+            <div>
+              <span class="text-sm font-medium text-surface-700 dark:text-surface-200">{pk.name}</span>
+              {#if pk.created_at}
+                <span class="text-xs text-surface-400 ml-2">{new Date(pk.created_at).toLocaleDateString()}</span>
+              {/if}
+            </div>
+            <button onclick={() => deletePasskey(pk.id, pk.name)} disabled={passkeyLoading} class="text-xs text-rose-500 hover:text-rose-700 disabled:opacity-50">Remove</button>
+          </div>
+        {/each}
+      </div>
+    {:else}
+      <p class="text-sm text-surface-400 mb-4">No passkeys registered</p>
+    {/if}
+ 
+    <div class="flex gap-2">
+      <button onclick={registerPasskey} disabled={passkeyLoading} class="px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 rounded-lg transition-colors disabled:opacity-50">
+        Register new passkey
+      </button>
+      {#if passkeys.length > 1}
+        <button onclick={clearPasskeys} disabled={passkeyLoading} class="px-4 py-2 text-sm font-medium text-rose-600 bg-rose-50 hover:bg-rose-100 rounded-lg transition-colors disabled:opacity-50">
+          Remove all
+        </button>
+      {/if}
+    </div>
+  </div>
+ 
+  {#if currentUser.role === "admin" && rbacConfig}
+  <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-6 shadow-sm">
+    <div class="flex items-center justify-between mb-4">
+      <h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200">Access Control</h3>
+      <button onclick={loadRbac} disabled={rbacLoading} class="px-3 py-1.5 text-xs font-medium text-primary-600 bg-primary-50 hover:bg-primary-100 rounded-lg transition-colors disabled:opacity-50">
+        {rbacLoading ? "Loading..." : "Refresh"}
+      </button>
+    </div>
+ 
+    {#if rbacMsg}
+      <div class="text-sm text-emerald-600 bg-emerald-50 px-3 py-2 rounded-lg mb-4">{rbacMsg}</div>
+    {/if}
+    {#if rbacErr}
+      <div class="text-sm text-rose-600 bg-rose-50 px-3 py-2 rounded-lg mb-4">{rbacErr}</div>
+    {/if}
+ 
+    <!-- Role Defaults -->
+    <div class="mb-4">
+      <p class="text-xs font-medium text-surface-500 mb-2">Role Defaults</p>
+      <div class="space-y-2">
+        {#each Object.entries(rbacConfig.role_defaults || {}) as [role, cfg]}
+          {#if editingRole === role}
+            <div class="p-3 bg-surface-50 dark:bg-surface-700 rounded-lg space-y-2">
+              <p class="text-xs font-medium text-surface-700 dark:text-surface-200">{role}</p>
+              <p class="text-[10px] text-surface-500 mt-1">Pages (internal dashboard pages)</p>
+              <div class="flex flex-wrap gap-1.5">
+                <button onclick={() => toggleRolePage("*")} class="px-2 py-0.5 text-xs rounded-md transition-colors {editRolePages.includes('*') ? 'bg-primary-600 text-white' : 'bg-surface-200 dark:bg-surface-600 text-surface-500'}">* (all)</button>
+                {#each allPages as p}
+                  <button onclick={() => toggleRolePage(p)} class="px-2 py-0.5 text-xs rounded-md transition-colors {editRolePages.includes(p) ? 'bg-primary-600 text-white' : 'bg-surface-200 dark:bg-surface-600 text-surface-500'}">{p}</button>
+                {/each}
+              </div>
+              <p class="text-[10px] text-surface-500 mt-2">Sidebar Links (all services)</p>
+              <div class="flex flex-wrap gap-1.5">
+                <button onclick={() => toggleRoleSidebarLink("*")} class="px-2 py-0.5 text-xs rounded-md transition-colors {editRoleSidebarLinks.includes('*') ? 'bg-primary-600 text-white' : 'bg-surface-200 dark:bg-surface-600 text-surface-500'}">* (all)</button>
+                {#each allSidebarLinks as link}
+                  <button onclick={() => toggleRoleSidebarLink(link)} class="px-2 py-0.5 text-xs rounded-md transition-colors {editRoleSidebarLinks.includes(link) ? 'bg-primary-600 text-white' : 'bg-surface-200 dark:bg-surface-600 text-surface-500'}">{link}</button>
+                {/each}
+              </div>
+              <div class="flex gap-2">
+                <button onclick={saveRoleDefault} class="px-3 py-1 text-xs font-medium text-white bg-primary-600 hover:bg-primary-700 rounded-md transition-colors">Save</button>
+                <button onclick={cancelEdit} class="px-3 py-1 text-xs font-medium text-surface-600 hover:text-surface-800 dark:text-surface-400 dark:hover:text-surface-200">Cancel</button>
+              </div>
+            </div>
+          {:else}
+            <div class="flex items-center justify-between p-2 bg-surface-50 dark:bg-surface-700 rounded-lg">
+              <div class="flex flex-col gap-1 text-xs">
+                <div class="flex items-center gap-2">
+                  <span class="font-medium text-surface-700 dark:text-surface-300 w-16">{role}</span>
+                  <span class="text-surface-400">Pages: {cfg.pages === "*" ? "All" : (cfg.pages || []).join(", ")}</span>
+                </div>
+                <div class="flex items-center gap-2">
+                  <span class="w-16"></span>
+                  <span class="text-surface-400">Links: {cfg.sidebar_links === "*" ? "All" : (cfg.sidebar_links || ["*"]).join(", ")}</span>
+                </div>
+              </div>
+              <button onclick={() => startEditRole(role, cfg.pages, cfg.sidebar_links)} class="text-xs text-primary-500 hover:text-primary-700">Edit</button>
+            </div>
+          {/if}
+        {/each}
+      </div>
+    </div>
+ 
+    <!-- User Overrides -->
+    <div class="mb-4">
+      <div class="flex items-center justify-between mb-2">
+        <p class="text-xs font-medium text-surface-500">User Overrides</p>
+        {#if editingUser !== "__new__"}
+          <button onclick={startAdd} class="px-2 py-1 text-xs font-medium text-primary-600 bg-primary-50 hover:bg-primary-100 rounded-md transition-colors">Add Override</button>
+        {/if}
+      </div>
+ 
+      {#if Object.keys(rbacConfig.user_overrides || {}).length > 0}
+        <div class="space-y-2">
+          {#each Object.entries(rbacConfig.user_overrides) as [username, cfg]}
+            {#if editingUser === username}
+              <div class="p-3 bg-surface-50 dark:bg-surface-700 rounded-lg space-y-2">
+                <p class="text-xs font-medium text-surface-700 dark:text-surface-200">{username}</p>
+                <div class="flex flex-wrap gap-1.5">
+                  {#each allPages as p}
+                    <button onclick={() => togglePage(p)} class="px-2 py-0.5 text-xs rounded-md transition-colors {editPages.includes(p) ? 'bg-primary-600 text-white' : 'bg-surface-200 dark:bg-surface-600 text-surface-500'}">{p}</button>
+                  {/each}
+                </div>
+                <div class="flex gap-2">
+                  <button onclick={saveOverride} class="px-3 py-1 text-xs font-medium text-white bg-primary-600 hover:bg-primary-700 rounded-md">Save</button>
+                  <button onclick={cancelEdit} class="px-3 py-1 text-xs font-medium text-surface-500 bg-surface-200 hover:bg-surface-300 rounded-md">Cancel</button>
+                </div>
+              </div>
+            {:else}
+              <div class="flex items-center justify-between px-3 py-2 bg-surface-50 dark:bg-surface-700 rounded-lg">
+                <div>
+                  <span class="text-sm font-medium text-surface-700 dark:text-surface-200">{username}</span>
+                  <span class="text-xs text-surface-400 ml-2">{(cfg.pages || []).join(", ")}</span>
+                </div>
+                <div class="flex gap-1.5">
+                  <button onclick={() => startEdit(username, cfg.pages || [])} class="text-xs text-primary-500 hover:text-primary-700">Edit</button>
+                  <button onclick={() => deleteOverride(username)} class="text-xs text-rose-500 hover:text-rose-700">Delete</button>
+                </div>
+              </div>
+            {/if}
+          {/each}
+        </div>
+      {:else if editingUser !== "__new__"}
+        <p class="text-sm text-surface-400">No user overrides configured</p>
+      {/if}
+ 
+      {#if editingUser === "__new__"}
+        <div class="p-3 bg-surface-50 dark:bg-surface-700 rounded-lg space-y-2 mt-2">
+          <input type="text" bind:value={newUsername} placeholder="Username" class="w-full px-3 py-1.5 text-sm border border-surface-200 dark:border-surface-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-surface-800 dark:text-white" />
+          <div class="flex flex-wrap gap-1.5">
+            {#each allPages as p}
+              <button onclick={() => togglePage(p)} class="px-2 py-0.5 text-xs rounded-md transition-colors {editPages.includes(p) ? 'bg-primary-600 text-white' : 'bg-surface-200 dark:bg-surface-600 text-surface-500'}">{p}</button>
+            {/each}
+          </div>
+          <div class="flex gap-2">
+            <button onclick={saveOverride} class="px-3 py-1 text-xs font-medium text-white bg-primary-600 hover:bg-primary-700 rounded-md">Save</button>
+            <button onclick={cancelEdit} class="px-3 py-1 text-xs font-medium text-surface-500 bg-surface-200 hover:bg-surface-300 rounded-md">Cancel</button>
+          </div>
+        </div>
+      {/if}
+    </div>
+  </div>
+  {/if}
+ 
+  <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-6 shadow-sm">
+    <div class="flex items-center justify-between mb-4">
+      <h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200">Audit Log</h3>
+      <button onclick={loadAuditLog} disabled={auditLoading} class="px-3 py-1.5 text-xs font-medium text-primary-600 bg-primary-50 hover:bg-primary-100 rounded-lg transition-colors disabled:opacity-50">
+        {auditLoading ? "Loading..." : auditEntries.length ? "Refresh" : "Load"}
+      </button>
+    </div>
+    {#if auditEntries.length > 0}
+      <div class="flex gap-1.5 mb-3">
+        {#each ["all", "high", "medium", "low", "info"] as lvl}
+          <button onclick={() => auditFilter = lvl} class="px-2.5 py-1 text-xs rounded-md transition-colors {auditFilter === lvl ? 'bg-surface-800 text-white' : 'bg-surface-100 text-surface-500 hover:bg-surface-200'}">
+            {lvl === "all" ? "All" : levelLabels[lvl] || lvl}
+            {#if lvl !== "all"}
+              <span class="ml-1 opacity-60">{auditEntries.filter(e => e.level === lvl).length}</span>
+            {/if}
+          </button>
+        {/each}
+      </div>
+      <div class="max-h-80 overflow-auto rounded-lg bg-surface-950 p-3 space-y-0.5">
+        {#each filteredEntries as e}
+          <div class="text-xs font-mono flex gap-2 leading-5">
+            <span class="{levelColors[e.level]} w-8 shrink-0">{levelLabels[e.level]}</span>
+            <span class="text-surface-500 shrink-0">{e.ts.replace('T', ' ')}</span>
+            <span class="text-surface-400 shrink-0 w-10">{e.status}</span>
+            <span class="text-surface-300 shrink-0">{e.user}</span>
+            <span class="text-surface-500">{e.method} {e.path}</span>
+          </div>
+        {/each}
+      </div>
+    {:else if !auditLoading}
+      <p class="text-sm text-surface-400">Click Load to view recent audit log entries</p>
+    {/if}
+  </div>
+</div>
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/lcov-report/src/routes/Terminal.svelte.html b/dashboard/frontend/coverage/lcov-report/src/routes/Terminal.svelte.html new file mode 100644 index 0000000..526cd1b --- /dev/null +++ b/dashboard/frontend/coverage/lcov-report/src/routes/Terminal.svelte.html @@ -0,0 +1,2494 @@ + + + + + + Code coverage report for src/routes/Terminal.svelte + + + + + + + + + +
+
+

All files / src/routes Terminal.svelte

+
+ +
+ 0% + Statements + 0/545 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/545 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504 +505 +506 +507 +508 +509 +510 +511 +512 +513 +514 +515 +516 +517 +518 +519 +520 +521 +522 +523 +524 +525 +526 +527 +528 +529 +530 +531 +532 +533 +534 +535 +536 +537 +538 +539 +540 +541 +542 +543 +544 +545 +546 +547 +548 +549 +550 +551 +552 +553 +554 +555 +556 +557 +558 +559 +560 +561 +562 +563 +564 +565 +566 +567 +568 +569 +570 +571 +572 +573 +574 +575 +576 +577 +578 +579 +580 +581 +582 +583 +584 +585 +586 +587 +588 +589 +590 +591 +592 +593 +594 +595 +596 +597 +598 +599 +600 +601 +602 +603 +604 +605 +606 +607 +608 +609 +610 +611 +612 +613 +614 +615 +616 +617 +618 +619 +620 +621 +622 +623 +624 +625 +626 +627 +628 +629 +630 +631 +632 +633 +634 +635 +636 +637 +638 +639 +640 +641 +642 +643 +644 +645 +646 +647 +648 +649 +650 +651 +652 +653 +654 +655 +656 +657 +658 +659 +660 +661 +662 +663 +664 +665 +666 +667 +668 +669 +670 +671 +672 +673 +674 +675 +676 +677 +678 +679 +680 +681 +682 +683 +684 +685 +686 +687 +688 +689 +690 +691 +692 +693 +694 +695 +696 +697 +698 +699 +700 +701 +702 +703 +704 +705 +706 +707 +708 +709 +710 +711 +712 +713 +714 +715 +716 +717 +718 +719 +720 +721 +722 +723 +724 +725 +726 +727 +728 +729 +730 +731 +732 +733 +734 +735 +736 +737 +738 +739 +740 +741 +742 +743 +744 +745 +746 +747 +748 +749 +750 +751 +752 +753 +754 +755 +756 +757 +758 +759 +760 +761 +762 +763 +764 +765 +766 +767 +768 +769 +770 +771 +772 +773 +774 +775 +776 +777 +778 +779 +780 +781 +782 +783 +784 +785 +786 +787 +788 +789 +790 +791 +792 +793 +794 +795 +796 +797 +798 +799 +800 +801 +802 +803 +804  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
<script>
+  import { onMount, onDestroy } from "svelte";
+  import { VoiceSession } from "../lib/voice.js";
+  import { tryRefreshSession } from "../lib/api.js";
+ 
+  const PERSISTENCE_STATUS_PREFIX = "__DASHBOARD_PERSISTENCE__:";
+  const PERSISTENCE_STATUS_PREFIX_BYTES = new TextEncoder().encode(PERSISTENCE_STATUS_PREFIX);
+ 
+  const hosts = [
+    { id: "nas", label: "NAS" },
+    { id: "vps", label: "VPS" },
+    { id: "caddy-vps", label: "Caddy VPS" },
+    { id: "mac", label: "Mac" },
+    { id: "claude-dev", label: "Claude Dev", persistent: true },
+  ];
+ 
+  const DARK_THEME = {
+    background: "#0f172a", foreground: "#e2e8f0", cursor: "#6366f1",
+    cursorAccent: "#0f172a", selectionBackground: "#334155",
+    black: "#0f172a", red: "#f43f5e", green: "#10b981",
+    yellow: "#f59e0b", blue: "#3b82f6", magenta: "#a855f7",
+    cyan: "#06b6d4", white: "#e2e8f0",
+  };
+ 
+  const LIGHT_THEME = {
+    background: "#f8fafc", foreground: "#0f172a", cursor: "#4f46e5",
+    cursorAccent: "#f8fafc", selectionBackground: "#cbd5e1",
+    black: "#0f172a", red: "#e11d48", green: "#059669",
+    yellow: "#d97706", blue: "#2563eb", magenta: "#9333ea",
+    cyan: "#0891b2", white: "#334155",
+  };
+ 
+  let tabs = $state([]);
+  let activeTab = $state(null);
+  let showMenu = $state(false);
+  let voiceTick = $state(0);
+  let isDark = $state(false);
+  let isFullscreen = $state(false);
+  let showActionButtons = $state(false);
+  let tabCounter = 0;
+  const tabData = new Map();
+ 
+  function currentTheme() {
+    return isDark ? DARK_THEME : LIGHT_THEME;
+  }
+ 
+  function hostById(hostId) {
+    return hosts.find((h) => h.id === hostId);
+  }
+ 
+  function openFreshSession(tab) {
+    const url = new URL(window.location.href);
+    url.searchParams.set("page", "terminal");
+    url.searchParams.set("host", tab.hostId);
+    window.open(`${url.pathname}${url.search}${url.hash}`, "_blank", "noopener");
+  }
+ 
+  function persistenceLabel(status, reconnecting) {
+    if (status === "created") return "created new persistent session";
+    if (reconnecting) return "reattached to persistent session";
+    return "attached to persistent session";
+  }
+ 
+  function applyThemeToTerminals() {
+    const theme = currentTheme();
+    tabData.forEach((d) => {
+      d.term?.options && (d.term.options.theme = theme);
+    });
+  }
+ 
+  function syncTheme() {
+    isDark = document.documentElement.classList.contains("dark");
+    applyThemeToTerminals();
+  }
+ 
+  const themeObserver = new MutationObserver((mutations) => {
+    if (mutations.some((m) => m.attributeName === "class")) syncTheme();
+  });
+  onMount(() => {
+    syncTheme();
+    themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
+    const requestedHost = new URLSearchParams(window.location.search).get("host");
+    if (requestedHost && !tabs.length && hostById(requestedHost)) addTab(requestedHost);
+  });
+ 
+  function cleanName(name) {
+    return (name || "image")
+      .replace(/[^a-zA-Z0-9._-]/g, "-")
+      .replace(/-+/g, "-")
+      .replace(/^-|-$/g, "") || "image";
+  }
+ 
+  function extensionFor(type) {
+    if (type === "image/png") return "png";
+    if (type === "image/jpeg") return "jpg";
+    if (type === "image/webp") return "webp";
+    if (type === "image/gif") return "gif";
+    return "bin";
+  }
+ 
+  async function fileToBase64(file) {
+    const buf = await file.arrayBuffer();
+    let binary = "";
+    const bytes = new Uint8Array(buf);
+    const chunkSize = 0x8000;
+    for (let i = 0; i < bytes.length; i += chunkSize) {
+      binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
+    }
+    return btoa(binary);
+  }
+ 
+  async function sendDroppedImage(term, ws, file) {
+    if (!file?.type?.startsWith("image/")) return;
+    if (ws.readyState !== 1) return;
+ 
+    const ext = extensionFor(file.type);
+    const baseName = cleanName(file.name).replace(/\.[a-zA-Z0-9]+$/, "");
+    const stamp = Date.now();
+    const imagePath = `/tmp/claude-images/${baseName || "image"}-${stamp}.${ext}`;
+    const b64 = await fileToBase64(file);
+ 
+    const cmd = [
+      "python3 - <<'PY'",
+      "import base64, pathlib",
+      `p = pathlib.Path(${JSON.stringify(imagePath)})`,
+      "p.parent.mkdir(parents=True, exist_ok=True)",
+      `p.write_bytes(base64.b64decode(${JSON.stringify(b64)}))`,
+      `print(f'saved image -> {p}')`,
+      "PY",
+      "",
+    ].join("\n");
+ 
+    ws.send(new TextEncoder().encode(cmd));
+    term.write(`\r\n\x1b[90m[Uploaded image to ${imagePath}]\x1b[0m\r\n`);
+  }
+ 
+  function addTab(hostId) {
+    const id = ++tabCounter;
+    const host = hostById(hostId);
+    tabs = [...tabs, {
+      id,
+      hostId,
+      label: host.label,
+      connected: false,
+      error: "",
+      statusBanner: host.persistent ? "Connecting to persistent session..." : "",
+      persistent: !!host.persistent,
+    }];
+    activeTab = id;
+    showMenu = false;
+  }
+ 
+  function closeTab(id) {
+    const d = tabData.get(id);
+    if (d) {
+      d.closedManually = true;
+      if (d.reconnectTimer) clearTimeout(d.reconnectTimer);
+      d.voice?.destroy();
+      d.ro?.disconnect();
+      if (d.heartbeat) clearInterval(d.heartbeat);
+      d.term?.element?.removeEventListener("dragover", d.handleDragOver);
+      d.term?.element?.removeEventListener("drop", d.handleDrop);
+      d.term?.textarea?.removeEventListener("paste", d.handlePaste);
+      d.ws?.close();
+      d.term?.dispose();
+      tabData.delete(id);
+    }
+    tabs = tabs.filter((t) => t.id !== id);
+    if (activeTab === id) activeTab = tabs.length ? tabs[tabs.length - 1].id : null;
+  }
+ 
+  function updateTab(tabId, props) {
+    tabs = tabs.map((t) => (t.id === tabId ? { ...t, ...props } : t));
+  }
+ 
+  function initTerminal(el, tab) {
+    const tabId = tab.id;
+    (async () => {
+      const { Terminal } = await import("@xterm/xterm");
+      const { FitAddon } = await import("@xterm/addon-fit");
+      await import("@xterm/xterm/css/xterm.css");
+      const term = new Terminal({
+        cursorBlink: true, fontSize: 13,
+        fontFamily: "'JetBrains Mono', 'Fira Code', 'SF Mono', 'Menlo', monospace",
+        lineHeight: 1.4,
+        theme: currentTheme(),
+      });
+      const fit = new FitAddon();
+      term.loadAddon(fit);
+      term.open(el);
+      fit.fit();
+ 
+      const proto = location.protocol === "https:" ? "wss:" : "ws:";
+      let heartbeat = null;
+      let pongTimeout = null;
+      let reconnectTimer = null;
+      let closedManually = false;
+      let reconnecting = false;
+      let authRecoveryInFlight = false;
+      let reconnectAttempts = 0;
+      let consecutiveAuthFailures = 0;
+      const MAX_RECONNECT_DELAY = 30000;
+      const MAX_AUTH_FAILURES = 3;
+      const PING_INTERVAL = 30000; // 30s between pings
+      const PONG_TIMEOUT = 10000; // 10s to receive pong
+ 
+      function clearHeartbeat() {
+        if (heartbeat) {
+          clearInterval(heartbeat);
+          heartbeat = null;
+        }
+        if (pongTimeout) {
+          clearTimeout(pongTimeout);
+          pongTimeout = null;
+        }
+      }
+ 
+      function handleAuthExpired(reason = "Session expired — please log in again") {
+        clearHeartbeat();
+        if (reconnectTimer) {
+          clearTimeout(reconnectTimer);
+          reconnectTimer = null;
+        }
+        reconnecting = false;
+        updateTab(tabId, {
+          connected: false,
+          error: reason,
+          statusBanner: "",
+        });
+        term.write(`\r\n\x1b[90m[${reason}]\x1b[0m`);
+        const d = tabData.get(tabId);
+        if (d) {
+          d.ws = null;
+          d.heartbeat = heartbeat;
+          d.reconnectTimer = reconnectTimer;
+          d.closedManually = closedManually;
+          d.reconnecting = reconnecting;
+        }
+      }
+ 
+      function scheduleReconnect(reason) {
+        clearHeartbeat();
+        if (closedManually || reconnectTimer) return;
+        reconnecting = true;
+        reconnectAttempts++;
+        const delay = Math.min(1000 * Math.pow(2, reconnectAttempts - 1), MAX_RECONNECT_DELAY);
+        updateTab(tabId, {
+          connected: false,
+          error: `${reason} — reconnecting in ${Math.round(delay / 1000)}s (attempt ${reconnectAttempts})...`,
+          statusBanner: tab.persistent ? "Reconnecting to persistent session..." : "",
+        });
+        term.write(`\r\n\x1b[90m[${reason} — reconnecting in ${Math.round(delay / 1000)}s...]\x1b[0m`);
+        reconnectTimer = setTimeout(() => {
+          reconnectTimer = null;
+          if (!tabData.has(tabId) || closedManually) return;
+          connect(true);
+        }, delay);
+      }
+ 
+      function sendSize(ws) {
+        if (ws.readyState === 1) {
+          const buf = new Uint8Array(5);
+          buf[0] = 0x01;
+          new DataView(buf.buffer).setUint16(1, term.cols);
+          new DataView(buf.buffer).setUint16(3, term.rows);
+          ws.send(buf);
+        }
+      }
+ 
+      async function connect(forceRefresh = false) {
+        let d = tabData.get(tabId);
+ 
+        if (forceRefresh) {
+          const refreshed = await tryRefreshSession();
+          if (!refreshed) {
+            handleAuthExpired();
+            return null;
+          }
+        }
+ 
+        const ws = new WebSocket(
+          `${proto}//${location.host}/ws/terminal?host=${encodeURIComponent(tab.hostId)}`
+        );
+        ws.binaryType = "arraybuffer";
+ 
+        ws.onopen = () => {
+          reconnectAttempts = 0;
+          consecutiveAuthFailures = 0; // Reset auth failure counter on successful connection
+          updateTab(tabId, {
+            connected: true,
+            error: "",
+            statusBanner: tab.persistent ? "Connected. Waiting for persistent session status..." : "",
+          });
+          sendSize(ws);
+          clearHeartbeat();
+          heartbeat = setInterval(() => {
+            if (ws.readyState === 1) {
+              ws.send("__ping__");
+              // Set timeout to detect missing pong
+              pongTimeout = setTimeout(() => {
+                // Check if still the same WebSocket instance and still open
+                const currentTab = tabData.get(tabId);
+                if (currentTab?.ws === ws && ws.readyState === 1) {
+                  ws.close(1000, "keepalive ping timeout");
+                }
+              }, PONG_TIMEOUT);
+            }
+          }, PING_INTERVAL);
+          const d = tabData.get(tabId);
+          if (d) {
+            d.ws = ws;
+            d.heartbeat = heartbeat;
+            d.reconnectTimer = reconnectTimer;
+            d.closedManually = closedManually;
+            d.reconnecting = reconnecting;
+            d.voice?.attach(term, ws);
+          }
+        };
+        ws.onmessage = (e) => {
+          // Check for text pong response
+          if (typeof e.data === 'string' && e.data === '__pong__') {
+            // Clear the pong timeout - connection is alive
+            if (pongTimeout) {
+              clearTimeout(pongTimeout);
+              pongTimeout = null;
+            }
+            return;
+          }
+ 
+          const data = new Uint8Array(e.data);
+          const isPersistenceMessage = tab.persistent
+            && data.length >= PERSISTENCE_STATUS_PREFIX_BYTES.length
+            && PERSISTENCE_STATUS_PREFIX_BYTES.every((byte, index) => data[index] === byte);
+          if (isPersistenceMessage) {
+            const status = new TextDecoder().decode(data.slice(PERSISTENCE_STATUS_PREFIX_BYTES.length)).trim();
+            updateTab(tabId, { statusBanner: persistenceLabel(status, reconnecting) });
+            reconnecting = false;
+            const d = tabData.get(tabId);
+            if (d) d.reconnecting = reconnecting;
+            return;
+          }
+          term.write(data);
+        };
+        ws.onclose = async (e) => {
+          const reason = e.reason || `Connection closed (code ${e.code})`;
+          const tabState = tabData.get(tabId);
+          if (tabState) tabState.ws = null;
+ 
+          // Handle auth failures (code 1008)
+          if (e.code === 1008) {
+            consecutiveAuthFailures++;
+ 
+            // Stop after MAX_AUTH_FAILURES consecutive auth failures
+            if (consecutiveAuthFailures >= MAX_AUTH_FAILURES) {
+              handleAuthExpired(`Session expired after ${MAX_AUTH_FAILURES} attempts — please refresh page`);
+              return;
+            }
+ 
+            // Try to refresh session
+            if (!closedManually && !authRecoveryInFlight) {
+              authRecoveryInFlight = true;
+              try {
+                const refreshed = await tryRefreshSession();
+                if (refreshed && !closedManually) {
+                  reconnecting = true;
+                  updateTab(tabId, {
+                    connected: false,
+                    error: `Refreshing session (attempt ${consecutiveAuthFailures}/${MAX_AUTH_FAILURES})...`,
+                    statusBanner: tab.persistent ? "Reconnecting to persistent session..." : "",
+                  });
+                  term.write(`\r\n\x1b[90m[Refreshing session...]\x1b[0m`);
+                  void connect(false);
+                  return;
+                }
+              } catch (err) {
+                console.error("Session refresh failed:", err);
+              } finally {
+                authRecoveryInFlight = false;
+              }
+            }
+            handleAuthExpired("Session expired — please log in again");
+            return;
+          }
+ 
+          // Non-auth failures: continue with normal reconnection
+          if (!closedManually) scheduleReconnect(reason);
+          else {
+            clearHeartbeat();
+            updateTab(tabId, { connected: false, error: reason });
+            term.write(`\r\n\x1b[90m[${reason}]\x1b[0m`);
+          }
+        };
+        ws.onerror = () => {
+          const tabState = tabData.get(tabId);
+          if (tabState) tabState.ws = null;
+          if (!closedManually) scheduleReconnect("Connection failed");
+          else updateTab(tabId, { connected: false, error: "Connection failed" });
+        };
+ 
+        d = tabData.get(tabId);
+        if (d) {
+          d.ws = ws;
+          d.heartbeat = heartbeat;
+          d.reconnectTimer = reconnectTimer;
+          d.closedManually = closedManually;
+          d.reconnecting = reconnecting;
+        }
+        return ws;
+      }
+ 
+      let ws = null;
+      void connect().then((connectedWs) => {
+        ws = connectedWs;
+        if (connectedWs) voice.attach(term, connectedWs);
+      });
+      term.onData((data) => {
+        const currentWs = tabData.get(tabId)?.ws;
+        if (currentWs?.readyState === 1) currentWs.send(new TextEncoder().encode(data));
+      });
+ 
+      const handleDragOver = (e) => {
+        e.preventDefault();
+      };
+ 
+      const handleDrop = async (e) => {
+        e.preventDefault();
+        if (activeTab !== tabId) return;
+        const currentWs = tabData.get(tabId)?.ws;
+        const files = [...(e.dataTransfer?.files || [])].filter((f) => f.type?.startsWith("image/"));
+        for (const file of files) {
+          await sendDroppedImage(term, currentWs, file);
+        }
+      };
+ 
+      const handlePaste = async (e) => {
+        if (activeTab !== tabId) return;
+        const currentWs = tabData.get(tabId)?.ws;
+        const items = [...(e.clipboardData?.items || [])]
+          .filter((it) => it.kind === "file" && it.type?.startsWith("image/"));
+        for (const item of items) {
+          const file = item.getAsFile();
+          if (file) await sendDroppedImage(term, currentWs, file);
+        }
+      };
+ 
+      term.element?.addEventListener("dragover", handleDragOver);
+      term.element?.addEventListener("drop", handleDrop);
+      term.textarea?.addEventListener("paste", handlePaste);
+ 
+      const ro = new ResizeObserver(() => {
+        fit.fit();
+        const currentWs = tabData.get(tabId)?.ws;
+        if (currentWs) sendSize(currentWs);
+      });
+      ro.observe(el);
+      const voice = new VoiceSession(() => { voiceTick++; });
+      voice.attach(term, ws);
+      tabData.set(tabId, { ws, term, ro, el, voice, heartbeat, reconnectTimer, closedManually, reconnecting, handleDragOver, handleDrop, handlePaste });
+    })();
+  }
+ 
+  function activeVoice() { return voiceTick >= 0 && tabData.get(activeTab)?.voice; }
+ 
+  function toggleFullscreen() {
+    const elem = document.documentElement;
+    if (!document.fullscreenElement) {
+      elem.requestFullscreen().then(() => {
+        isFullscreen = true;
+        showActionButtons = true;
+      }).catch(err => {
+        console.error('Failed to enter fullscreen:', err);
+      });
+    } else {
+      document.exitFullscreen().then(() => {
+        isFullscreen = false;
+        showActionButtons = false;
+      }).catch(err => {
+        console.error('Failed to exit fullscreen:', err);
+      });
+    }
+  }
+ 
+  function sendKey(key) {
+    const currentWs = tabData.get(activeTab)?.ws;
+    if (currentWs?.readyState === 1) {
+      currentWs.send(new TextEncoder().encode(key));
+    }
+  }
+ 
+  onMount(() => {
+    syncTheme();
+    themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
+    const requestedHost = new URLSearchParams(window.location.search).get("host");
+    if (requestedHost && !tabs.length && hostById(requestedHost)) addTab(requestedHost);
+ 
+    // Listen for fullscreen changes
+    document.addEventListener('fullscreenchange', () => {
+      isFullscreen = !!document.fullscreenElement;
+      showActionButtons = !!document.fullscreenElement;
+    });
+  });
+ 
+  onDestroy(() => {
+    themeObserver.disconnect();
+    tabData.forEach((d) => {
+      d.closedManually = true;
+      if (d.reconnectTimer) clearTimeout(d.reconnectTimer);
+      d.voice?.destroy();
+      d.ro?.disconnect();
+      if (d.heartbeat) clearInterval(d.heartbeat);
+      d.term?.element?.removeEventListener("dragover", d.handleDragOver);
+      d.term?.element?.removeEventListener("drop", d.handleDrop);
+      d.term?.textarea?.removeEventListener("paste", d.handlePaste);
+      d.ws?.close();
+      d.term?.dispose();
+    });
+  });
+</script>
+<div class={activeVoice()?.voiceMode ? 'flex flex-col h-[calc(100vh-4rem)] overflow-hidden gap-1' : 'space-y-2'}>
+  {#if !activeVoice()?.voiceMode}
+    <h1 class="text-2xl font-bold text-surface-900 dark:text-surface-100 tracking-tight">Terminal</h1>
+  {/if}
+ 
+  <!-- iPhone Landscape Action Buttons (only in fullscreen) -->
+  {#if showActionButtons && activeTab}
+    <!-- Top Left Pocket -->
+    <div class="actions-top-left">
+      <button class="action-btn" onclick={() => sendKey('\x1b')} title="ESC">ESC</button>
+      <button class="action-btn" onclick={() => sendKey('\t')} title="TAB">TAB</button>
+    </div>
+ 
+    <!-- Bottom Left Pocket -->
+    <div class="actions-bottom-left">
+      <button class="action-btn" onclick={() => sendKey('\x01')} title="CTRL+A">^A</button>
+      <button class="action-btn" onclick={() => sendKey('\x03')} title="CTRL+C">^C</button>
+    </div>
+ 
+    <!-- Top Right Pocket -->
+    <div class="actions-top-right">
+      <button class="action-btn" onclick={() => sendKey('\x0c')} title="CTRL+L">^L</button>
+      <button class="action-btn" onclick={() => sendKey('\x1a')} title="CTRL+Z">^Z</button>
+    </div>
+ 
+    <!-- Bottom Right Pocket -->
+    <div class="actions-bottom-right">
+      <button class="action-btn" onclick={() => sendKey('\x04')} title="CTRL+D">^D</button>
+      <button class="action-btn" onclick={() => sendKey('\x12')} title="CTRL+R">^R</button>
+    </div>
+  {/if}
+ 
+  <div class="flex items-center gap-1 border-b border-surface-300 dark:border-surface-700 pb-1 relative">
+    {#each tabs as tab (tab.id)}
+      <button
+        class="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-t-lg transition-colors {activeTab === tab.id ? 'bg-surface-200 dark:bg-surface-800 text-surface-900 dark:text-surface-100' : 'text-surface-600 dark:text-surface-400 hover:text-surface-800 dark:hover:text-surface-200 hover:bg-surface-100 dark:hover:bg-surface-800/50'}"
+        onclick={() => activeTab = tab.id}
+      >
+        {#if tab.connected}
+          <span class="w-1.5 h-1.5 rounded-full bg-emerald-500"></span>
+        {:else if tab.error}
+          <span class="w-1.5 h-1.5 rounded-full bg-rose-500"></span>
+        {:else}
+          <span class="w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse"></span>
+        {/if}
+        {tab.label}
+        {#if tab.persistent}
+          <span class="rounded bg-indigo-100 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300">tmux</span>
+        {/if}
+        <span
+          class="ml-1 text-surface-500 hover:text-sky-500 cursor-pointer"
+          role="button"
+          tabindex="0"
+          title="Open fresh session in new browser tab"
+          aria-label={`Open fresh ${tab.label} session in new browser tab`}
+          onclick={(e) => { e.stopPropagation(); openFreshSession(tab); }}
+          onkeydown={(e) => e.key === 'Enter' && openFreshSession(tab)}
+        >↗</span>
+        <span
+          class="ml-1 text-surface-500 hover:text-rose-400 cursor-pointer"
+          role="button"
+          tabindex="0"
+          onclick={(e) => { e.stopPropagation(); closeTab(tab.id); }}
+          onkeydown={(e) => e.key === 'Enter' && closeTab(tab.id)}
+        >&times;</span>
+      </button>
+    {/each}
+    <div class="relative">
+      <button
+        class="px-2 py-1 text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-100 hover:bg-surface-200 dark:hover:bg-surface-800 rounded text-lg leading-none"
+        onclick={() => showMenu = !showMenu}
+      >+</button>
+      {#if showMenu}
+        <div class="absolute top-full left-0 mt-1 bg-surface-100 dark:bg-surface-800 border border-surface-300 dark:border-surface-600 rounded-lg shadow-xl z-10 py-1 min-w-[140px]">
+          {#each hosts as h}
+            <button
+              class="flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-sm text-surface-800 dark:text-surface-200 hover:bg-surface-200 dark:hover:bg-surface-700"
+              onclick={() => addTab(h.id)}
+            >
+              <span>{h.label}</span>
+              {#if h.persistent}
+                <span class="rounded bg-indigo-100 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300">persistent</span>
+              {/if}
+            </button>
+          {/each}
+        </div>
+      {/if}
+    </div>
+    {#if activeTab && tabs.find(t => t.id === activeTab)?.connected}
+      <div class="ml-auto flex items-center gap-1">
+        <button
+          class="px-2 py-1 rounded text-sm transition-colors text-surface-600 dark:text-surface-500 hover:text-surface-800 dark:hover:text-surface-300"
+          title={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
+          onclick={toggleFullscreen}
+        >
+          {#if isFullscreen}
+            <svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 3v3a2 2 0 01-2 2H3m18 0h-3a2 2 0 01-2-2V3m0 18v-3a2 2 0 012-2h3M3 16h3a2 2 0 012 2v3"/></svg>
+          {:else}
+            <svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3"/></svg>
+          {/if}
+        </button>
+        {#if activeVoice()}
+          {@const v = activeVoice()}
+          <button
+            class="px-2 py-1 rounded text-sm transition-colors {v.voiceMode ? 'text-rose-400' : v.sttAvailable ? 'text-surface-600 dark:text-surface-500 hover:text-surface-800 dark:hover:text-surface-300' : 'text-surface-300 dark:text-surface-700 cursor-not-allowed'}"
+            title={!v.sttAvailable ? 'STT not supported' : v.voiceMode ? 'Voice mode on' : 'Enter voice mode'}
+            disabled={!v.sttAvailable}
+            onclick={() => { if (!v.voiceMode) v.enterVoiceMode(); else v.exitVoiceMode(); voiceTick++; }}
+          >
+            <svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"/><path d="M19 10v2a7 7 0 01-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
+          </button>
+        {/if}
+      </div>
+    {/if}
+  </div>
+ 
+  <p class="text-xs text-surface-600 dark:text-surface-500">
+    Tip: drag & drop or paste images into the active terminal tab to upload them to <code class="text-surface-700 dark:text-surface-400">/tmp/claude-images</code>.
+  </p>
+ 
+  {#if activeTab && tabs.find(t => t.id === activeTab)?.statusBanner}
+    <div class="rounded-lg border border-indigo-200 bg-indigo-50 px-3 py-2 text-sm text-indigo-800 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-200">
+      {tabs.find(t => t.id === activeTab)?.statusBanner}
+    </div>
+  {/if}
+ 
+  {#if !tabs.length}
+    <div class="flex items-center justify-center h-[calc(100vh-12rem)] text-surface-600 dark:text-surface-500 text-sm">
+      Click + to open a terminal
+    </div>
+  {/if}
+ 
+  {#each tabs as tab (tab.id)}
+    <div class="{activeTab === tab.id ? '' : 'hidden'} {activeVoice()?.voiceMode ? 'flex-1 min-h-0' : ''}">
+      <div
+        use:initTerminal={tab}
+        class="h-full rounded-xl overflow-hidden shadow-lg border border-surface-300 dark:border-surface-700 bg-surface-50 dark:bg-[#0f172a] transition-all duration-300"
+        style="{activeVoice()?.voiceMode ? '' : 'height: calc(100vh - 10rem);'}"
+      ></div>
+    </div>
+  {/each}
+ 
+  {#if activeVoice()?.voiceMode}
+    {@const v = activeVoice()}
+    <div class="mt-1 shrink-0 rounded-xl bg-surface-100/95 dark:bg-surface-800/95 border border-surface-300 dark:border-surface-700 shadow-xl overflow-hidden animate-slide-up">
+      <!-- Header -->
+      <div class="flex items-center justify-between px-3 py-1.5 border-b border-surface-300/50 dark:border-surface-700/50">
+        <span class="text-xs font-medium text-surface-700 dark:text-surface-300">Voice Mode</span>
+        <div class="flex items-center gap-2">
+          <select
+            class="bg-surface-200 dark:bg-surface-700 text-surface-800 dark:text-surface-300 text-xs rounded px-1 py-0.5 outline-none max-w-[120px]"
+            onchange={(e) => { v.setVoice(+e.target.value); voiceTick++; }}
+          >
+            {#each v.voices as voice, i}
+              <option value={i} selected={i === v.voiceIdx}>{voice.name}</option>
+            {/each}
+          </select>
+          <button
+            class="px-1.5 py-0.5 rounded text-xs font-medium text-surface-600 dark:text-surface-400 hover:text-surface-800 dark:hover:text-surface-200 hover:bg-surface-200 dark:hover:bg-surface-700"
+            onclick={() => { v.cycleLang(); voiceTick++; }}
+          >{v.langLabel}</button>
+          <button
+            class="px-1.5 py-0.5 rounded text-xs font-medium text-surface-600 dark:text-surface-400 hover:text-surface-800 dark:hover:text-surface-200 hover:bg-surface-200 dark:hover:bg-surface-700"
+            onclick={() => { v.cycleTalkMode(); voiceTick++; }}
+          >{v.modeLabel}</button>
+          <button
+            class="px-1.5 py-0.5 rounded text-xs text-surface-500 hover:text-rose-400"
+            onclick={() => { v.exitVoiceMode(); voiceTick++; }}
+          >&times;</button>
+        </div>
+      </div>
+      <!-- Transcript + Mic -->
+      <div class="flex items-center gap-3 px-3 py-2">
+        {#if v.talkMode === "hold"}
+          <button
+            aria-label="Hold to talk"
+            class="w-10 h-10 shrink-0 rounded-full flex items-center justify-center transition-colors {v.listening ? 'bg-rose-500/30 text-rose-400' : 'bg-surface-200 dark:bg-surface-700 text-surface-700 dark:text-surface-300 hover:bg-surface-300 dark:hover:bg-surface-600'}"
+            onpointerdown={() => { v.startHold(); voiceTick++; }}
+            onpointerup={() => { v.releaseHold(); voiceTick++; }}
+            onpointerleave={() => { if (v.listening) { v.releaseHold(); voiceTick++; } }}
+          >
+            <svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"/><path d="M19 10v2a7 7 0 01-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
+          </button>
+        {:else if v.talkMode === "tap"}
+          <button
+            aria-label="Tap to talk"
+            class="w-10 h-10 shrink-0 rounded-full flex items-center justify-center transition-colors {v.listening ? 'bg-rose-500/30 text-rose-400' : 'bg-surface-200 dark:bg-surface-700 text-surface-700 dark:text-surface-300 hover:bg-surface-300 dark:hover:bg-surface-600'}"
+            onclick={() => { v.tapMic(); voiceTick++; }}
+          >
+            <svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"/><path d="M19 10v2a7 7 0 01-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
+          </button>
+        {:else}
+          <div class="w-10 h-10 shrink-0 rounded-full flex items-center justify-center {v.listening ? 'bg-emerald-500/20 text-emerald-400 animate-pulse' : 'bg-surface-200 dark:bg-surface-700 text-surface-500'}">
+            <svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"/><path d="M19 10v2a7 7 0 01-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
+          </div>
+        {/if}
+        <div class="flex-1 min-w-0">
+          {#if v.transcript}
+            <p class="text-sm text-surface-600 dark:text-surface-400 italic truncate">"{v.transcript}"</p>
+          {:else}
+            <p class="text-xs text-surface-500 dark:text-surface-600">{v.speaking ? 'Speaking...' : v.listening ? 'Listening...' : 'Ready'}</p>
+          {/if}
+        </div>
+      </div>
+    </div>
+  {/if}
+</div>
+ 
+<style>
+  @keyframes slide-up {
+    from { transform: translateY(100%); opacity: 0; }
+    to { transform: translateY(0); opacity: 1; }
+  }
+  .animate-slide-up { animation: slide-up 0.25s ease-out; }
+ 
+  /* iPhone 17 Pro Landscape Action Button Zones */
+  .actions-top-left,
+  .actions-bottom-left,
+  .actions-top-right,
+  .actions-bottom-right {
+    position: fixed;
+    display: flex;
+    flex-direction: column;
+    gap: 5px;
+    z-index: 9999;
+  }
+ 
+  .actions-top-left {
+    top: 5px;
+    left: 5px;
+    width: 52px;
+    height: 125px;
+  }
+ 
+  .actions-bottom-left {
+    bottom: 26px;
+    left: 5px;
+    width: 52px;
+    height: 110px;
+  }
+ 
+  .actions-top-right {
+    top: 5px;
+    right: 5px;
+    width: 52px;
+    height: 125px;
+  }
+ 
+  .actions-bottom-right {
+    bottom: 26px;
+    right: 5px;
+    width: 52px;
+    height: 110px;
+  }
+ 
+  .action-btn {
+    flex: 1;
+    background: rgba(99, 102, 241, 0.9);
+    color: white;
+    border: none;
+    border-radius: 8px;
+    font-size: 11px;
+    font-weight: 600;
+    cursor: pointer;
+    transition: all 0.2s;
+    backdrop-filter: blur(10px);
+    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+ 
+  .action-btn:active {
+    background: rgba(79, 70, 229, 1);
+    transform: scale(0.95);
+  }
+ 
+  /* Terminal container respects safe areas */
+  .terminal-safe-container {
+    padding-left: env(safe-area-inset-left);
+    padding-right: env(safe-area-inset-right);
+    padding-bottom: env(safe-area-inset-bottom);
+    box-sizing: border-box;
+  }
+</style>
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/lcov-report/src/routes/Transmission.svelte.html b/dashboard/frontend/coverage/lcov-report/src/routes/Transmission.svelte.html new file mode 100644 index 0000000..10efbbf --- /dev/null +++ b/dashboard/frontend/coverage/lcov-report/src/routes/Transmission.svelte.html @@ -0,0 +1,931 @@ + + + + + + Code coverage report for src/routes/Transmission.svelte + + + + + + + + + +
+
+

All files / src/routes Transmission.svelte

+
+ +
+ 0% + Statements + 0/145 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/145 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
<script>
+  import { onMount, onDestroy } from "svelte";
+  import { get, post } from "../lib/api.js";
+ 
+  let status = $state(null);
+  let torrents = $state([]);
+  let trackers = $state([]);
+  let stats = $state(null);
+  let loading = $state(true);
+  let selectedTab = $state("torrents");
+ 
+  async function load() {
+    try {
+      const [statusData, torrentsData, trackersData, statsData] = await Promise.all([
+        get("/transmission/status").catch(() => null),
+        get("/transmission/torrents").catch(() => ({ torrents: [] })),
+        get("/transmission/trackers").catch(() => ({ trackers: [] })),
+        get("/transmission/stats").catch(() => null),
+      ]);
+ 
+      status = statusData;
+      torrents = torrentsData?.torrents || [];
+      trackers = trackersData?.trackers || [];
+      stats = statsData;
+      loading = false;
+    } catch (e) {
+      console.error("Failed to load transmission data:", e);
+      loading = false;
+    }
+  }
+ 
+  async function reannounce(torrentId) {
+    try {
+      await post(`/transmission/reannounce/${torrentId}`);
+      await load();
+    } catch (e) {
+      alert("Failed to reannounce: " + e.message);
+    }
+  }
+ 
+  async function startTorrent(torrentId) {
+    try {
+      await post(`/transmission/start/${torrentId}`);
+      await load();
+    } catch (e) {
+      alert("Failed to start: " + e.message);
+    }
+  }
+ 
+  async function stopTorrent(torrentId) {
+    try {
+      await post(`/transmission/stop/${torrentId}`);
+      await load();
+    } catch (e) {
+      alert("Failed to stop: " + e.message);
+    }
+  }
+ 
+  function fmtBytes(b) {
+    if (!b) return "0 B";
+    const u = ["B", "KB", "MB", "GB", "TB"];
+    const i = Math.floor(Math.log(b) / Math.log(1024));
+    return (b / Math.pow(1024, i)).toFixed(1) + " " + u[i];
+  }
+ 
+  function fmtSpeed(bytesPerSec) {
+    return fmtBytes(bytesPerSec) + "/s";
+  }
+ 
+  function statusText(status) {
+    const statusMap = {
+      0: "Stopped",
+      1: "Check waiting",
+      2: "Checking",
+      3: "Download waiting",
+      4: "Downloading",
+      5: "Seed waiting",
+      6: "Seeding"
+    };
+    return statusMap[status] || "Unknown";
+  }
+ 
+  let interval;
+  onMount(() => { load(); interval = setInterval(load, 10000); });
+  onDestroy(() => clearInterval(interval));
+</script>
+ 
+<div class="space-y-6">
+  <div>
+    <h1 class="text-2xl font-bold text-surface-900 dark:text-white tracking-tight">Transmission</h1>
+    <p class="text-sm text-surface-400 mt-1">BitTorrent client status and management</p>
+  </div>
+ 
+  {#if loading}
+    <div class="h-[400px] rounded-xl bg-surface-100 dark:bg-surface-800 animate-pulse"></div>
+  {:else}
+    <!-- Status Cards -->
+    <div class="grid grid-cols-1 md:grid-cols-4 gap-4">
+      <!-- Daemon Status -->
+      <div class="rounded-xl bg-white dark:bg-surface-800 p-6 border border-surface-200 dark:border-surface-700">
+        <div class="flex items-center justify-between">
+          <div>
+            <p class="text-sm text-surface-500 dark:text-surface-400">Daemon</p>
+            <p class="text-2xl font-bold mt-1 {status?.running ? 'text-emerald-500' : 'text-rose-500'}">
+              {status?.running ? 'Running' : 'Stopped'}
+            </p>
+          </div>
+          <div class="w-12 h-12 rounded-full {status?.running ? 'bg-emerald-100 dark:bg-emerald-900/30' : 'bg-rose-100 dark:bg-rose-900/30'} flex items-center justify-center">
+            <svg class="w-6 h-6 {status?.running ? 'text-emerald-500' : 'text-rose-500'}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
+            </svg>
+          </div>
+        </div>
+      </div>
+ 
+      <!-- Active Torrents -->
+      <div class="rounded-xl bg-white dark:bg-surface-800 p-6 border border-surface-200 dark:border-surface-700">
+        <div class="flex items-center justify-between">
+          <div>
+            <p class="text-sm text-surface-500 dark:text-surface-400">Active Torrents</p>
+            <p class="text-2xl font-bold mt-1 text-surface-900 dark:text-white">
+              {torrents.filter(t => t.status === 4 || t.status === 6).length}
+            </p>
+          </div>
+          <div class="w-12 h-12 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
+            <svg class="w-6 h-6 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10"></path>
+            </svg>
+          </div>
+        </div>
+      </div>
+ 
+      <!-- Download Speed -->
+      <div class="rounded-xl bg-white dark:bg-surface-800 p-6 border border-surface-200 dark:border-surface-700">
+        <div class="flex items-center justify-between">
+          <div>
+            <p class="text-sm text-surface-500 dark:text-surface-400">Download</p>
+            <p class="text-2xl font-bold mt-1 text-surface-900 dark:text-white">
+              {fmtSpeed(torrents.reduce((sum, t) => sum + (t.rateDownload || 0), 0))}
+            </p>
+          </div>
+          <div class="w-12 h-12 rounded-full bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
+            <svg class="w-6 h-6 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3"></path>
+            </svg>
+          </div>
+        </div>
+      </div>
+ 
+      <!-- Upload Speed -->
+      <div class="rounded-xl bg-white dark:bg-surface-800 p-6 border border-surface-200 dark:border-surface-700">
+        <div class="flex items-center justify-between">
+          <div>
+            <p class="text-sm text-surface-500 dark:text-surface-400">Upload</p>
+            <p class="text-2xl font-bold mt-1 text-surface-900 dark:text-white">
+              {fmtSpeed(torrents.reduce((sum, t) => sum + (t.rateUpload || 0), 0))}
+            </p>
+          </div>
+          <div class="w-12 h-12 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
+            <svg class="w-6 h-6 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18"></path>
+            </svg>
+          </div>
+        </div>
+      </div>
+    </div>
+ 
+    <!-- Tabs -->
+    <div class="border-b border-surface-200 dark:border-surface-700">
+      <nav class="-mb-px flex space-x-8">
+        <button
+          onclick={() => selectedTab = "torrents"}
+          class="py-4 px-1 border-b-2 font-medium text-sm {selectedTab === 'torrents' ? 'border-blue-500 text-blue-600 dark:text-blue-400' : 'border-transparent text-surface-500 hover:text-surface-700 hover:border-surface-300 dark:text-surface-400 dark:hover:text-surface-300'}"
+        >
+          Torrents ({torrents.length})
+        </button>
+        <button
+          onclick={() => selectedTab = "trackers"}
+          class="py-4 px-1 border-b-2 font-medium text-sm {selectedTab === 'trackers' ? 'border-blue-500 text-blue-600 dark:text-blue-400' : 'border-transparent text-surface-500 hover:text-surface-700 hover:border-surface-300 dark:text-surface-400 dark:hover:text-surface-300'}"
+        >
+          Trackers ({trackers.length})
+        </button>
+      </nav>
+    </div>
+ 
+    <!-- Content -->
+    {#if selectedTab === "torrents"}
+      <div class="rounded-xl bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 overflow-hidden">
+        <div class="overflow-x-auto">
+          <table class="w-full">
+            <thead class="bg-surface-50 dark:bg-surface-900/50">
+              <tr>
+                <th class="px-6 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase tracking-wider">Name</th>
+                <th class="px-6 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase tracking-wider">Status</th>
+                <th class="px-6 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase tracking-wider">Progress</th>
+                <th class="px-6 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase tracking-wider">Down</th>
+                <th class="px-6 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase tracking-wider">Up</th>
+                <th class="px-6 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase tracking-wider">Ratio</th>
+                <th class="px-6 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase tracking-wider">Tracker</th>
+                <th class="px-6 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase tracking-wider">Actions</th>
+              </tr>
+            </thead>
+            <tbody class="divide-y divide-surface-200 dark:divide-surface-700">
+              {#each torrents as torrent}
+                <tr class="hover:bg-surface-50 dark:hover:bg-surface-900/30">
+                  <td class="px-6 py-4 text-sm text-surface-900 dark:text-white max-w-md truncate">{torrent.name}</td>
+                  <td class="px-6 py-4 text-sm">
+                    <span class="px-2 py-1 rounded-full text-xs font-medium {torrent.status === 4 || torrent.status === 6 ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' : 'bg-surface-100 text-surface-700 dark:bg-surface-700 dark:text-surface-300'}">
+                      {statusText(torrent.status)}
+                    </span>
+                  </td>
+                  <td class="px-6 py-4 text-sm text-surface-500 dark:text-surface-400">{(torrent.percentDone * 100).toFixed(1)}%</td>
+                  <td class="px-6 py-4 text-sm text-surface-500 dark:text-surface-400">{fmtSpeed(torrent.rateDownload)}</td>
+                  <td class="px-6 py-4 text-sm text-surface-500 dark:text-surface-400">{fmtSpeed(torrent.rateUpload)}</td>
+                  <td class="px-6 py-4 text-sm text-surface-500 dark:text-surface-400">{torrent.uploadRatio?.toFixed(2) || '0.00'}</td>
+                  <td class="px-6 py-4 text-sm">
+                    {#if torrent.hasTrackerErrors}
+                      <span class="text-rose-500 flex items-center gap-1">
+                        <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
+                          <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"></path>
+                        </svg>
+                        Error
+                      </span>
+                    {:else}
+                      <span class="text-emerald-500">OK</span>
+                    {/if}
+                  </td>
+                  <td class="px-6 py-4 text-sm">
+                    <button
+                      onclick={() => reannounce(torrent.id)}
+                      class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
+                      title="Reannounce to tracker"
+                    >
+                      Reannounce
+                    </button>
+                  </td>
+                </tr>
+              {/each}
+            </tbody>
+          </table>
+        </div>
+      </div>
+    {:else if selectedTab === "trackers"}
+      <div class="rounded-xl bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 overflow-hidden">
+        <div class="overflow-x-auto">
+          <table class="w-full">
+            <thead class="bg-surface-50 dark:bg-surface-900/50">
+              <tr>
+                <th class="px-6 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase tracking-wider">Tracker</th>
+                <th class="px-6 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase tracking-wider">Total Torrents</th>
+                <th class="px-6 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase tracking-wider">Errors</th>
+                <th class="px-6 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase tracking-wider">Status</th>
+                <th class="px-6 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase tracking-wider">Last Error</th>
+              </tr>
+            </thead>
+            <tbody class="divide-y divide-surface-200 dark:divide-surface-700">
+              {#each trackers as tracker}
+                <tr class="hover:bg-surface-50 dark:hover:bg-surface-900/30">
+                  <td class="px-6 py-4 text-sm font-medium text-surface-900 dark:text-white">{tracker.host}</td>
+                  <td class="px-6 py-4 text-sm text-surface-500 dark:text-surface-400">{tracker.total}</td>
+                  <td class="px-6 py-4 text-sm text-surface-500 dark:text-surface-400">{tracker.errors}</td>
+                  <td class="px-6 py-4 text-sm">
+                    {#if tracker.errors > 0}
+                      <span class="px-2 py-1 rounded-full text-xs font-medium bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400">
+                        Error
+                      </span>
+                    {:else}
+                      <span class="px-2 py-1 rounded-full text-xs font-medium bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400">
+                        OK
+                      </span>
+                    {/if}
+                  </td>
+                  <td class="px-6 py-4 text-sm text-surface-500 dark:text-surface-400">{tracker.lastError || '-'}</td>
+                </tr>
+              {/each}
+            </tbody>
+          </table>
+        </div>
+      </div>
+    {/if}
+  {/if}
+</div>
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/lcov-report/src/routes/index.html b/dashboard/frontend/coverage/lcov-report/src/routes/index.html new file mode 100644 index 0000000..42a0bd3 --- /dev/null +++ b/dashboard/frontend/coverage/lcov-report/src/routes/index.html @@ -0,0 +1,341 @@ + + + + + + Code coverage report for src/routes + + + + + + + + + +
+
+

All files src/routes

+
+ +
+ 2.01% + Statements + 48/2380 +
+ + +
+ 12% + Branches + 3/25 +
+ + +
+ 9.52% + Functions + 2/21 +
+ + +
+ 2.01% + Lines + 48/2380 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
CcConnect.svelte +
+
0%0/870%0/10%0/10%0/87
ChatSummary.svelte +
+
0%0/910%0/10%0/10%0/91
Conversations.svelte +
+
0%0/2010%0/10%0/10%0/201
Dashboard.svelte +
+
0%0/870%0/10%0/10%0/87
Docker.svelte +
+
0%0/940%0/10%0/10%0/94
Files.svelte +
+
0%0/1050%0/10%0/10%0/105
Gitea.svelte +
+
0%0/740%0/10%0/10%0/74
InfoEngine.svelte +
+
0%0/920%0/10%0/10%0/92
LiteLLM.svelte +
+
0%0/580%0/10%0/10%0/58
Login.svelte +
+
41.02%48/11730%3/1033.33%2/641.02%48/117
OPC.svelte +
+
0%0/1290%0/10%0/10%0/129
OpenClaw.svelte +
+
0%0/220%0/10%0/10%0/22
Security.svelte +
+
0%0/1710%0/10%0/10%0/171
Settings.svelte +
+
0%0/3620%0/10%0/10%0/362
Terminal.svelte +
+
0%0/5450%0/10%0/10%0/545
Transmission.svelte +
+
0%0/1450%0/10%0/10%0/145
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/lcov.info b/dashboard/frontend/coverage/lcov.info new file mode 100644 index 0000000..33e8064 --- /dev/null +++ b/dashboard/frontend/coverage/lcov.info @@ -0,0 +1,4265 @@ +TN: +SF:src/App.svelte +FN:1,(empty-report) +FNF:1 +FNH:0 +FNDA:0,(empty-report) +DA:1,0 +DA:22,0 +DA:23,0 +DA:24,0 +DA:25,0 +DA:26,0 +DA:27,0 +DA:28,0 +DA:29,0 +DA:30,0 +DA:31,0 +DA:32,0 +DA:33,0 +DA:34,0 +DA:35,0 +DA:36,0 +DA:37,0 +DA:40,0 +DA:41,0 +DA:42,0 +DA:43,0 +DA:44,0 +DA:45,0 +DA:46,0 +DA:47,0 +DA:48,0 +DA:49,0 +DA:51,0 +DA:52,0 +DA:53,0 +DA:54,0 +DA:56,0 +DA:57,0 +DA:58,0 +DA:59,0 +DA:60,0 +DA:61,0 +DA:63,0 +DA:64,0 +DA:65,0 +DA:66,0 +DA:67,0 +DA:68,0 +DA:69,0 +DA:71,0 +DA:72,0 +DA:73,0 +DA:74,0 +DA:75,0 +DA:76,0 +DA:77,0 +DA:79,0 +DA:80,0 +DA:81,0 +DA:82,0 +DA:83,0 +DA:84,0 +DA:85,0 +DA:86,0 +DA:88,0 +DA:89,0 +DA:90,0 +DA:91,0 +DA:92,0 +DA:93,0 +DA:94,0 +DA:95,0 +DA:96,0 +DA:98,0 +DA:99,0 +DA:100,0 +DA:101,0 +DA:103,0 +DA:105,0 +DA:106,0 +DA:107,0 +DA:108,0 +DA:109,0 +DA:110,0 +DA:111,0 +DA:114,0 +DA:115,0 +DA:116,0 +DA:117,0 +DA:118,0 +DA:119,0 +DA:120,0 +DA:121,0 +DA:122,0 +DA:123,0 +DA:125,0 +DA:126,0 +DA:128,0 +DA:131,0 +DA:132,0 +DA:133,0 +DA:134,0 +DA:136,0 +DA:138,0 +DA:139,0 +DA:140,0 +DA:141,0 +DA:142,0 +DA:143,0 +DA:144,0 +DA:145,0 +DA:146,0 +DA:147,0 +DA:148,0 +DA:149,0 +DA:150,0 +DA:152,0 +DA:153,0 +DA:154,0 +DA:155,0 +DA:157,0 +DA:158,0 +DA:159,0 +DA:160,0 +DA:161,0 +DA:162,0 +DA:163,0 +DA:164,0 +DA:165,0 +DA:166,0 +DA:168,0 +DA:169,0 +DA:170,0 +DA:171,0 +DA:172,0 +DA:173,0 +DA:174,0 +DA:175,0 +DA:176,0 +DA:177,0 +DA:179,0 +DA:180,0 +DA:183,0 +DA:184,0 +DA:186,0 +DA:187,0 +DA:188,0 +DA:189,0 +DA:191,0 +DA:194,0 +DA:195,0 +DA:196,0 +DA:197,0 +DA:198,0 +DA:199,0 +DA:200,0 +DA:201,0 +DA:202,0 +DA:203,0 +DA:204,0 +DA:205,0 +DA:206,0 +DA:207,0 +DA:208,0 +DA:209,0 +DA:210,0 +DA:211,0 +DA:212,0 +DA:213,0 +DA:214,0 +DA:215,0 +DA:216,0 +DA:217,0 +DA:218,0 +DA:219,0 +DA:220,0 +DA:221,0 +DA:222,0 +DA:223,0 +DA:226,0 +DA:227,0 +DA:228,0 +LF:177 +LH:0 +BRDA:1,0,0,0 +BRF:1 +BRH:0 +end_of_record +TN: +SF:src/main.js +FN:1,(empty-report) +FNF:1 +FNH:0 +FNDA:0,(empty-report) +DA:1,0 +DA:2,0 +DA:3,0 +DA:5,0 +DA:6,0 +LF:5 +LH:0 +BRDA:1,0,0,0 +BRF:1 +BRH:0 +end_of_record +TN: +SF:src/components/Sidebar.svelte +FN:1,(empty-report) +FNF:1 +FNH:0 +FNDA:0,(empty-report) +DA:1,0 +DA:2,0 +DA:4,0 +DA:5,0 +DA:6,0 +DA:7,0 +DA:8,0 +DA:9,0 +DA:10,0 +DA:12,0 +DA:13,0 +DA:15,0 +DA:16,0 +DA:17,0 +DA:19,0 +DA:20,0 +DA:21,0 +DA:22,0 +DA:23,0 +DA:24,0 +DA:25,0 +DA:26,0 +DA:27,0 +DA:30,0 +DA:31,0 +DA:32,0 +DA:33,0 +DA:35,0 +DA:36,0 +DA:37,0 +DA:38,0 +DA:41,0 +DA:42,0 +DA:43,0 +DA:44,0 +DA:45,0 +DA:46,0 +DA:48,0 +DA:49,0 +DA:50,0 +DA:51,0 +DA:52,0 +DA:53,0 +DA:56,0 +DA:57,0 +DA:58,0 +DA:59,0 +DA:60,0 +DA:61,0 +DA:62,0 +DA:63,0 +DA:64,0 +DA:65,0 +DA:66,0 +DA:69,0 +DA:71,0 +DA:74,0 +DA:77,0 +DA:78,0 +DA:80,0 +DA:81,0 +DA:83,0 +DA:84,0 +DA:86,0 +DA:87,0 +DA:88,0 +DA:89,0 +DA:90,0 +DA:91,0 +DA:92,0 +DA:93,0 +DA:94,0 +DA:97,0 +DA:98,0 +DA:99,0 +DA:100,0 +DA:101,0 +DA:102,0 +DA:103,0 +DA:104,0 +DA:106,0 +DA:107,0 +DA:109,0 +DA:110,0 +DA:111,0 +DA:112,0 +DA:113,0 +DA:116,0 +DA:119,0 +DA:120,0 +DA:121,0 +DA:122,0 +DA:124,0 +DA:125,0 +DA:126,0 +DA:127,0 +DA:128,0 +DA:129,0 +DA:131,0 +DA:132,0 +DA:133,0 +DA:134,0 +DA:135,0 +DA:136,0 +DA:138,0 +DA:139,0 +DA:140,0 +DA:141,0 +DA:142,0 +DA:143,0 +DA:145,0 +DA:146,0 +DA:147,0 +DA:148,0 +DA:149,0 +DA:150,0 +DA:151,0 +DA:152,0 +DA:153,0 +DA:154,0 +DA:155,0 +DA:156,0 +DA:157,0 +DA:159,0 +DA:160,0 +DA:161,0 +DA:164,0 +DA:165,0 +DA:166,0 +DA:167,0 +DA:168,0 +DA:169,0 +DA:171,0 +DA:172,0 +DA:173,0 +DA:174,0 +DA:175,0 +DA:176,0 +DA:177,0 +DA:178,0 +DA:179,0 +DA:180,0 +DA:181,0 +DA:182,0 +DA:184,0 +DA:185,0 +DA:186,0 +DA:188,0 +DA:189,0 +DA:190,0 +DA:192,0 +DA:193,0 +DA:195,0 +DA:196,0 +DA:197,0 +DA:198,0 +DA:199,0 +DA:200,0 +DA:201,0 +DA:202,0 +DA:203,0 +DA:204,0 +DA:205,0 +DA:207,0 +DA:209,0 +DA:210,0 +DA:211,0 +DA:213,0 +DA:214,0 +DA:215,0 +DA:216,0 +DA:217,0 +DA:218,0 +DA:219,0 +DA:220,0 +DA:222,0 +DA:223,0 +DA:224,0 +DA:225,0 +DA:227,0 +DA:228,0 +DA:229,0 +DA:230,0 +DA:231,0 +DA:233,0 +DA:234,0 +DA:235,0 +DA:236,0 +DA:237,0 +DA:238,0 +DA:239,0 +DA:240,0 +DA:242,0 +DA:243,0 +DA:244,0 +DA:246,0 +DA:247,0 +DA:248,0 +DA:249,0 +DA:250,0 +DA:251,0 +DA:252,0 +DA:253,0 +DA:254,0 +DA:255,0 +DA:256,0 +DA:257,0 +DA:258,0 +DA:259,0 +DA:260,0 +DA:261,0 +DA:262,0 +DA:263,0 +DA:264,0 +DA:265,0 +DA:266,0 +DA:267,0 +DA:268,0 +DA:269,0 +DA:270,0 +DA:271,0 +DA:272,0 +DA:273,0 +DA:274,0 +DA:275,0 +DA:276,0 +DA:277,0 +DA:278,0 +DA:279,0 +DA:280,0 +DA:281,0 +DA:282,0 +DA:283,0 +DA:284,0 +DA:285,0 +DA:286,0 +DA:287,0 +DA:288,0 +DA:289,0 +DA:290,0 +DA:291,0 +DA:297,0 +DA:298,0 +DA:301,0 +DA:314,0 +DA:315,0 +DA:316,0 +DA:317,0 +DA:319,0 +DA:320,0 +DA:321,0 +DA:322,0 +DA:323,0 +DA:324,0 +DA:326,0 +DA:327,0 +DA:328,0 +DA:331,0 +DA:332,0 +DA:333,0 +DA:335,0 +DA:336,0 +DA:337,0 +DA:342,0 +DA:343,0 +DA:344,0 +DA:345,0 +DA:346,0 +DA:350,0 +DA:351,0 +DA:352,0 +DA:353,0 +DA:354,0 +DA:357,0 +DA:358,0 +DA:362,0 +DA:364,0 +DA:365,0 +DA:366,0 +DA:367,0 +DA:369,0 +DA:370,0 +DA:371,0 +DA:372,0 +DA:373,0 +DA:376,0 +DA:377,0 +DA:387,0 +DA:388,0 +DA:389,0 +DA:390,0 +DA:391,0 +DA:397,0 +DA:398,0 +DA:399,0 +DA:405,0 +DA:406,0 +DA:408,0 +DA:410,0 +DA:411,0 +DA:413,0 +LF:301 +LH:0 +BRDA:1,0,0,0 +BRF:1 +BRH:0 +end_of_record +TN: +SF:src/components/opc/AgentPanel.svelte +FN:1,(empty-report) +FNF:1 +FNH:0 +FNDA:0,(empty-report) +DA:1,0 +DA:5,0 +DA:6,0 +DA:7,0 +DA:8,0 +DA:10,0 +DA:11,0 +DA:12,0 +DA:13,0 +DA:14,0 +DA:15,0 +DA:17,0 +DA:18,0 +DA:19,0 +DA:20,0 +DA:21,0 +DA:22,0 +DA:23,0 +DA:24,0 +DA:26,0 +DA:27,0 +DA:28,0 +DA:29,0 +DA:30,0 +DA:31,0 +DA:32,0 +DA:33,0 +DA:35,0 +DA:36,0 +DA:38,0 +DA:39,0 +DA:40,0 +DA:41,0 +DA:42,0 +DA:43,0 +DA:44,0 +DA:46,0 +DA:47,0 +DA:49,0 +DA:50,0 +DA:51,0 +DA:53,0 +DA:54,0 +DA:55,0 +DA:56,0 +DA:58,0 +DA:59,0 +DA:61,0 +DA:62,0 +DA:63,0 +DA:64,0 +DA:66,0 +DA:71,0 +DA:72,0 +DA:75,0 +DA:76,0 +DA:77,0 +DA:78,0 +DA:79,0 +DA:81,0 +DA:82,0 +DA:83,0 +DA:86,0 +DA:87,0 +DA:88,0 +DA:89,0 +DA:90,0 +DA:91,0 +DA:93,0 +DA:94,0 +DA:98,0 +DA:99,0 +DA:104,0 +DA:109,0 +DA:110,0 +DA:112,0 +DA:113,0 +DA:116,0 +DA:118,0 +DA:119,0 +DA:125,0 +DA:127,0 +DA:128,0 +DA:129,0 +DA:130,0 +DA:133,0 +DA:134,0 +DA:143,0 +DA:144,0 +DA:145,0 +DA:146,0 +DA:150,0 +DA:151,0 +DA:152,0 +DA:153,0 +DA:154,0 +DA:155,0 +DA:156,0 +DA:157,0 +DA:158,0 +DA:160,0 +DA:161,0 +DA:165,0 +DA:166,0 +DA:170,0 +DA:171,0 +DA:172,0 +DA:176,0 +DA:177,0 +DA:178,0 +DA:179,0 +DA:180,0 +DA:181,0 +DA:187,0 +DA:188,0 +DA:189,0 +DA:194,0 +LF:117 +LH:0 +BRDA:1,0,0,0 +BRF:1 +BRH:0 +end_of_record +TN: +SF:src/components/opc/KanbanBoard.svelte +FN:1,(empty-report) +FNF:1 +FNH:0 +FNDA:0,(empty-report) +DA:1,0 +DA:5,0 +DA:7,0 +DA:8,0 +DA:9,0 +DA:10,0 +DA:13,0 +DA:14,0 +DA:15,0 +DA:17,0 +DA:18,0 +DA:19,0 +DA:20,0 +DA:21,0 +DA:22,0 +DA:23,0 +DA:24,0 +DA:25,0 +DA:27,0 +DA:28,0 +DA:29,0 +DA:30,0 +DA:31,0 +DA:32,0 +DA:33,0 +DA:37,0 +LF:26 +LH:0 +BRDA:1,0,0,0 +BRF:1 +BRH:0 +end_of_record +TN: +SF:src/components/opc/KanbanColumn.svelte +FN:1,(empty-report) +FNF:1 +FNH:0 +FNDA:0,(empty-report) +DA:1,0 +DA:7,0 +DA:8,0 +DA:15,0 +DA:16,0 +DA:17,0 +DA:18,0 +DA:21,0 +DA:22,0 +DA:23,0 +DA:24,0 +DA:26,0 +DA:27,0 +DA:28,0 +DA:30,0 +DA:31,0 +DA:32,0 +DA:34,0 +DA:35,0 +DA:37,0 +DA:38,0 +DA:39,0 +DA:40,0 +DA:42,0 +DA:43,0 +DA:44,0 +DA:45,0 +DA:46,0 +DA:47,0 +DA:49,0 +DA:51,0 +DA:52,0 +DA:55,0 +DA:56,0 +DA:61,0 +DA:62,0 +DA:63,0 +DA:64,0 +DA:65,0 +DA:67,0 +DA:68,0 +DA:72,0 +DA:73,0 +DA:74,0 +DA:75,0 +DA:76,0 +LF:46 +LH:0 +BRDA:1,0,0,0 +BRF:1 +BRH:0 +end_of_record +TN: +SF:src/components/opc/TaskCard.svelte +FN:1,(empty-report) +FNF:1 +FNH:0 +FNDA:0,(empty-report) +DA:1,0 +DA:4,0 +DA:6,0 +DA:7,0 +DA:9,0 +DA:10,0 +DA:11,0 +DA:12,0 +DA:13,0 +DA:16,0 +DA:17,0 +DA:18,0 +DA:19,0 +DA:20,0 +DA:21,0 +DA:22,0 +DA:23,0 +DA:24,0 +DA:26,0 +DA:27,0 +DA:28,0 +DA:30,0 +DA:31,0 +DA:32,0 +DA:33,0 +DA:34,0 +DA:35,0 +DA:36,0 +DA:37,0 +DA:38,0 +DA:39,0 +DA:41,0 +DA:42,0 +DA:43,0 +DA:44,0 +DA:45,0 +DA:46,0 +DA:47,0 +DA:48,0 +DA:50,0 +DA:51,0 +DA:52,0 +DA:53,0 +DA:54,0 +DA:55,0 +DA:56,0 +DA:57,0 +DA:59,0 +DA:60,0 +DA:61,0 +DA:63,0 +DA:65,0 +DA:66,0 +DA:69,0 +DA:70,0 +DA:71,0 +DA:73,0 +DA:74,0 +DA:75,0 +DA:82,0 +DA:83,0 +DA:94,0 +DA:95,0 +DA:99,0 +DA:100,0 +DA:101,0 +DA:106,0 +DA:107,0 +DA:108,0 +DA:109,0 +DA:110,0 +DA:117,0 +DA:118,0 +DA:119,0 +DA:120,0 +DA:121,0 +DA:122,0 +DA:125,0 +DA:128,0 +DA:129,0 +DA:138,0 +DA:139,0 +DA:140,0 +DA:146,0 +DA:147,0 +DA:148,0 +DA:153,0 +DA:154,0 +DA:156,0 +DA:157,0 +DA:158,0 +DA:159,0 +DA:161,0 +DA:163,0 +LF:94 +LH:0 +BRDA:1,0,0,0 +BRF:1 +BRH:0 +end_of_record +TN: +SF:src/components/opc/TaskModal.svelte +FN:1,(empty-report) +FNF:1 +FNH:0 +FNDA:0,(empty-report) +DA:1,0 +DA:4,0 +DA:6,0 +DA:7,0 +DA:8,0 +DA:9,0 +DA:10,0 +DA:11,0 +DA:12,0 +DA:13,0 +DA:14,0 +DA:15,0 +DA:18,0 +DA:19,0 +DA:21,0 +DA:22,0 +DA:23,0 +DA:24,0 +DA:25,0 +DA:26,0 +DA:28,0 +DA:29,0 +DA:30,0 +DA:32,0 +DA:33,0 +DA:34,0 +DA:36,0 +DA:37,0 +DA:38,0 +DA:39,0 +DA:40,0 +DA:43,0 +DA:44,0 +DA:45,0 +DA:46,0 +DA:47,0 +DA:51,0 +DA:52,0 +DA:53,0 +DA:54,0 +DA:55,0 +DA:56,0 +DA:57,0 +DA:60,0 +DA:62,0 +DA:65,0 +DA:67,0 +DA:68,0 +DA:69,0 +DA:71,0 +DA:80,0 +DA:82,0 +DA:86,0 +DA:88,0 +DA:96,0 +DA:100,0 +DA:101,0 +DA:109,0 +DA:110,0 +DA:114,0 +DA:115,0 +DA:118,0 +DA:119,0 +DA:120,0 +DA:124,0 +DA:128,0 +DA:129,0 +DA:132,0 +DA:133,0 +DA:134,0 +DA:135,0 +DA:141,0 +DA:142,0 +DA:146,0 +DA:147,0 +DA:150,0 +DA:151,0 +DA:155,0 +DA:156,0 +DA:157,0 +DA:159,0 +DA:160,0 +DA:161,0 +DA:164,0 +DA:165,0 +DA:166,0 +DA:170,0 +DA:172,0 +DA:181,0 +DA:185,0 +DA:187,0 +DA:193,0 +DA:197,0 +DA:198,0 +DA:200,0 +DA:201,0 +DA:205,0 +DA:207,0 +DA:213,0 +DA:214,0 +DA:215,0 +DA:216,0 +DA:217,0 +DA:218,0 +DA:220,0 +DA:232,0 +DA:233,0 +DA:240,0 +DA:242,0 +DA:245,0 +LF:110 +LH:0 +BRDA:1,0,0,0 +BRF:1 +BRH:0 +end_of_record +TN: +SF:src/lib/api.js +FN:7,setCurrentUser +FN:11,setToken +FN:17,getToken +FN:22,setRefreshToken +FN:26,getLegacyRefreshToken +FN:30,clearLegacyRefreshToken +FN:34,requestRefresh +FN:47,tryRefreshSession +FN:75,request +FN:117,get +FN:121,post +FN:125,del +FN:129,upload +FN:146,login +FN:150,checkAuth +FN:154,logout +FN:158,getPreferences +FN:162,savePreferences +FN:166,getLiteLLMHealth +FN:170,getCcConnectHealth +FN:174,startCcConnect +FN:178,stopCcConnect +FN:182,getInfoEngineItems +FN:193,getInfoEngineItem +FN:197,put +FN:201,download +FNF:26 +FNH:15 +FNDA:1,setCurrentUser +FNDA:30,setToken +FNDA:5,getToken +FNDA:0,setRefreshToken +FNDA:3,getLegacyRefreshToken +FNDA:3,clearLegacyRefreshToken +FNDA:6,requestRefresh +FNDA:5,tryRefreshSession +FNDA:20,request +FNDA:13,get +FNDA:2,post +FNDA:1,del +FNDA:0,upload +FNDA:1,login +FNDA:1,checkAuth +FNDA:1,logout +FNDA:0,getPreferences +FNDA:0,savePreferences +FNDA:0,getLiteLLMHealth +FNDA:0,getCcConnectHealth +FNDA:0,startCcConnect +FNDA:0,stopCcConnect +FNDA:0,getInfoEngineItems +FNDA:0,getInfoEngineItem +FNDA:1,put +FNDA:0,download +DA:1,1 +DA:2,1 +DA:4,1 +DA:5,1 +DA:7,1 +DA:8,1 +DA:9,1 +DA:11,1 +DA:12,30 +DA:13,30 +DA:14,12 +DA:15,30 +DA:17,1 +DA:18,5 +DA:19,5 +DA:21,1 +DA:22,1 +DA:24,1 +DA:26,3 +DA:27,3 +DA:28,3 +DA:30,3 +DA:31,3 +DA:32,3 +DA:34,6 +DA:35,6 +DA:36,6 +DA:37,6 +DA:38,6 +DA:39,6 +DA:40,6 +DA:41,6 +DA:42,3 +DA:43,6 +DA:44,6 +DA:45,6 +DA:47,1 +DA:48,5 +DA:49,5 +DA:50,5 +DA:51,5 +DA:52,5 +DA:53,2 +DA:54,2 +DA:55,2 +DA:57,3 +DA:58,5 +DA:59,1 +DA:60,1 +DA:61,1 +DA:62,1 +DA:63,1 +DA:64,0 +DA:65,0 +DA:66,0 +DA:67,0 +DA:68,5 +DA:69,5 +DA:70,5 +DA:71,5 +DA:72,5 +DA:73,5 +DA:75,20 +DA:76,20 +DA:77,20 +DA:79,20 +DA:80,3 +DA:81,3 +DA:82,3 +DA:83,3 +DA:85,20 +DA:86,20 +DA:88,20 +DA:89,20 +DA:91,20 +DA:92,2 +DA:93,2 +DA:94,1 +DA:95,1 +DA:96,1 +DA:97,2 +DA:99,20 +DA:100,1 +DA:101,1 +DA:102,1 +DA:104,20 +DA:105,2 +DA:106,2 +DA:107,2 +DA:108,2 +DA:109,2 +DA:110,16 +DA:111,20 +DA:112,4 +DA:113,4 +DA:114,4 +DA:115,20 +DA:117,1 +DA:118,13 +DA:119,13 +DA:121,1 +DA:122,2 +DA:123,2 +DA:125,1 +DA:126,1 +DA:127,1 +DA:129,1 +DA:130,0 +DA:131,0 +DA:133,0 +DA:134,0 +DA:136,0 +DA:137,0 +DA:138,0 +DA:139,0 +DA:140,0 +DA:142,0 +DA:143,0 +DA:144,0 +DA:146,1 +DA:147,1 +DA:148,1 +DA:150,1 +DA:151,1 +DA:152,1 +DA:154,1 +DA:155,1 +DA:156,1 +DA:158,1 +DA:159,0 +DA:160,0 +DA:162,1 +DA:163,0 +DA:164,0 +DA:166,1 +DA:167,0 +DA:168,0 +DA:170,1 +DA:171,0 +DA:172,0 +DA:174,1 +DA:175,0 +DA:176,0 +DA:178,1 +DA:179,0 +DA:180,0 +DA:182,1 +DA:183,0 +DA:184,0 +DA:185,0 +DA:186,0 +DA:187,0 +DA:188,0 +DA:189,0 +DA:190,0 +DA:191,0 +DA:193,1 +DA:194,0 +DA:195,0 +DA:197,1 +DA:198,1 +DA:199,1 +DA:201,1 +DA:202,0 +DA:203,0 +DA:205,0 +DA:206,0 +DA:207,0 +DA:208,0 +DA:209,0 +DA:210,0 +DA:212,0 +DA:213,0 +DA:214,0 +DA:215,0 +DA:216,0 +DA:218,0 +DA:220,0 +DA:221,0 +DA:222,0 +DA:223,0 +DA:224,0 +DA:226,0 +DA:227,0 +DA:228,0 +DA:229,0 +DA:230,0 +DA:231,0 +DA:232,0 +DA:233,0 +DA:234,0 +LF:191 +LH:124 +BRDA:7,0,0,1 +BRDA:11,1,0,30 +BRDA:13,2,0,18 +BRDA:13,3,0,12 +BRDA:17,4,0,5 +BRDA:26,5,0,3 +BRDA:27,6,0,2 +BRDA:30,7,0,3 +BRDA:34,8,0,6 +BRDA:39,9,0,1 +BRDA:39,10,0,5 +BRDA:41,11,0,3 +BRDA:43,12,0,0 +BRDA:47,13,0,5 +BRDA:48,14,0,0 +BRDA:49,15,0,5 +BRDA:52,16,0,2 +BRDA:55,17,0,3 +BRDA:58,18,0,2 +BRDA:58,19,0,1 +BRDA:63,20,0,0 +BRDA:75,21,0,20 +BRDA:77,22,0,12 +BRDA:79,23,0,3 +BRDA:86,24,0,3 +BRDA:89,25,0,19 +BRDA:91,26,0,2 +BRDA:91,27,0,2 +BRDA:93,28,0,1 +BRDA:97,29,0,19 +BRDA:99,30,0,1 +BRDA:102,31,0,18 +BRDA:104,32,0,2 +BRDA:107,33,0,0 +BRDA:109,34,0,16 +BRDA:111,35,0,4 +BRDA:117,36,0,13 +BRDA:121,37,0,2 +BRDA:125,38,0,1 +BRDA:146,39,0,1 +BRDA:150,40,0,1 +BRDA:154,41,0,1 +BRDA:197,42,0,1 +BRF:43 +BRH:39 +end_of_record +TN: +SF:src/lib/opc-api.js +FN:1,(empty-report) +FNF:1 +FNH:0 +FNDA:0,(empty-report) +DA:1,0 +DA:2,0 +DA:3,0 +DA:4,0 +DA:6,0 +DA:7,0 +DA:8,0 +DA:9,0 +DA:10,0 +DA:11,0 +DA:12,0 +DA:13,0 +DA:14,0 +DA:15,0 +DA:17,0 +DA:18,0 +DA:19,0 +DA:21,0 +DA:22,0 +DA:23,0 +DA:25,0 +DA:26,0 +DA:27,0 +DA:29,0 +DA:30,0 +DA:31,0 +DA:33,0 +DA:34,0 +DA:35,0 +DA:37,0 +DA:38,0 +DA:39,0 +DA:41,0 +DA:42,0 +DA:43,0 +DA:45,0 +DA:46,0 +DA:47,0 +DA:49,0 +DA:50,0 +DA:51,0 +DA:52,0 +DA:54,0 +DA:55,0 +DA:56,0 +DA:58,0 +DA:59,0 +DA:60,0 +DA:62,0 +DA:63,0 +DA:64,0 +DA:65,0 +DA:66,0 +DA:67,0 +DA:68,0 +DA:70,0 +DA:71,0 +DA:72,0 +DA:74,0 +DA:75,0 +DA:76,0 +DA:78,0 +DA:79,0 +DA:80,0 +LF:64 +LH:0 +BRDA:1,0,0,0 +BRF:1 +BRH:0 +end_of_record +TN: +SF:src/lib/opc-ws.js +FN:1,(empty-report) +FNF:1 +FNH:0 +FNDA:0,(empty-report) +DA:1,0 +DA:2,0 +DA:3,0 +DA:5,0 +DA:7,0 +DA:8,0 +DA:9,0 +DA:11,0 +DA:12,0 +DA:13,0 +DA:14,0 +DA:16,0 +DA:17,0 +DA:18,0 +DA:19,0 +DA:21,0 +DA:23,0 +DA:24,0 +DA:25,0 +DA:26,0 +DA:27,0 +DA:28,0 +DA:30,0 +DA:31,0 +DA:32,0 +DA:33,0 +DA:34,0 +DA:35,0 +DA:36,0 +DA:37,0 +DA:38,0 +DA:40,0 +DA:41,0 +DA:42,0 +DA:43,0 +DA:44,0 +DA:45,0 +DA:46,0 +DA:47,0 +DA:49,0 +DA:50,0 +DA:51,0 +DA:53,0 +DA:54,0 +DA:55,0 +DA:57,0 +DA:58,0 +DA:59,0 +DA:60,0 +DA:61,0 +DA:62,0 +DA:63,0 +DA:65,0 +DA:66,0 +DA:67,0 +DA:68,0 +DA:69,0 +DA:70,0 +DA:71,0 +DA:72,0 +DA:73,0 +DA:74,0 +DA:76,0 +DA:77,0 +DA:78,0 +DA:79,0 +DA:81,0 +DA:82,0 +DA:83,0 +DA:84,0 +DA:85,0 +DA:86,0 +DA:87,0 +DA:88,0 +DA:89,0 +LF:75 +LH:0 +BRDA:1,0,0,0 +BRF:1 +BRH:0 +end_of_record +TN: +SF:src/lib/voice.js +FN:1,(empty-report) +FNF:1 +FNH:0 +FNDA:0,(empty-report) +DA:1,0 +DA:2,0 +DA:3,0 +DA:4,0 +DA:5,0 +DA:6,0 +DA:7,0 +DA:8,0 +DA:9,0 +DA:10,0 +DA:12,0 +DA:13,0 +DA:14,0 +DA:15,0 +DA:16,0 +DA:17,0 +DA:18,0 +DA:19,0 +DA:20,0 +DA:21,0 +DA:22,0 +DA:23,0 +DA:24,0 +DA:25,0 +DA:26,0 +DA:27,0 +DA:28,0 +DA:29,0 +DA:30,0 +DA:31,0 +DA:32,0 +DA:33,0 +DA:35,0 +DA:36,0 +DA:37,0 +DA:38,0 +DA:40,0 +DA:41,0 +DA:42,0 +DA:43,0 +DA:44,0 +DA:45,0 +DA:46,0 +DA:47,0 +DA:48,0 +DA:50,0 +DA:51,0 +DA:52,0 +DA:53,0 +DA:55,0 +DA:56,0 +DA:57,0 +DA:58,0 +DA:59,0 +DA:60,0 +DA:62,0 +DA:64,0 +DA:65,0 +DA:66,0 +DA:67,0 +DA:68,0 +DA:69,0 +DA:70,0 +DA:71,0 +DA:72,0 +DA:73,0 +DA:74,0 +DA:75,0 +DA:76,0 +DA:77,0 +DA:78,0 +DA:79,0 +DA:81,0 +DA:82,0 +DA:83,0 +DA:84,0 +DA:85,0 +DA:86,0 +DA:87,0 +DA:88,0 +DA:89,0 +DA:91,0 +DA:92,0 +DA:93,0 +DA:94,0 +DA:95,0 +DA:96,0 +DA:97,0 +DA:99,0 +DA:100,0 +DA:101,0 +DA:102,0 +DA:104,0 +DA:105,0 +DA:106,0 +DA:107,0 +DA:108,0 +DA:110,0 +DA:111,0 +DA:112,0 +DA:113,0 +DA:115,0 +DA:116,0 +DA:117,0 +DA:118,0 +DA:119,0 +DA:120,0 +DA:122,0 +DA:123,0 +DA:124,0 +DA:125,0 +DA:126,0 +DA:127,0 +DA:128,0 +DA:130,0 +DA:131,0 +DA:132,0 +DA:133,0 +DA:134,0 +DA:135,0 +DA:136,0 +DA:137,0 +DA:138,0 +DA:139,0 +DA:140,0 +DA:141,0 +DA:142,0 +DA:143,0 +DA:144,0 +DA:145,0 +DA:146,0 +DA:147,0 +DA:148,0 +DA:149,0 +DA:150,0 +DA:151,0 +DA:152,0 +DA:153,0 +DA:154,0 +DA:155,0 +DA:156,0 +DA:157,0 +DA:158,0 +DA:159,0 +DA:160,0 +DA:161,0 +DA:162,0 +DA:163,0 +DA:164,0 +DA:165,0 +DA:166,0 +DA:167,0 +DA:168,0 +DA:169,0 +DA:170,0 +DA:171,0 +DA:173,0 +DA:174,0 +DA:175,0 +DA:176,0 +DA:177,0 +DA:178,0 +DA:179,0 +DA:181,0 +DA:182,0 +DA:183,0 +DA:184,0 +DA:185,0 +DA:186,0 +DA:187,0 +DA:188,0 +DA:190,0 +DA:191,0 +DA:192,0 +DA:193,0 +DA:194,0 +DA:195,0 +DA:197,0 +DA:198,0 +DA:199,0 +DA:200,0 +DA:201,0 +DA:202,0 +DA:203,0 +DA:204,0 +DA:205,0 +DA:206,0 +DA:207,0 +DA:208,0 +DA:209,0 +DA:210,0 +DA:212,0 +DA:213,0 +DA:214,0 +DA:215,0 +DA:216,0 +DA:217,0 +DA:218,0 +DA:219,0 +DA:220,0 +DA:221,0 +DA:222,0 +DA:224,0 +DA:225,0 +DA:226,0 +DA:227,0 +DA:228,0 +DA:229,0 +DA:230,0 +DA:231,0 +DA:232,0 +DA:233,0 +DA:235,0 +DA:236,0 +DA:237,0 +DA:238,0 +DA:239,0 +DA:240,0 +DA:241,0 +DA:242,0 +DA:243,0 +DA:244,0 +DA:246,0 +DA:247,0 +DA:248,0 +DA:249,0 +DA:250,0 +DA:251,0 +DA:252,0 +DA:253,0 +DA:254,0 +DA:255,0 +LF:232 +LH:0 +BRDA:1,0,0,0 +BRF:1 +BRH:0 +end_of_record +TN: +SF:src/routes/CcConnect.svelte +FN:1,(empty-report) +FNF:1 +FNH:0 +FNDA:0,(empty-report) +DA:1,0 +DA:5,0 +DA:6,0 +DA:7,0 +DA:8,0 +DA:10,0 +DA:11,0 +DA:12,0 +DA:13,0 +DA:14,0 +DA:15,0 +DA:16,0 +DA:17,0 +DA:18,0 +DA:19,0 +DA:20,0 +DA:21,0 +DA:23,0 +DA:24,0 +DA:25,0 +DA:26,0 +DA:27,0 +DA:28,0 +DA:29,0 +DA:30,0 +DA:31,0 +DA:32,0 +DA:33,0 +DA:34,0 +DA:35,0 +DA:36,0 +DA:37,0 +DA:39,0 +DA:40,0 +DA:41,0 +DA:42,0 +DA:43,0 +DA:44,0 +DA:46,0 +DA:47,0 +DA:48,0 +DA:50,0 +DA:51,0 +DA:53,0 +DA:54,0 +DA:63,0 +DA:64,0 +DA:65,0 +DA:66,0 +DA:67,0 +DA:70,0 +DA:73,0 +DA:74,0 +DA:75,0 +DA:78,0 +DA:81,0 +DA:82,0 +DA:87,0 +DA:88,0 +DA:90,0 +DA:94,0 +DA:95,0 +DA:96,0 +DA:98,0 +DA:99,0 +DA:100,0 +DA:104,0 +DA:105,0 +DA:107,0 +DA:110,0 +DA:111,0 +DA:113,0 +DA:116,0 +DA:118,0 +DA:121,0 +DA:123,0 +DA:126,0 +DA:128,0 +DA:131,0 +DA:133,0 +DA:136,0 +DA:138,0 +DA:141,0 +DA:143,0 +DA:146,0 +DA:148,0 +DA:152,0 +LF:87 +LH:0 +BRDA:1,0,0,0 +BRF:1 +BRH:0 +end_of_record +TN: +SF:src/routes/ChatSummary.svelte +FN:1,(empty-report) +FNF:1 +FNH:0 +FNDA:0,(empty-report) +DA:1,0 +DA:5,0 +DA:6,0 +DA:7,0 +DA:8,0 +DA:9,0 +DA:10,0 +DA:11,0 +DA:12,0 +DA:14,0 +DA:15,0 +DA:16,0 +DA:17,0 +DA:18,0 +DA:19,0 +DA:20,0 +DA:21,0 +DA:23,0 +DA:24,0 +DA:25,0 +DA:26,0 +DA:27,0 +DA:28,0 +DA:29,0 +DA:30,0 +DA:31,0 +DA:32,0 +DA:33,0 +DA:34,0 +DA:35,0 +DA:36,0 +DA:38,0 +DA:39,0 +DA:40,0 +DA:41,0 +DA:42,0 +DA:43,0 +DA:44,0 +DA:45,0 +DA:46,0 +DA:48,0 +DA:49,0 +DA:50,0 +DA:51,0 +DA:52,0 +DA:53,0 +DA:54,0 +DA:55,0 +DA:56,0 +DA:57,0 +DA:59,0 +DA:60,0 +DA:61,0 +DA:62,0 +DA:63,0 +DA:64,0 +DA:65,0 +DA:66,0 +DA:67,0 +DA:68,0 +DA:69,0 +DA:70,0 +DA:71,0 +DA:72,0 +DA:74,0 +DA:75,0 +DA:77,0 +DA:79,0 +DA:83,0 +DA:84,0 +DA:87,0 +DA:88,0 +DA:89,0 +DA:90,0 +DA:91,0 +DA:94,0 +DA:95,0 +DA:99,0 +DA:100,0 +DA:101,0 +DA:102,0 +DA:103,0 +DA:106,0 +DA:108,0 +DA:111,0 +DA:112,0 +DA:113,0 +DA:114,0 +DA:115,0 +DA:116,0 +DA:117,0 +LF:91 +LH:0 +BRDA:1,0,0,0 +BRF:1 +BRH:0 +end_of_record +TN: +SF:src/routes/Conversations.svelte +FN:1,(empty-report) +FNF:1 +FNH:0 +FNDA:0,(empty-report) +DA:1,0 +DA:5,0 +DA:6,0 +DA:7,0 +DA:8,0 +DA:9,0 +DA:10,0 +DA:11,0 +DA:12,0 +DA:13,0 +DA:14,0 +DA:15,0 +DA:16,0 +DA:17,0 +DA:19,0 +DA:20,0 +DA:21,0 +DA:22,0 +DA:23,0 +DA:24,0 +DA:25,0 +DA:26,0 +DA:27,0 +DA:29,0 +DA:30,0 +DA:31,0 +DA:32,0 +DA:33,0 +DA:34,0 +DA:35,0 +DA:36,0 +DA:37,0 +DA:38,0 +DA:39,0 +DA:40,0 +DA:41,0 +DA:42,0 +DA:43,0 +DA:45,0 +DA:46,0 +DA:47,0 +DA:48,0 +DA:49,0 +DA:50,0 +DA:51,0 +DA:52,0 +DA:53,0 +DA:54,0 +DA:55,0 +DA:56,0 +DA:58,0 +DA:59,0 +DA:60,0 +DA:61,0 +DA:62,0 +DA:63,0 +DA:64,0 +DA:65,0 +DA:66,0 +DA:67,0 +DA:68,0 +DA:69,0 +DA:71,0 +DA:72,0 +DA:73,0 +DA:74,0 +DA:75,0 +DA:76,0 +DA:77,0 +DA:79,0 +DA:80,0 +DA:81,0 +DA:82,0 +DA:83,0 +DA:84,0 +DA:85,0 +DA:86,0 +DA:87,0 +DA:88,0 +DA:89,0 +DA:91,0 +DA:92,0 +DA:93,0 +DA:94,0 +DA:95,0 +DA:96,0 +DA:97,0 +DA:98,0 +DA:99,0 +DA:100,0 +DA:101,0 +DA:102,0 +DA:103,0 +DA:105,0 +DA:106,0 +DA:107,0 +DA:108,0 +DA:110,0 +DA:111,0 +DA:113,0 +DA:114,0 +DA:117,0 +DA:119,0 +DA:124,0 +DA:125,0 +DA:129,0 +DA:130,0 +DA:132,0 +DA:133,0 +DA:140,0 +DA:141,0 +DA:142,0 +DA:143,0 +DA:144,0 +DA:145,0 +DA:148,0 +DA:149,0 +DA:154,0 +DA:155,0 +DA:156,0 +DA:158,0 +DA:159,0 +DA:161,0 +DA:163,0 +DA:165,0 +DA:167,0 +DA:169,0 +DA:171,0 +DA:173,0 +DA:177,0 +DA:178,0 +DA:182,0 +DA:183,0 +DA:184,0 +DA:186,0 +DA:187,0 +DA:188,0 +DA:190,0 +DA:191,0 +DA:192,0 +DA:193,0 +DA:195,0 +DA:196,0 +DA:197,0 +DA:204,0 +DA:206,0 +DA:208,0 +DA:209,0 +DA:210,0 +DA:211,0 +DA:212,0 +DA:213,0 +DA:214,0 +DA:219,0 +DA:220,0 +DA:221,0 +DA:222,0 +DA:223,0 +DA:224,0 +DA:226,0 +DA:227,0 +DA:228,0 +DA:229,0 +DA:232,0 +DA:233,0 +DA:234,0 +DA:241,0 +DA:243,0 +DA:244,0 +DA:245,0 +DA:246,0 +DA:248,0 +DA:249,0 +DA:250,0 +DA:252,0 +DA:253,0 +DA:254,0 +DA:255,0 +DA:257,0 +DA:261,0 +DA:262,0 +DA:266,0 +DA:268,0 +DA:269,0 +DA:270,0 +DA:272,0 +DA:274,0 +DA:276,0 +DA:278,0 +DA:280,0 +DA:284,0 +DA:285,0 +DA:287,0 +DA:288,0 +DA:289,0 +DA:290,0 +DA:295,0 +DA:297,0 +DA:298,0 +DA:299,0 +DA:300,0 +LF:201 +LH:0 +BRDA:1,0,0,0 +BRF:1 +BRH:0 +end_of_record +TN: +SF:src/routes/Dashboard.svelte +FN:1,(empty-report) +FNF:1 +FNH:0 +FNDA:0,(empty-report) +DA:1,0 +DA:5,0 +DA:6,0 +DA:7,0 +DA:8,0 +DA:10,0 +DA:11,0 +DA:12,0 +DA:13,0 +DA:14,0 +DA:16,0 +DA:17,0 +DA:18,0 +DA:19,0 +DA:20,0 +DA:22,0 +DA:23,0 +DA:24,0 +DA:25,0 +DA:26,0 +DA:27,0 +DA:29,0 +DA:30,0 +DA:31,0 +DA:32,0 +DA:33,0 +DA:35,0 +DA:37,0 +DA:38,0 +DA:39,0 +DA:41,0 +DA:47,0 +DA:48,0 +DA:49,0 +DA:50,0 +DA:55,0 +DA:57,0 +DA:64,0 +DA:65,0 +DA:69,0 +DA:76,0 +DA:77,0 +DA:81,0 +DA:88,0 +DA:89,0 +DA:93,0 +DA:100,0 +DA:101,0 +DA:106,0 +DA:108,0 +DA:109,0 +DA:111,0 +DA:113,0 +DA:114,0 +DA:115,0 +DA:116,0 +DA:117,0 +DA:118,0 +DA:120,0 +DA:123,0 +DA:124,0 +DA:130,0 +DA:131,0 +DA:133,0 +DA:135,0 +DA:136,0 +DA:137,0 +DA:138,0 +DA:140,0 +DA:142,0 +DA:145,0 +DA:146,0 +DA:153,0 +DA:154,0 +DA:156,0 +DA:157,0 +DA:158,0 +DA:159,0 +DA:160,0 +DA:161,0 +DA:163,0 +DA:164,0 +DA:165,0 +DA:166,0 +DA:169,0 +DA:170,0 +DA:171,0 +LF:87 +LH:0 +BRDA:1,0,0,0 +BRF:1 +BRH:0 +end_of_record +TN: +SF:src/routes/Docker.svelte +FN:1,(empty-report) +FNF:1 +FNH:0 +FNDA:0,(empty-report) +DA:1,0 +DA:5,0 +DA:6,0 +DA:7,0 +DA:8,0 +DA:9,0 +DA:10,0 +DA:11,0 +DA:13,0 +DA:14,0 +DA:15,0 +DA:16,0 +DA:17,0 +DA:18,0 +DA:19,0 +DA:20,0 +DA:21,0 +DA:22,0 +DA:23,0 +DA:25,0 +DA:26,0 +DA:27,0 +DA:28,0 +DA:29,0 +DA:30,0 +DA:31,0 +DA:32,0 +DA:33,0 +DA:34,0 +DA:36,0 +DA:37,0 +DA:38,0 +DA:39,0 +DA:40,0 +DA:41,0 +DA:42,0 +DA:43,0 +DA:44,0 +DA:45,0 +DA:46,0 +DA:48,0 +DA:49,0 +DA:51,0 +DA:52,0 +DA:53,0 +DA:55,0 +DA:57,0 +DA:62,0 +DA:63,0 +DA:64,0 +DA:65,0 +DA:68,0 +DA:69,0 +DA:70,0 +DA:71,0 +DA:73,0 +DA:75,0 +DA:81,0 +DA:82,0 +DA:83,0 +DA:84,0 +DA:85,0 +DA:86,0 +DA:90,0 +DA:93,0 +DA:94,0 +DA:95,0 +DA:98,0 +DA:99,0 +DA:100,0 +DA:102,0 +DA:103,0 +DA:105,0 +DA:107,0 +DA:109,0 +DA:110,0 +DA:112,0 +DA:115,0 +DA:117,0 +DA:118,0 +DA:120,0 +DA:123,0 +DA:125,0 +DA:136,0 +DA:137,0 +DA:138,0 +DA:139,0 +DA:140,0 +DA:142,0 +DA:144,0 +DA:148,0 +DA:149,0 +DA:150,0 +DA:154,0 +LF:94 +LH:0 +BRDA:1,0,0,0 +BRF:1 +BRH:0 +end_of_record +TN: +SF:src/routes/Files.svelte +FN:1,(empty-report) +FNF:1 +FNH:0 +FNDA:0,(empty-report) +DA:1,0 +DA:5,0 +DA:6,0 +DA:7,0 +DA:8,0 +DA:9,0 +DA:10,0 +DA:12,0 +DA:13,0 +DA:14,0 +DA:15,0 +DA:16,0 +DA:17,0 +DA:18,0 +DA:20,0 +DA:21,0 +DA:22,0 +DA:23,0 +DA:24,0 +DA:26,0 +DA:27,0 +DA:28,0 +DA:29,0 +DA:30,0 +DA:31,0 +DA:33,0 +DA:34,0 +DA:35,0 +DA:36,0 +DA:37,0 +DA:38,0 +DA:39,0 +DA:40,0 +DA:41,0 +DA:42,0 +DA:44,0 +DA:45,0 +DA:46,0 +DA:47,0 +DA:48,0 +DA:49,0 +DA:50,0 +DA:51,0 +DA:52,0 +DA:54,0 +DA:55,0 +DA:56,0 +DA:57,0 +DA:58,0 +DA:59,0 +DA:61,0 +DA:62,0 +DA:63,0 +DA:64,0 +DA:65,0 +DA:66,0 +DA:67,0 +DA:68,0 +DA:69,0 +DA:71,0 +DA:73,0 +DA:74,0 +DA:76,0 +DA:77,0 +DA:78,0 +DA:80,0 +DA:82,0 +DA:83,0 +DA:84,0 +DA:85,0 +DA:87,0 +DA:89,0 +DA:95,0 +DA:96,0 +DA:100,0 +DA:101,0 +DA:103,0 +DA:112,0 +DA:113,0 +DA:114,0 +DA:116,0 +DA:118,0 +DA:119,0 +DA:121,0 +DA:122,0 +DA:124,0 +DA:133,0 +DA:134,0 +DA:135,0 +DA:136,0 +DA:140,0 +DA:148,0 +DA:149,0 +DA:150,0 +DA:151,0 +DA:153,0 +DA:156,0 +DA:158,0 +DA:161,0 +DA:162,0 +DA:163,0 +DA:164,0 +DA:168,0 +DA:172,0 +DA:173,0 +LF:105 +LH:0 +BRDA:1,0,0,0 +BRF:1 +BRH:0 +end_of_record +TN: +SF:src/routes/Gitea.svelte +FN:1,(empty-report) +FNF:1 +FNH:0 +FNDA:0,(empty-report) +DA:1,0 +DA:5,0 +DA:6,0 +DA:7,0 +DA:8,0 +DA:9,0 +DA:11,0 +DA:12,0 +DA:13,0 +DA:14,0 +DA:15,0 +DA:16,0 +DA:18,0 +DA:19,0 +DA:20,0 +DA:21,0 +DA:22,0 +DA:23,0 +DA:24,0 +DA:26,0 +DA:27,0 +DA:28,0 +DA:29,0 +DA:30,0 +DA:31,0 +DA:32,0 +DA:34,0 +DA:35,0 +DA:36,0 +DA:39,0 +DA:40,0 +DA:42,0 +DA:43,0 +DA:45,0 +DA:48,0 +DA:49,0 +DA:50,0 +DA:51,0 +DA:55,0 +DA:56,0 +DA:57,0 +DA:58,0 +DA:59,0 +DA:61,0 +DA:62,0 +DA:63,0 +DA:64,0 +DA:66,0 +DA:67,0 +DA:68,0 +DA:69,0 +DA:73,0 +DA:80,0 +DA:81,0 +DA:82,0 +DA:83,0 +DA:85,0 +DA:87,0 +DA:90,0 +DA:95,0 +DA:96,0 +DA:97,0 +DA:98,0 +DA:102,0 +DA:103,0 +DA:104,0 +DA:105,0 +DA:107,0 +DA:108,0 +DA:111,0 +DA:112,0 +DA:113,0 +DA:114,0 +DA:117,0 +LF:74 +LH:0 +BRDA:1,0,0,0 +BRF:1 +BRH:0 +end_of_record +TN: +SF:src/routes/InfoEngine.svelte +FN:1,(empty-report) +FNF:1 +FNH:0 +FNDA:0,(empty-report) +DA:1,0 +DA:5,0 +DA:6,0 +DA:7,0 +DA:8,0 +DA:9,0 +DA:10,0 +DA:11,0 +DA:13,0 +DA:14,0 +DA:15,0 +DA:16,0 +DA:17,0 +DA:18,0 +DA:19,0 +DA:20,0 +DA:21,0 +DA:22,0 +DA:23,0 +DA:24,0 +DA:25,0 +DA:26,0 +DA:27,0 +DA:28,0 +DA:29,0 +DA:30,0 +DA:31,0 +DA:32,0 +DA:33,0 +DA:34,0 +DA:35,0 +DA:37,0 +DA:38,0 +DA:39,0 +DA:40,0 +DA:41,0 +DA:42,0 +DA:43,0 +DA:44,0 +DA:45,0 +DA:46,0 +DA:47,0 +DA:48,0 +DA:49,0 +DA:51,0 +DA:52,0 +DA:53,0 +DA:54,0 +DA:55,0 +DA:56,0 +DA:57,0 +DA:58,0 +DA:60,0 +DA:61,0 +DA:63,0 +DA:64,0 +DA:69,0 +DA:70,0 +DA:74,0 +DA:75,0 +DA:76,0 +DA:80,0 +DA:81,0 +DA:82,0 +DA:83,0 +DA:85,0 +DA:86,0 +DA:87,0 +DA:88,0 +DA:91,0 +DA:92,0 +DA:94,0 +DA:95,0 +DA:96,0 +DA:97,0 +DA:98,0 +DA:105,0 +DA:106,0 +DA:107,0 +DA:113,0 +DA:114,0 +DA:115,0 +DA:116,0 +DA:117,0 +DA:118,0 +DA:121,0 +DA:122,0 +DA:123,0 +DA:124,0 +DA:129,0 +DA:131,0 +DA:135,0 +LF:92 +LH:0 +BRDA:1,0,0,0 +BRF:1 +BRH:0 +end_of_record +TN: +SF:src/routes/LiteLLM.svelte +FN:1,(empty-report) +FNF:1 +FNH:0 +FNDA:0,(empty-report) +DA:1,0 +DA:5,0 +DA:6,0 +DA:7,0 +DA:9,0 +DA:10,0 +DA:11,0 +DA:12,0 +DA:13,0 +DA:14,0 +DA:15,0 +DA:16,0 +DA:17,0 +DA:18,0 +DA:19,0 +DA:20,0 +DA:22,0 +DA:23,0 +DA:24,0 +DA:25,0 +DA:26,0 +DA:27,0 +DA:28,0 +DA:29,0 +DA:30,0 +DA:32,0 +DA:33,0 +DA:34,0 +DA:36,0 +DA:37,0 +DA:39,0 +DA:40,0 +DA:50,0 +DA:51,0 +DA:55,0 +DA:56,0 +DA:58,0 +DA:62,0 +DA:63,0 +DA:64,0 +DA:66,0 +DA:67,0 +DA:68,0 +DA:72,0 +DA:73,0 +DA:75,0 +DA:78,0 +DA:79,0 +DA:81,0 +DA:84,0 +DA:86,0 +DA:89,0 +DA:91,0 +DA:94,0 +DA:96,0 +DA:99,0 +DA:101,0 +DA:105,0 +LF:58 +LH:0 +BRDA:1,0,0,0 +BRF:1 +BRH:0 +end_of_record +TN: +SF:src/routes/Login.svelte +FN:1,Login +FN:13,base64urlToBuffer +FN:19,bufferToBase64url +FN:26,loginWithPasskey +FN:69,handleSubmit +FN:104,Login.__vite_ssr_import_1__.transition.y +FNF:6 +FNH:2 +FNDA:3,Login +FNDA:0,base64urlToBuffer +FNDA:0,bufferToBase64url +FNDA:0,loginWithPasskey +FNDA:0,handleSubmit +FNDA:3,Login.__vite_ssr_import_1__.transition.y +DA:1,3 +DA:5,3 +DA:6,3 +DA:7,3 +DA:8,3 +DA:9,3 +DA:10,3 +DA:11,3 +DA:13,3 +DA:14,0 +DA:15,0 +DA:16,0 +DA:17,0 +DA:19,3 +DA:20,0 +DA:21,0 +DA:22,0 +DA:23,0 +DA:24,0 +DA:26,3 +DA:27,0 +DA:28,0 +DA:29,0 +DA:30,0 +DA:31,0 +DA:32,0 +DA:33,0 +DA:34,0 +DA:35,0 +DA:36,0 +DA:38,0 +DA:39,0 +DA:40,0 +DA:41,0 +DA:42,0 +DA:43,0 +DA:44,0 +DA:45,0 +DA:46,0 +DA:47,0 +DA:48,0 +DA:51,0 +DA:52,0 +DA:53,0 +DA:54,0 +DA:56,0 +DA:57,0 +DA:58,0 +DA:59,0 +DA:60,0 +DA:61,0 +DA:62,0 +DA:63,0 +DA:64,0 +DA:65,0 +DA:66,0 +DA:67,0 +DA:69,3 +DA:70,0 +DA:71,0 +DA:72,0 +DA:74,0 +DA:75,0 +DA:76,0 +DA:78,0 +DA:80,0 +DA:83,0 +DA:85,0 +DA:86,0 +DA:87,0 +DA:89,0 +DA:90,0 +DA:91,0 +DA:93,0 +DA:94,0 +DA:95,0 +DA:96,0 +DA:97,0 +DA:98,0 +DA:99,0 +DA:100,0 +DA:101,3 +DA:103,1 +DA:104,1 +DA:117,1 +DA:118,1 +DA:119,1 +DA:121,1 +DA:122,1 +DA:125,1 +DA:126,1 +DA:139,1 +DA:144,1 +DA:145,1 +DA:146,1 +DA:147,1 +DA:148,1 +DA:150,1 +DA:155,1 +DA:160,1 +DA:162,1 +DA:167,1 +DA:173,1 +DA:175,1 +DA:184,1 +DA:189,1 +DA:190,1 +DA:198,1 +DA:200,1 +DA:203,1 +DA:204,1 +DA:209,1 +DA:214,1 +DA:215,1 +DA:216,1 +DA:222,1 +DA:223,1 +LF:117 +LH:48 +BRDA:1,0,0,3 +BRDA:118,1,0,0 +BRDA:118,2,0,0 +BRDA:147,3,0,0 +BRDA:203,4,0,0 +BRDA:209,5,0,0 +BRDA:144,6,0,0 +BRDA:214,7,0,0 +BRDA:104,8,0,3 +BRDA:104,9,0,3 +BRF:10 +BRH:3 +end_of_record +TN: +SF:src/routes/OPC.svelte +FN:1,(empty-report) +FNF:1 +FNH:0 +FNDA:0,(empty-report) +DA:1,0 +DA:9,0 +DA:10,0 +DA:11,0 +DA:12,0 +DA:13,0 +DA:14,0 +DA:15,0 +DA:16,0 +DA:17,0 +DA:19,0 +DA:22,0 +DA:23,0 +DA:24,0 +DA:25,0 +DA:26,0 +DA:28,0 +DA:29,0 +DA:30,0 +DA:31,0 +DA:32,0 +DA:33,0 +DA:35,0 +DA:36,0 +DA:37,0 +DA:38,0 +DA:39,0 +DA:40,0 +DA:41,0 +DA:42,0 +DA:44,0 +DA:45,0 +DA:46,0 +DA:48,0 +DA:49,0 +DA:50,0 +DA:51,0 +DA:52,0 +DA:53,0 +DA:54,0 +DA:55,0 +DA:57,0 +DA:58,0 +DA:59,0 +DA:61,0 +DA:62,0 +DA:63,0 +DA:64,0 +DA:66,0 +DA:67,0 +DA:68,0 +DA:69,0 +DA:71,0 +DA:72,0 +DA:73,0 +DA:75,0 +DA:76,0 +DA:77,0 +DA:78,0 +DA:79,0 +DA:80,0 +DA:81,0 +DA:82,0 +DA:84,0 +DA:85,0 +DA:86,0 +DA:87,0 +DA:88,0 +DA:89,0 +DA:90,0 +DA:92,0 +DA:93,0 +DA:94,0 +DA:95,0 +DA:97,0 +DA:98,0 +DA:99,0 +DA:101,0 +DA:102,0 +DA:105,0 +DA:106,0 +DA:107,0 +DA:109,0 +DA:110,0 +DA:111,0 +DA:112,0 +DA:113,0 +DA:114,0 +DA:115,0 +DA:117,0 +DA:119,0 +DA:128,0 +DA:129,0 +DA:130,0 +DA:134,0 +DA:136,0 +DA:137,0 +DA:147,0 +DA:148,0 +DA:150,0 +DA:151,0 +DA:154,0 +DA:156,0 +DA:157,0 +DA:160,0 +DA:162,0 +DA:163,0 +DA:166,0 +DA:168,0 +DA:169,0 +DA:175,0 +DA:176,0 +DA:180,0 +DA:181,0 +DA:182,0 +DA:183,0 +DA:184,0 +DA:185,0 +DA:186,0 +DA:187,0 +DA:193,0 +DA:194,0 +DA:195,0 +DA:201,0 +DA:202,0 +DA:203,0 +DA:204,0 +DA:205,0 +DA:206,0 +LF:129 +LH:0 +BRDA:1,0,0,0 +BRF:1 +BRH:0 +end_of_record +TN: +SF:src/routes/OpenClaw.svelte +FN:1,(empty-report) +FNF:1 +FNH:0 +FNDA:0,(empty-report) +DA:1,0 +DA:5,0 +DA:6,0 +DA:7,0 +DA:8,0 +DA:10,0 +DA:11,0 +DA:12,0 +DA:13,0 +DA:14,0 +DA:15,0 +DA:17,0 +DA:18,0 +DA:19,0 +DA:20,0 +DA:21,0 +DA:23,0 +DA:29,0 +DA:30,0 +DA:40,0 +DA:41,0 +DA:53,0 +LF:22 +LH:0 +BRDA:1,0,0,0 +BRF:1 +BRH:0 +end_of_record +TN: +SF:src/routes/Security.svelte +FN:1,(empty-report) +FNF:1 +FNH:0 +FNDA:0,(empty-report) +DA:1,0 +DA:5,0 +DA:6,0 +DA:7,0 +DA:8,0 +DA:9,0 +DA:10,0 +DA:11,0 +DA:12,0 +DA:14,0 +DA:16,0 +DA:17,0 +DA:18,0 +DA:19,0 +DA:20,0 +DA:22,0 +DA:23,0 +DA:24,0 +DA:25,0 +DA:26,0 +DA:27,0 +DA:28,0 +DA:30,0 +DA:31,0 +DA:32,0 +DA:33,0 +DA:34,0 +DA:35,0 +DA:36,0 +DA:37,0 +DA:39,0 +DA:40,0 +DA:41,0 +DA:42,0 +DA:43,0 +DA:44,0 +DA:45,0 +DA:46,0 +DA:48,0 +DA:49,0 +DA:50,0 +DA:52,0 +DA:54,0 +DA:55,0 +DA:56,0 +DA:57,0 +DA:58,0 +DA:59,0 +DA:60,0 +DA:62,0 +DA:63,0 +DA:64,0 +DA:65,0 +DA:67,0 +DA:68,0 +DA:69,0 +DA:70,0 +DA:72,0 +DA:73,0 +DA:74,0 +DA:75,0 +DA:76,0 +DA:77,0 +DA:78,0 +DA:79,0 +DA:81,0 +DA:82,0 +DA:85,0 +DA:86,0 +DA:89,0 +DA:90,0 +DA:92,0 +DA:94,0 +DA:95,0 +DA:96,0 +DA:97,0 +DA:98,0 +DA:99,0 +DA:100,0 +DA:101,0 +DA:102,0 +DA:103,0 +DA:104,0 +DA:105,0 +DA:106,0 +DA:107,0 +DA:109,0 +DA:110,0 +DA:111,0 +DA:112,0 +DA:113,0 +DA:114,0 +DA:117,0 +DA:118,0 +DA:119,0 +DA:120,0 +DA:121,0 +DA:122,0 +DA:124,0 +DA:126,0 +DA:127,0 +DA:129,0 +DA:130,0 +DA:135,0 +DA:136,0 +DA:137,0 +DA:139,0 +DA:141,0 +DA:143,0 +DA:145,0 +DA:147,0 +DA:153,0 +DA:154,0 +DA:156,0 +DA:157,0 +DA:158,0 +DA:159,0 +DA:160,0 +DA:161,0 +DA:162,0 +DA:175,0 +DA:176,0 +DA:178,0 +DA:179,0 +DA:180,0 +DA:181,0 +DA:182,0 +DA:183,0 +DA:185,0 +DA:193,0 +DA:194,0 +DA:199,0 +DA:200,0 +DA:206,0 +DA:207,0 +DA:208,0 +DA:209,0 +DA:214,0 +DA:215,0 +DA:217,0 +DA:219,0 +DA:221,0 +DA:225,0 +DA:227,0 +DA:229,0 +DA:230,0 +DA:234,0 +DA:236,0 +DA:238,0 +DA:239,0 +DA:245,0 +DA:246,0 +DA:249,0 +DA:250,0 +DA:256,0 +DA:257,0 +DA:260,0 +DA:261,0 +DA:262,0 +DA:263,0 +DA:264,0 +DA:265,0 +DA:266,0 +DA:267,0 +DA:269,0 +DA:270,0 +DA:272,0 +DA:273,0 +DA:275,0 +DA:279,0 +DA:280,0 +LF:171 +LH:0 +BRDA:1,0,0,0 +BRF:1 +BRH:0 +end_of_record +TN: +SF:src/routes/Settings.svelte +FN:1,(empty-report) +FNF:1 +FNH:0 +FNDA:0,(empty-report) +DA:1,0 +DA:6,0 +DA:7,0 +DA:8,0 +DA:9,0 +DA:10,0 +DA:11,0 +DA:13,0 +DA:14,0 +DA:15,0 +DA:16,0 +DA:18,0 +DA:19,0 +DA:20,0 +DA:21,0 +DA:22,0 +DA:23,0 +DA:25,0 +DA:26,0 +DA:27,0 +DA:28,0 +DA:29,0 +DA:31,0 +DA:32,0 +DA:33,0 +DA:34,0 +DA:35,0 +DA:36,0 +DA:38,0 +DA:39,0 +DA:40,0 +DA:41,0 +DA:42,0 +DA:43,0 +DA:44,0 +DA:45,0 +DA:46,0 +DA:47,0 +DA:48,0 +DA:49,0 +DA:50,0 +DA:51,0 +DA:52,0 +DA:53,0 +DA:54,0 +DA:55,0 +DA:56,0 +DA:57,0 +DA:58,0 +DA:59,0 +DA:60,0 +DA:61,0 +DA:62,0 +DA:63,0 +DA:64,0 +DA:65,0 +DA:66,0 +DA:67,0 +DA:68,0 +DA:71,0 +DA:72,0 +DA:73,0 +DA:74,0 +DA:75,0 +DA:76,0 +DA:77,0 +DA:78,0 +DA:79,0 +DA:80,0 +DA:81,0 +DA:82,0 +DA:84,0 +DA:85,0 +DA:86,0 +DA:87,0 +DA:88,0 +DA:89,0 +DA:90,0 +DA:91,0 +DA:92,0 +DA:93,0 +DA:94,0 +DA:95,0 +DA:96,0 +DA:98,0 +DA:99,0 +DA:100,0 +DA:101,0 +DA:102,0 +DA:103,0 +DA:104,0 +DA:105,0 +DA:106,0 +DA:107,0 +DA:108,0 +DA:109,0 +DA:110,0 +DA:112,0 +DA:113,0 +DA:114,0 +DA:115,0 +DA:116,0 +DA:117,0 +DA:118,0 +DA:119,0 +DA:120,0 +DA:121,0 +DA:122,0 +DA:123,0 +DA:124,0 +DA:125,0 +DA:126,0 +DA:128,0 +DA:129,0 +DA:130,0 +DA:132,0 +DA:133,0 +DA:135,0 +DA:137,0 +DA:138,0 +DA:139,0 +DA:140,0 +DA:141,0 +DA:142,0 +DA:143,0 +DA:144,0 +DA:147,0 +DA:148,0 +DA:149,0 +DA:150,0 +DA:151,0 +DA:152,0 +DA:153,0 +DA:154,0 +DA:155,0 +DA:156,0 +DA:157,0 +DA:158,0 +DA:160,0 +DA:161,0 +DA:162,0 +DA:163,0 +DA:164,0 +DA:165,0 +DA:166,0 +DA:168,0 +DA:169,0 +DA:170,0 +DA:171,0 +DA:172,0 +DA:173,0 +DA:175,0 +DA:176,0 +DA:177,0 +DA:178,0 +DA:179,0 +DA:180,0 +DA:182,0 +DA:183,0 +DA:184,0 +DA:185,0 +DA:186,0 +DA:187,0 +DA:188,0 +DA:189,0 +DA:191,0 +DA:192,0 +DA:193,0 +DA:194,0 +DA:195,0 +DA:196,0 +DA:198,0 +DA:199,0 +DA:200,0 +DA:201,0 +DA:202,0 +DA:203,0 +DA:204,0 +DA:205,0 +DA:206,0 +DA:208,0 +DA:209,0 +DA:210,0 +DA:211,0 +DA:212,0 +DA:213,0 +DA:214,0 +DA:215,0 +DA:216,0 +DA:218,0 +DA:219,0 +DA:220,0 +DA:221,0 +DA:223,0 +DA:224,0 +DA:225,0 +DA:226,0 +DA:227,0 +DA:228,0 +DA:229,0 +DA:230,0 +DA:231,0 +DA:232,0 +DA:233,0 +DA:234,0 +DA:235,0 +DA:236,0 +DA:238,0 +DA:239,0 +DA:240,0 +DA:241,0 +DA:242,0 +DA:243,0 +DA:244,0 +DA:245,0 +DA:246,0 +DA:247,0 +DA:248,0 +DA:250,0 +DA:251,0 +DA:252,0 +DA:253,0 +DA:254,0 +DA:255,0 +DA:256,0 +DA:257,0 +DA:258,0 +DA:259,0 +DA:260,0 +DA:261,0 +DA:262,0 +DA:263,0 +DA:264,0 +DA:265,0 +DA:267,0 +DA:268,0 +DA:269,0 +DA:270,0 +DA:271,0 +DA:273,0 +DA:279,0 +DA:282,0 +DA:283,0 +DA:285,0 +DA:286,0 +DA:289,0 +DA:290,0 +DA:292,0 +DA:294,0 +DA:296,0 +DA:298,0 +DA:300,0 +DA:302,0 +DA:303,0 +DA:308,0 +DA:311,0 +DA:312,0 +DA:314,0 +DA:315,0 +DA:318,0 +DA:319,0 +DA:320,0 +DA:321,0 +DA:322,0 +DA:323,0 +DA:324,0 +DA:325,0 +DA:328,0 +DA:333,0 +DA:336,0 +DA:337,0 +DA:340,0 +DA:341,0 +DA:348,0 +DA:349,0 +DA:350,0 +DA:352,0 +DA:353,0 +DA:357,0 +DA:358,0 +DA:360,0 +DA:361,0 +DA:365,0 +DA:367,0 +DA:368,0 +DA:369,0 +DA:370,0 +DA:371,0 +DA:373,0 +DA:374,0 +DA:375,0 +DA:376,0 +DA:380,0 +DA:381,0 +DA:382,0 +DA:383,0 +DA:386,0 +DA:387,0 +DA:388,0 +DA:392,0 +DA:393,0 +DA:394,0 +DA:395,0 +DA:396,0 +DA:398,0 +DA:400,0 +DA:403,0 +DA:411,0 +DA:412,0 +DA:414,0 +DA:415,0 +DA:419,0 +DA:420,0 +DA:421,0 +DA:422,0 +DA:423,0 +DA:424,0 +DA:425,0 +DA:426,0 +DA:427,0 +DA:430,0 +DA:431,0 +DA:432,0 +DA:436,0 +DA:437,0 +DA:438,0 +DA:439,0 +DA:441,0 +DA:442,0 +DA:443,0 +DA:449,0 +DA:450,0 +DA:453,0 +DA:454,0 +DA:455,0 +DA:456,0 +DA:457,0 +DA:458,0 +DA:461,0 +DA:462,0 +DA:463,0 +DA:471,0 +DA:472,0 +DA:474,0 +DA:475,0 +DA:478,0 +DA:479,0 +DA:480,0 +DA:481,0 +DA:482,0 +DA:483,0 +DA:484,0 +DA:489,0 +DA:490,0 +DA:491,0 +DA:492,0 +DA:493,0 +DA:494,0 +DA:495,0 +DA:496,0 +DA:500,0 +DA:501,0 +LF:362 +LH:0 +BRDA:1,0,0,0 +BRF:1 +BRH:0 +end_of_record +TN: +SF:src/routes/Terminal.svelte +FN:1,(empty-report) +FNF:1 +FNH:0 +FNDA:0,(empty-report) +DA:1,0 +DA:6,0 +DA:7,0 +DA:9,0 +DA:10,0 +DA:11,0 +DA:12,0 +DA:13,0 +DA:14,0 +DA:17,0 +DA:18,0 +DA:19,0 +DA:20,0 +DA:21,0 +DA:22,0 +DA:25,0 +DA:26,0 +DA:27,0 +DA:28,0 +DA:29,0 +DA:30,0 +DA:33,0 +DA:34,0 +DA:35,0 +DA:36,0 +DA:37,0 +DA:38,0 +DA:39,0 +DA:40,0 +DA:41,0 +DA:43,0 +DA:44,0 +DA:45,0 +DA:47,0 +DA:48,0 +DA:49,0 +DA:51,0 +DA:52,0 +DA:53,0 +DA:54,0 +DA:55,0 +DA:56,0 +DA:58,0 +DA:59,0 +DA:60,0 +DA:61,0 +DA:62,0 +DA:64,0 +DA:65,0 +DA:66,0 +DA:67,0 +DA:68,0 +DA:69,0 +DA:71,0 +DA:72,0 +DA:73,0 +DA:74,0 +DA:76,0 +DA:77,0 +DA:78,0 +DA:79,0 +DA:80,0 +DA:81,0 +DA:82,0 +DA:83,0 +DA:84,0 +DA:86,0 +DA:87,0 +DA:88,0 +DA:89,0 +DA:90,0 +DA:91,0 +DA:93,0 +DA:94,0 +DA:95,0 +DA:96,0 +DA:97,0 +DA:98,0 +DA:99,0 +DA:101,0 +DA:102,0 +DA:103,0 +DA:104,0 +DA:105,0 +DA:106,0 +DA:107,0 +DA:108,0 +DA:109,0 +DA:110,0 +DA:112,0 +DA:113,0 +DA:114,0 +DA:116,0 +DA:117,0 +DA:118,0 +DA:119,0 +DA:120,0 +DA:122,0 +DA:123,0 +DA:124,0 +DA:125,0 +DA:126,0 +DA:127,0 +DA:129,0 +DA:130,0 +DA:131,0 +DA:133,0 +DA:134,0 +DA:135,0 +DA:137,0 +DA:138,0 +DA:139,0 +DA:140,0 +DA:141,0 +DA:142,0 +DA:143,0 +DA:144,0 +DA:145,0 +DA:146,0 +DA:147,0 +DA:149,0 +DA:150,0 +DA:151,0 +DA:153,0 +DA:154,0 +DA:155,0 +DA:156,0 +DA:157,0 +DA:158,0 +DA:159,0 +DA:160,0 +DA:161,0 +DA:162,0 +DA:163,0 +DA:164,0 +DA:165,0 +DA:166,0 +DA:167,0 +DA:168,0 +DA:169,0 +DA:170,0 +DA:172,0 +DA:173,0 +DA:174,0 +DA:176,0 +DA:177,0 +DA:178,0 +DA:179,0 +DA:180,0 +DA:181,0 +DA:182,0 +DA:183,0 +DA:184,0 +DA:185,0 +DA:186,0 +DA:188,0 +DA:189,0 +DA:190,0 +DA:191,0 +DA:193,0 +DA:194,0 +DA:195,0 +DA:196,0 +DA:197,0 +DA:198,0 +DA:199,0 +DA:200,0 +DA:201,0 +DA:202,0 +DA:203,0 +DA:204,0 +DA:205,0 +DA:207,0 +DA:208,0 +DA:209,0 +DA:210,0 +DA:211,0 +DA:212,0 +DA:213,0 +DA:214,0 +DA:215,0 +DA:216,0 +DA:218,0 +DA:219,0 +DA:220,0 +DA:221,0 +DA:222,0 +DA:223,0 +DA:224,0 +DA:225,0 +DA:226,0 +DA:227,0 +DA:228,0 +DA:230,0 +DA:231,0 +DA:232,0 +DA:233,0 +DA:234,0 +DA:235,0 +DA:236,0 +DA:237,0 +DA:238,0 +DA:239,0 +DA:241,0 +DA:242,0 +DA:243,0 +DA:244,0 +DA:245,0 +DA:246,0 +DA:247,0 +DA:248,0 +DA:249,0 +DA:250,0 +DA:252,0 +DA:253,0 +DA:254,0 +DA:255,0 +DA:256,0 +DA:257,0 +DA:258,0 +DA:260,0 +DA:261,0 +DA:262,0 +DA:263,0 +DA:264,0 +DA:265,0 +DA:266,0 +DA:267,0 +DA:268,0 +DA:270,0 +DA:271,0 +DA:273,0 +DA:274,0 +DA:275,0 +DA:276,0 +DA:277,0 +DA:278,0 +DA:279,0 +DA:281,0 +DA:282,0 +DA:284,0 +DA:286,0 +DA:287,0 +DA:288,0 +DA:289,0 +DA:290,0 +DA:291,0 +DA:292,0 +DA:294,0 +DA:295,0 +DA:296,0 +DA:297,0 +DA:298,0 +DA:300,0 +DA:302,0 +DA:303,0 +DA:304,0 +DA:305,0 +DA:306,0 +DA:307,0 +DA:308,0 +DA:309,0 +DA:310,0 +DA:311,0 +DA:312,0 +DA:313,0 +DA:314,0 +DA:315,0 +DA:316,0 +DA:317,0 +DA:318,0 +DA:319,0 +DA:321,0 +DA:323,0 +DA:324,0 +DA:325,0 +DA:326,0 +DA:328,0 +DA:330,0 +DA:331,0 +DA:332,0 +DA:333,0 +DA:334,0 +DA:335,0 +DA:336,0 +DA:337,0 +DA:338,0 +DA:339,0 +DA:341,0 +DA:342,0 +DA:343,0 +DA:344,0 +DA:345,0 +DA:346,0 +DA:347,0 +DA:350,0 +DA:351,0 +DA:354,0 +DA:355,0 +DA:357,0 +DA:360,0 +DA:361,0 +DA:362,0 +DA:363,0 +DA:364,0 +DA:365,0 +DA:366,0 +DA:367,0 +DA:368,0 +DA:369,0 +DA:371,0 +DA:372,0 +DA:374,0 +DA:375,0 +DA:376,0 +DA:377,0 +DA:378,0 +DA:379,0 +DA:380,0 +DA:381,0 +DA:383,0 +DA:386,0 +DA:387,0 +DA:388,0 +DA:389,0 +DA:390,0 +DA:391,0 +DA:392,0 +DA:393,0 +DA:394,0 +DA:395,0 +DA:396,0 +DA:397,0 +DA:398,0 +DA:400,0 +DA:401,0 +DA:402,0 +DA:403,0 +DA:404,0 +DA:405,0 +DA:406,0 +DA:407,0 +DA:408,0 +DA:409,0 +DA:411,0 +DA:412,0 +DA:413,0 +DA:414,0 +DA:415,0 +DA:416,0 +DA:417,0 +DA:418,0 +DA:419,0 +DA:421,0 +DA:422,0 +DA:423,0 +DA:425,0 +DA:426,0 +DA:427,0 +DA:428,0 +DA:429,0 +DA:430,0 +DA:431,0 +DA:432,0 +DA:433,0 +DA:435,0 +DA:436,0 +DA:437,0 +DA:438,0 +DA:439,0 +DA:440,0 +DA:441,0 +DA:442,0 +DA:443,0 +DA:444,0 +DA:446,0 +DA:447,0 +DA:448,0 +DA:450,0 +DA:451,0 +DA:452,0 +DA:453,0 +DA:454,0 +DA:455,0 +DA:456,0 +DA:457,0 +DA:458,0 +DA:459,0 +DA:460,0 +DA:462,0 +DA:464,0 +DA:465,0 +DA:466,0 +DA:467,0 +DA:468,0 +DA:469,0 +DA:470,0 +DA:471,0 +DA:472,0 +DA:473,0 +DA:474,0 +DA:475,0 +DA:476,0 +DA:477,0 +DA:478,0 +DA:479,0 +DA:480,0 +DA:481,0 +DA:483,0 +DA:484,0 +DA:485,0 +DA:486,0 +DA:487,0 +DA:488,0 +DA:490,0 +DA:491,0 +DA:492,0 +DA:493,0 +DA:494,0 +DA:497,0 +DA:498,0 +DA:499,0 +DA:500,0 +DA:501,0 +DA:503,0 +DA:504,0 +DA:505,0 +DA:506,0 +DA:507,0 +DA:508,0 +DA:509,0 +DA:510,0 +DA:511,0 +DA:512,0 +DA:513,0 +DA:514,0 +DA:515,0 +DA:516,0 +DA:517,0 +DA:518,0 +DA:519,0 +DA:520,0 +DA:521,0 +DA:525,0 +DA:527,0 +DA:528,0 +DA:529,0 +DA:533,0 +DA:534,0 +DA:535,0 +DA:539,0 +DA:540,0 +DA:541,0 +DA:545,0 +DA:546,0 +DA:547,0 +DA:551,0 +DA:552,0 +DA:553,0 +DA:554,0 +DA:555,0 +DA:557,0 +DA:558,0 +DA:559,0 +DA:560,0 +DA:562,0 +DA:564,0 +DA:565,0 +DA:566,0 +DA:568,0 +DA:573,0 +DA:574,0 +DA:575,0 +DA:577,0 +DA:581,0 +DA:582,0 +DA:586,0 +DA:587,0 +DA:589,0 +DA:591,0 +DA:592,0 +DA:593,0 +DA:594,0 +DA:596,0 +DA:598,0 +DA:599,0 +DA:600,0 +DA:607,0 +DA:608,0 +DA:609,0 +DA:611,0 +DA:612,0 +DA:614,0 +DA:615,0 +DA:617,0 +DA:620,0 +DA:621,0 +DA:622,0 +DA:623,0 +DA:624,0 +DA:625,0 +DA:626,0 +DA:639,0 +DA:640,0 +DA:641,0 +DA:645,0 +DA:646,0 +DA:651,0 +DA:652,0 +DA:653,0 +DA:654,0 +DA:656,0 +DA:661,0 +DA:662,0 +DA:663,0 +DA:665,0 +DA:667,0 +DA:668,0 +DA:670,0 +DA:672,0 +DA:673,0 +DA:676,0 +DA:678,0 +DA:679,0 +DA:680,0 +DA:682,0 +DA:683,0 +DA:684,0 +DA:686,0 +DA:691,0 +DA:692,0 +DA:693,0 +DA:695,0 +DA:696,0 +DA:697,0 +DA:698,0 +DA:702,0 +DA:703,0 +DA:705,0 +DA:706,0 +DA:711,0 +DA:715,0 +DA:716,0 +DA:717,0 +DA:719,0 +LF:545 +LH:0 +BRDA:1,0,0,0 +BRF:1 +BRH:0 +end_of_record +TN: +SF:src/routes/Transmission.svelte +FN:1,(empty-report) +FNF:1 +FNH:0 +FNDA:0,(empty-report) +DA:1,0 +DA:5,0 +DA:6,0 +DA:7,0 +DA:8,0 +DA:9,0 +DA:10,0 +DA:12,0 +DA:13,0 +DA:14,0 +DA:15,0 +DA:16,0 +DA:17,0 +DA:18,0 +DA:21,0 +DA:22,0 +DA:23,0 +DA:24,0 +DA:25,0 +DA:26,0 +DA:27,0 +DA:28,0 +DA:29,0 +DA:30,0 +DA:32,0 +DA:33,0 +DA:34,0 +DA:35,0 +DA:36,0 +DA:37,0 +DA:38,0 +DA:39,0 +DA:41,0 +DA:42,0 +DA:43,0 +DA:44,0 +DA:45,0 +DA:46,0 +DA:47,0 +DA:48,0 +DA:50,0 +DA:51,0 +DA:52,0 +DA:53,0 +DA:54,0 +DA:55,0 +DA:56,0 +DA:57,0 +DA:59,0 +DA:60,0 +DA:61,0 +DA:62,0 +DA:63,0 +DA:64,0 +DA:66,0 +DA:67,0 +DA:68,0 +DA:70,0 +DA:71,0 +DA:72,0 +DA:73,0 +DA:74,0 +DA:75,0 +DA:76,0 +DA:77,0 +DA:78,0 +DA:80,0 +DA:81,0 +DA:83,0 +DA:84,0 +DA:85,0 +DA:86,0 +DA:88,0 +DA:94,0 +DA:95,0 +DA:98,0 +DA:100,0 +DA:101,0 +DA:102,0 +DA:104,0 +DA:105,0 +DA:108,0 +DA:109,0 +DA:117,0 +DA:118,0 +DA:119,0 +DA:121,0 +DA:122,0 +DA:134,0 +DA:135,0 +DA:136,0 +DA:138,0 +DA:139,0 +DA:151,0 +DA:152,0 +DA:153,0 +DA:155,0 +DA:156,0 +DA:169,0 +DA:170,0 +DA:171,0 +DA:172,0 +DA:173,0 +DA:175,0 +DA:177,0 +DA:178,0 +DA:179,0 +DA:181,0 +DA:187,0 +DA:188,0 +DA:189,0 +DA:190,0 +DA:203,0 +DA:204,0 +DA:205,0 +DA:206,0 +DA:207,0 +DA:208,0 +DA:209,0 +DA:212,0 +DA:213,0 +DA:214,0 +DA:215,0 +DA:216,0 +DA:217,0 +DA:218,0 +DA:225,0 +DA:228,0 +DA:229,0 +DA:230,0 +DA:243,0 +DA:244,0 +DA:245,0 +DA:246,0 +DA:256,0 +DA:257,0 +DA:258,0 +DA:259,0 +DA:260,0 +DA:261,0 +DA:262,0 +DA:263,0 +DA:264,0 +DA:268,0 +DA:273,0 +LF:145 +LH:0 +BRDA:1,0,0,0 +BRF:1 +BRH:0 +end_of_record diff --git a/dashboard/frontend/coverage/prettify.css b/dashboard/frontend/coverage/prettify.css new file mode 100644 index 0000000..b317a7c --- /dev/null +++ b/dashboard/frontend/coverage/prettify.css @@ -0,0 +1 @@ +.pln{color:#000}@media screen{.str{color:#080}.kwd{color:#008}.com{color:#800}.typ{color:#606}.lit{color:#066}.pun,.opn,.clo{color:#660}.tag{color:#008}.atn{color:#606}.atv{color:#080}.dec,.var{color:#606}.fun{color:red}}@media print,projection{.str{color:#060}.kwd{color:#006;font-weight:bold}.com{color:#600;font-style:italic}.typ{color:#404;font-weight:bold}.lit{color:#044}.pun,.opn,.clo{color:#440}.tag{color:#006;font-weight:bold}.atn{color:#404}.atv{color:#060}}pre.prettyprint{padding:2px;border:1px solid #888}ol.linenums{margin-top:0;margin-bottom:0}li.L0,li.L1,li.L2,li.L3,li.L5,li.L6,li.L7,li.L8{list-style-type:none}li.L1,li.L3,li.L5,li.L7,li.L9{background:#eee} diff --git a/dashboard/frontend/coverage/prettify.js b/dashboard/frontend/coverage/prettify.js new file mode 100644 index 0000000..b322523 --- /dev/null +++ b/dashboard/frontend/coverage/prettify.js @@ -0,0 +1,2 @@ +/* eslint-disable */ +window.PR_SHOULD_USE_CONTINUATION=true;(function(){var h=["break,continue,do,else,for,if,return,while"];var u=[h,"auto,case,char,const,default,double,enum,extern,float,goto,int,long,register,short,signed,sizeof,static,struct,switch,typedef,union,unsigned,void,volatile"];var p=[u,"catch,class,delete,false,import,new,operator,private,protected,public,this,throw,true,try,typeof"];var l=[p,"alignof,align_union,asm,axiom,bool,concept,concept_map,const_cast,constexpr,decltype,dynamic_cast,explicit,export,friend,inline,late_check,mutable,namespace,nullptr,reinterpret_cast,static_assert,static_cast,template,typeid,typename,using,virtual,where"];var x=[p,"abstract,boolean,byte,extends,final,finally,implements,import,instanceof,null,native,package,strictfp,super,synchronized,throws,transient"];var R=[x,"as,base,by,checked,decimal,delegate,descending,dynamic,event,fixed,foreach,from,group,implicit,in,interface,internal,into,is,lock,object,out,override,orderby,params,partial,readonly,ref,sbyte,sealed,stackalloc,string,select,uint,ulong,unchecked,unsafe,ushort,var"];var r="all,and,by,catch,class,else,extends,false,finally,for,if,in,is,isnt,loop,new,no,not,null,of,off,on,or,return,super,then,true,try,unless,until,when,while,yes";var w=[p,"debugger,eval,export,function,get,null,set,undefined,var,with,Infinity,NaN"];var s="caller,delete,die,do,dump,elsif,eval,exit,foreach,for,goto,if,import,last,local,my,next,no,our,print,package,redo,require,sub,undef,unless,until,use,wantarray,while,BEGIN,END";var I=[h,"and,as,assert,class,def,del,elif,except,exec,finally,from,global,import,in,is,lambda,nonlocal,not,or,pass,print,raise,try,with,yield,False,True,None"];var f=[h,"alias,and,begin,case,class,def,defined,elsif,end,ensure,false,in,module,next,nil,not,or,redo,rescue,retry,self,super,then,true,undef,unless,until,when,yield,BEGIN,END"];var H=[h,"case,done,elif,esac,eval,fi,function,in,local,set,then,until"];var A=[l,R,w,s+I,f,H];var e=/^(DIR|FILE|vector|(de|priority_)?queue|list|stack|(const_)?iterator|(multi)?(set|map)|bitset|u?(int|float)\d*)/;var C="str";var z="kwd";var j="com";var O="typ";var G="lit";var L="pun";var F="pln";var m="tag";var E="dec";var J="src";var P="atn";var n="atv";var N="nocode";var M="(?:^^\\.?|[+-]|\\!|\\!=|\\!==|\\#|\\%|\\%=|&|&&|&&=|&=|\\(|\\*|\\*=|\\+=|\\,|\\-=|\\->|\\/|\\/=|:|::|\\;|<|<<|<<=|<=|=|==|===|>|>=|>>|>>=|>>>|>>>=|\\?|\\@|\\[|\\^|\\^=|\\^\\^|\\^\\^=|\\{|\\||\\|=|\\|\\||\\|\\|=|\\~|break|case|continue|delete|do|else|finally|instanceof|return|throw|try|typeof)\\s*";function k(Z){var ad=0;var S=false;var ac=false;for(var V=0,U=Z.length;V122)){if(!(al<65||ag>90)){af.push([Math.max(65,ag)|32,Math.min(al,90)|32])}if(!(al<97||ag>122)){af.push([Math.max(97,ag)&~32,Math.min(al,122)&~32])}}}}af.sort(function(av,au){return(av[0]-au[0])||(au[1]-av[1])});var ai=[];var ap=[NaN,NaN];for(var ar=0;arat[0]){if(at[1]+1>at[0]){an.push("-")}an.push(T(at[1]))}}an.push("]");return an.join("")}function W(al){var aj=al.source.match(new RegExp("(?:\\[(?:[^\\x5C\\x5D]|\\\\[\\s\\S])*\\]|\\\\u[A-Fa-f0-9]{4}|\\\\x[A-Fa-f0-9]{2}|\\\\[0-9]+|\\\\[^ux0-9]|\\(\\?[:!=]|[\\(\\)\\^]|[^\\x5B\\x5C\\(\\)\\^]+)","g"));var ah=aj.length;var an=[];for(var ak=0,am=0;ak=2&&ai==="["){aj[ak]=X(ag)}else{if(ai!=="\\"){aj[ak]=ag.replace(/[a-zA-Z]/g,function(ao){var ap=ao.charCodeAt(0);return"["+String.fromCharCode(ap&~32,ap|32)+"]"})}}}}return aj.join("")}var aa=[];for(var V=0,U=Z.length;V=0;){S[ac.charAt(ae)]=Y}}var af=Y[1];var aa=""+af;if(!ag.hasOwnProperty(aa)){ah.push(af);ag[aa]=null}}ah.push(/[\0-\uffff]/);V=k(ah)})();var X=T.length;var W=function(ah){var Z=ah.sourceCode,Y=ah.basePos;var ad=[Y,F];var af=0;var an=Z.match(V)||[];var aj={};for(var ae=0,aq=an.length;ae=5&&"lang-"===ap.substring(0,5);if(am&&!(ai&&typeof ai[1]==="string")){am=false;ap=J}if(!am){aj[ag]=ap}}var ab=af;af+=ag.length;if(!am){ad.push(Y+ab,ap)}else{var al=ai[1];var ak=ag.indexOf(al);var ac=ak+al.length;if(ai[2]){ac=ag.length-ai[2].length;ak=ac-al.length}var ar=ap.substring(5);B(Y+ab,ag.substring(0,ak),W,ad);B(Y+ab+ak,al,q(ar,al),ad);B(Y+ab+ac,ag.substring(ac),W,ad)}}ah.decorations=ad};return W}function i(T){var W=[],S=[];if(T.tripleQuotedStrings){W.push([C,/^(?:\'\'\'(?:[^\'\\]|\\[\s\S]|\'{1,2}(?=[^\']))*(?:\'\'\'|$)|\"\"\"(?:[^\"\\]|\\[\s\S]|\"{1,2}(?=[^\"]))*(?:\"\"\"|$)|\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$))/,null,"'\""])}else{if(T.multiLineStrings){W.push([C,/^(?:\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$)|\`(?:[^\\\`]|\\[\s\S])*(?:\`|$))/,null,"'\"`"])}else{W.push([C,/^(?:\'(?:[^\\\'\r\n]|\\.)*(?:\'|$)|\"(?:[^\\\"\r\n]|\\.)*(?:\"|$))/,null,"\"'"])}}if(T.verbatimStrings){S.push([C,/^@\"(?:[^\"]|\"\")*(?:\"|$)/,null])}var Y=T.hashComments;if(Y){if(T.cStyleComments){if(Y>1){W.push([j,/^#(?:##(?:[^#]|#(?!##))*(?:###|$)|.*)/,null,"#"])}else{W.push([j,/^#(?:(?:define|elif|else|endif|error|ifdef|include|ifndef|line|pragma|undef|warning)\b|[^\r\n]*)/,null,"#"])}S.push([C,/^<(?:(?:(?:\.\.\/)*|\/?)(?:[\w-]+(?:\/[\w-]+)+)?[\w-]+\.h|[a-z]\w*)>/,null])}else{W.push([j,/^#[^\r\n]*/,null,"#"])}}if(T.cStyleComments){S.push([j,/^\/\/[^\r\n]*/,null]);S.push([j,/^\/\*[\s\S]*?(?:\*\/|$)/,null])}if(T.regexLiterals){var X=("/(?=[^/*])(?:[^/\\x5B\\x5C]|\\x5C[\\s\\S]|\\x5B(?:[^\\x5C\\x5D]|\\x5C[\\s\\S])*(?:\\x5D|$))+/");S.push(["lang-regex",new RegExp("^"+M+"("+X+")")])}var V=T.types;if(V){S.push([O,V])}var U=(""+T.keywords).replace(/^ | $/g,"");if(U.length){S.push([z,new RegExp("^(?:"+U.replace(/[\s,]+/g,"|")+")\\b"),null])}W.push([F,/^\s+/,null," \r\n\t\xA0"]);S.push([G,/^@[a-z_$][a-z_$@0-9]*/i,null],[O,/^(?:[@_]?[A-Z]+[a-z][A-Za-z_$@0-9]*|\w+_t\b)/,null],[F,/^[a-z_$][a-z_$@0-9]*/i,null],[G,new RegExp("^(?:0x[a-f0-9]+|(?:\\d(?:_\\d+)*\\d*(?:\\.\\d*)?|\\.\\d\\+)(?:e[+\\-]?\\d+)?)[a-z]*","i"),null,"0123456789"],[F,/^\\[\s\S]?/,null],[L,/^.[^\s\w\.$@\'\"\`\/\#\\]*/,null]);return g(W,S)}var K=i({keywords:A,hashComments:true,cStyleComments:true,multiLineStrings:true,regexLiterals:true});function Q(V,ag){var U=/(?:^|\s)nocode(?:\s|$)/;var ab=/\r\n?|\n/;var ac=V.ownerDocument;var S;if(V.currentStyle){S=V.currentStyle.whiteSpace}else{if(window.getComputedStyle){S=ac.defaultView.getComputedStyle(V,null).getPropertyValue("white-space")}}var Z=S&&"pre"===S.substring(0,3);var af=ac.createElement("LI");while(V.firstChild){af.appendChild(V.firstChild)}var W=[af];function ae(al){switch(al.nodeType){case 1:if(U.test(al.className)){break}if("BR"===al.nodeName){ad(al);if(al.parentNode){al.parentNode.removeChild(al)}}else{for(var an=al.firstChild;an;an=an.nextSibling){ae(an)}}break;case 3:case 4:if(Z){var am=al.nodeValue;var aj=am.match(ab);if(aj){var ai=am.substring(0,aj.index);al.nodeValue=ai;var ah=am.substring(aj.index+aj[0].length);if(ah){var ak=al.parentNode;ak.insertBefore(ac.createTextNode(ah),al.nextSibling)}ad(al);if(!ai){al.parentNode.removeChild(al)}}}break}}function ad(ak){while(!ak.nextSibling){ak=ak.parentNode;if(!ak){return}}function ai(al,ar){var aq=ar?al.cloneNode(false):al;var ao=al.parentNode;if(ao){var ap=ai(ao,1);var an=al.nextSibling;ap.appendChild(aq);for(var am=an;am;am=an){an=am.nextSibling;ap.appendChild(am)}}return aq}var ah=ai(ak.nextSibling,0);for(var aj;(aj=ah.parentNode)&&aj.nodeType===1;){ah=aj}W.push(ah)}for(var Y=0;Y=S){ah+=2}if(V>=ap){Z+=2}}}var t={};function c(U,V){for(var S=V.length;--S>=0;){var T=V[S];if(!t.hasOwnProperty(T)){t[T]=U}else{if(window.console){console.warn("cannot override language handler %s",T)}}}}function q(T,S){if(!(T&&t.hasOwnProperty(T))){T=/^\s*]*(?:>|$)/],[j,/^<\!--[\s\S]*?(?:-\->|$)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],[L,/^(?:<[%?]|[%?]>)/],["lang-",/^]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-js",/^]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i]]),["default-markup","htm","html","mxml","xhtml","xml","xsl"]);c(g([[F,/^[\s]+/,null," \t\r\n"],[n,/^(?:\"[^\"]*\"?|\'[^\']*\'?)/,null,"\"'"]],[[m,/^^<\/?[a-z](?:[\w.:-]*\w)?|\/?>$/i],[P,/^(?!style[\s=]|on)[a-z](?:[\w:-]*\w)?/i],["lang-uq.val",/^=\s*([^>\'\"\s]*(?:[^>\'\"\s\/]|\/(?=\s)))/],[L,/^[=<>\/]+/],["lang-js",/^on\w+\s*=\s*\"([^\"]+)\"/i],["lang-js",/^on\w+\s*=\s*\'([^\']+)\'/i],["lang-js",/^on\w+\s*=\s*([^\"\'>\s]+)/i],["lang-css",/^style\s*=\s*\"([^\"]+)\"/i],["lang-css",/^style\s*=\s*\'([^\']+)\'/i],["lang-css",/^style\s*=\s*([^\"\'>\s]+)/i]]),["in.tag"]);c(g([],[[n,/^[\s\S]+/]]),["uq.val"]);c(i({keywords:l,hashComments:true,cStyleComments:true,types:e}),["c","cc","cpp","cxx","cyc","m"]);c(i({keywords:"null,true,false"}),["json"]);c(i({keywords:R,hashComments:true,cStyleComments:true,verbatimStrings:true,types:e}),["cs"]);c(i({keywords:x,cStyleComments:true}),["java"]);c(i({keywords:H,hashComments:true,multiLineStrings:true}),["bsh","csh","sh"]);c(i({keywords:I,hashComments:true,multiLineStrings:true,tripleQuotedStrings:true}),["cv","py"]);c(i({keywords:s,hashComments:true,multiLineStrings:true,regexLiterals:true}),["perl","pl","pm"]);c(i({keywords:f,hashComments:true,multiLineStrings:true,regexLiterals:true}),["rb"]);c(i({keywords:w,cStyleComments:true,regexLiterals:true}),["js"]);c(i({keywords:r,hashComments:3,cStyleComments:true,multilineStrings:true,tripleQuotedStrings:true,regexLiterals:true}),["coffee"]);c(g([],[[C,/^[\s\S]+/]]),["regex"]);function d(V){var U=V.langExtension;try{var S=a(V.sourceNode);var T=S.sourceCode;V.sourceCode=T;V.spans=S.spans;V.basePos=0;q(U,T)(V);D(V)}catch(W){if("console" in window){console.log(W&&W.stack?W.stack:W)}}}function y(W,V,U){var S=document.createElement("PRE");S.innerHTML=W;if(U){Q(S,U)}var T={langExtension:V,numberLines:U,sourceNode:S};d(T);return S.innerHTML}function b(ad){function Y(af){return document.getElementsByTagName(af)}var ac=[Y("pre"),Y("code"),Y("xmp")];var T=[];for(var aa=0;aa=0){var ah=ai.match(ab);var am;if(!ah&&(am=o(aj))&&"CODE"===am.tagName){ah=am.className.match(ab)}if(ah){ah=ah[1]}var al=false;for(var ak=aj.parentNode;ak;ak=ak.parentNode){if((ak.tagName==="pre"||ak.tagName==="code"||ak.tagName==="xmp")&&ak.className&&ak.className.indexOf("prettyprint")>=0){al=true;break}}if(!al){var af=aj.className.match(/\blinenums\b(?::(\d+))?/);af=af?af[1]&&af[1].length?+af[1]:true:false;if(af){Q(aj,af)}S={langExtension:ah,sourceNode:aj,numberLines:af};d(S)}}}if(X]*(?:>|$)/],[PR.PR_COMMENT,/^<\!--[\s\S]*?(?:-\->|$)/],[PR.PR_PUNCTUATION,/^(?:<[%?]|[%?]>)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],["lang-",/^]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-handlebars",/^]*type\s*=\s*['"]?text\/x-handlebars-template['"]?\b[^>]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-js",/^]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i],[PR.PR_DECLARATION,/^{{[#^>/]?\s*[\w.][^}]*}}/],[PR.PR_DECLARATION,/^{{&?\s*[\w.][^}]*}}/],[PR.PR_DECLARATION,/^{{{>?\s*[\w.][^}]*}}}/],[PR.PR_COMMENT,/^{{![^}]*}}/]]),["handlebars","hbs"]);PR.registerLangHandler(PR.createSimpleLexer([[PR.PR_PLAIN,/^[ \t\r\n\f]+/,null," \t\r\n\f"]],[[PR.PR_STRING,/^\"(?:[^\n\r\f\\\"]|\\(?:\r\n?|\n|\f)|\\[\s\S])*\"/,null],[PR.PR_STRING,/^\'(?:[^\n\r\f\\\']|\\(?:\r\n?|\n|\f)|\\[\s\S])*\'/,null],["lang-css-str",/^url\(([^\)\"\']*)\)/i],[PR.PR_KEYWORD,/^(?:url|rgb|\!important|@import|@page|@media|@charset|inherit)(?=[^\-\w]|$)/i,null],["lang-css-kw",/^(-?(?:[_a-z]|(?:\\[0-9a-f]+ ?))(?:[_a-z0-9\-]|\\(?:\\[0-9a-f]+ ?))*)\s*:/i],[PR.PR_COMMENT,/^\/\*[^*]*\*+(?:[^\/*][^*]*\*+)*\//],[PR.PR_COMMENT,/^(?:)/],[PR.PR_LITERAL,/^(?:\d+|\d*\.\d+)(?:%|[a-z]+)?/i],[PR.PR_LITERAL,/^#(?:[0-9a-f]{3}){1,2}/i],[PR.PR_PLAIN,/^-?(?:[_a-z]|(?:\\[\da-f]+ ?))(?:[_a-z\d\-]|\\(?:\\[\da-f]+ ?))*/i],[PR.PR_PUNCTUATION,/^[^\s\w\'\"]+/]]),["css"]);PR.registerLangHandler(PR.createSimpleLexer([],[[PR.PR_KEYWORD,/^-?(?:[_a-z]|(?:\\[\da-f]+ ?))(?:[_a-z\d\-]|\\(?:\\[\da-f]+ ?))*/i]]),["css-kw"]);PR.registerLangHandler(PR.createSimpleLexer([],[[PR.PR_STRING,/^[^\)\"\']+/]]),["css-str"]); diff --git a/dashboard/frontend/coverage/sort-arrow-sprite.png b/dashboard/frontend/coverage/sort-arrow-sprite.png new file mode 100644 index 0000000000000000000000000000000000000000..6ed68316eb3f65dec9063332d2f69bf3093bbfab GIT binary patch literal 138 zcmeAS@N?(olHy`uVBq!ia0vp^>_9Bd!3HEZxJ@+%Qh}Z>jv*C{$p!i!8j}?a+@3A= zIAGwzjijN=FBi!|L1t?LM;Q;gkwn>2cAy-KV{dn nf0J1DIvEHQu*n~6U}x}qyky7vi4|9XhBJ7&`njxgN@xNA8m%nc literal 0 HcmV?d00001 diff --git a/dashboard/frontend/coverage/sorter.js b/dashboard/frontend/coverage/sorter.js new file mode 100644 index 0000000..4ed70ae --- /dev/null +++ b/dashboard/frontend/coverage/sorter.js @@ -0,0 +1,210 @@ +/* eslint-disable */ +var addSorting = (function() { + 'use strict'; + var cols, + currentSort = { + index: 0, + desc: false + }; + + // returns the summary table element + function getTable() { + return document.querySelector('.coverage-summary'); + } + // returns the thead element of the summary table + function getTableHeader() { + return getTable().querySelector('thead tr'); + } + // returns the tbody element of the summary table + function getTableBody() { + return getTable().querySelector('tbody'); + } + // returns the th element for nth column + function getNthColumn(n) { + return getTableHeader().querySelectorAll('th')[n]; + } + + function onFilterInput() { + const searchValue = document.getElementById('fileSearch').value; + const rows = document.getElementsByTagName('tbody')[0].children; + + // Try to create a RegExp from the searchValue. If it fails (invalid regex), + // it will be treated as a plain text search + let searchRegex; + try { + searchRegex = new RegExp(searchValue, 'i'); // 'i' for case-insensitive + } catch (error) { + searchRegex = null; + } + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + let isMatch = false; + + if (searchRegex) { + // If a valid regex was created, use it for matching + isMatch = searchRegex.test(row.textContent); + } else { + // Otherwise, fall back to the original plain text search + isMatch = row.textContent + .toLowerCase() + .includes(searchValue.toLowerCase()); + } + + row.style.display = isMatch ? '' : 'none'; + } + } + + // loads the search box + function addSearchBox() { + var template = document.getElementById('filterTemplate'); + var templateClone = template.content.cloneNode(true); + templateClone.getElementById('fileSearch').oninput = onFilterInput; + template.parentElement.appendChild(templateClone); + } + + // loads all columns + function loadColumns() { + var colNodes = getTableHeader().querySelectorAll('th'), + colNode, + cols = [], + col, + i; + + for (i = 0; i < colNodes.length; i += 1) { + colNode = colNodes[i]; + col = { + key: colNode.getAttribute('data-col'), + sortable: !colNode.getAttribute('data-nosort'), + type: colNode.getAttribute('data-type') || 'string' + }; + cols.push(col); + if (col.sortable) { + col.defaultDescSort = col.type === 'number'; + colNode.innerHTML = + colNode.innerHTML + ''; + } + } + return cols; + } + // attaches a data attribute to every tr element with an object + // of data values keyed by column name + function loadRowData(tableRow) { + var tableCols = tableRow.querySelectorAll('td'), + colNode, + col, + data = {}, + i, + val; + for (i = 0; i < tableCols.length; i += 1) { + colNode = tableCols[i]; + col = cols[i]; + val = colNode.getAttribute('data-value'); + if (col.type === 'number') { + val = Number(val); + } + data[col.key] = val; + } + return data; + } + // loads all row data + function loadData() { + var rows = getTableBody().querySelectorAll('tr'), + i; + + for (i = 0; i < rows.length; i += 1) { + rows[i].data = loadRowData(rows[i]); + } + } + // sorts the table using the data for the ith column + function sortByIndex(index, desc) { + var key = cols[index].key, + sorter = function(a, b) { + a = a.data[key]; + b = b.data[key]; + return a < b ? -1 : a > b ? 1 : 0; + }, + finalSorter = sorter, + tableBody = document.querySelector('.coverage-summary tbody'), + rowNodes = tableBody.querySelectorAll('tr'), + rows = [], + i; + + if (desc) { + finalSorter = function(a, b) { + return -1 * sorter(a, b); + }; + } + + for (i = 0; i < rowNodes.length; i += 1) { + rows.push(rowNodes[i]); + tableBody.removeChild(rowNodes[i]); + } + + rows.sort(finalSorter); + + for (i = 0; i < rows.length; i += 1) { + tableBody.appendChild(rows[i]); + } + } + // removes sort indicators for current column being sorted + function removeSortIndicators() { + var col = getNthColumn(currentSort.index), + cls = col.className; + + cls = cls.replace(/ sorted$/, '').replace(/ sorted-desc$/, ''); + col.className = cls; + } + // adds sort indicators for current column being sorted + function addSortIndicators() { + getNthColumn(currentSort.index).className += currentSort.desc + ? ' sorted-desc' + : ' sorted'; + } + // adds event listeners for all sorter widgets + function enableUI() { + var i, + el, + ithSorter = function ithSorter(i) { + var col = cols[i]; + + return function() { + var desc = col.defaultDescSort; + + if (currentSort.index === i) { + desc = !currentSort.desc; + } + sortByIndex(i, desc); + removeSortIndicators(); + currentSort.index = i; + currentSort.desc = desc; + addSortIndicators(); + }; + }; + for (i = 0; i < cols.length; i += 1) { + if (cols[i].sortable) { + // add the click event handler on the th so users + // dont have to click on those tiny arrows + el = getNthColumn(i).querySelector('.sorter').parentElement; + if (el.addEventListener) { + el.addEventListener('click', ithSorter(i)); + } else { + el.attachEvent('onclick', ithSorter(i)); + } + } + } + } + // adds sorting functionality to the UI + return function() { + if (!getTable()) { + return; + } + cols = loadColumns(); + loadData(); + addSearchBox(); + addSortIndicators(); + enableUI(); + }; +})(); + +window.addEventListener('load', addSorting); diff --git a/dashboard/frontend/coverage/src/App.svelte.html b/dashboard/frontend/coverage/src/App.svelte.html new file mode 100644 index 0000000..f7f391b --- /dev/null +++ b/dashboard/frontend/coverage/src/App.svelte.html @@ -0,0 +1,787 @@ + + + + + + Code coverage report for src/App.svelte + + + + + + + + + +
+
+

All files / src App.svelte

+
+ +
+ 0% + Statements + 0/177 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/177 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
<script>
+  import Sidebar from "./components/Sidebar.svelte";
+  import Dashboard from "./routes/Dashboard.svelte";
+  import Docker from "./routes/Docker.svelte";
+  import Gitea from "./routes/Gitea.svelte";
+  import Files from "./routes/Files.svelte";
+  import Terminal from "./routes/Terminal.svelte";
+  import OpenClaw from "./routes/OpenClaw.svelte";
+  import Settings from "./routes/Settings.svelte";
+  import ChatSummary from "./routes/ChatSummary.svelte";
+  import Conversations from "./routes/Conversations.svelte";
+  import Security from "./routes/Security.svelte";
+  import LiteLLM from "./routes/LiteLLM.svelte";
+  import CcConnect from "./routes/CcConnect.svelte";
+  import InfoEngine from "./routes/InfoEngine.svelte";
+  import OPC from "./routes/OPC.svelte";
+  import Transmission from "./routes/Transmission.svelte";
+  import Login from "./routes/Login.svelte";
+  import { onMount } from "svelte";
+  import { getToken, checkAuth, setToken, currentUser, setCurrentUser, getPreferences, savePreferences, tryRefreshSession, logout as logoutSession } from "./lib/api.js";
+ 
+  const KNOWN_PAGES = new Set([
+    "dashboard",
+    "docker",
+    "gitea",
+    "files",
+    "terminal",
+    "openclaw",
+    "chat-digest",
+    "conversations",
+    "settings",
+    "security",
+    "litellm",
+    "cc-connect",
+    "info-engine",
+    "opc",
+    "transmission",
+  ]);
+ 
+  let page = $state("dashboard");
+  let dark = $state(false);
+  let sidebarOpen = $state(false);
+  let authorized = $state(false);
+  let loading = $state(true);
+  let userRole = $state("");
+  let allowedPages = $state([]);
+  let allowedSidebarLinks = $state("*");
+  let sidebarOrder = $state(null);
+  let orderSaveTimer = null;
+ 
+  function getRequestedPage() {
+    const requestedPage = new URLSearchParams(window.location.search).get("page");
+    return KNOWN_PAGES.has(requestedPage) ? requestedPage : null;
+  }
+ 
+  function canOpenPage(pageId) {
+    if (!pageId || !KNOWN_PAGES.has(pageId)) return false;
+    if (pageId === "settings") return userRole === "admin";
+    if (["litellm", "cc-connect", "info-engine"].includes(pageId)) return hasPageAccess("dashboard");
+    return hasPageAccess(pageId);
+  }
+ 
+  function syncPageQuery(pageId) {
+    const url = new URL(window.location.href);
+    if (pageId === "dashboard") url.searchParams.delete("page");
+    else url.searchParams.set("page", pageId);
+    if (pageId !== "terminal") url.searchParams.delete("host");
+    history.replaceState({}, "", `${url.pathname}${url.search}${url.hash}`);
+  }
+ 
+  async function fetchUserInfo() {
+    try {
+      const data = await checkAuth();
+      setCurrentUser(data);
+      userRole = data.role || "admin";
+      allowedPages = data.pages || "*";
+      allowedSidebarLinks = data.sidebar_links || "*";
+      // Fetch sidebar preferences
+      try {
+        const prefs = await getPreferences();
+        sidebarOrder = prefs.sidebar_order || null;
+      } catch {}
+    } catch (e) {
+      console.error("Failed to fetch user info:", e);
+    }
+  }
+ 
+  function handleOrderChange(newOrder) {
+    sidebarOrder = newOrder;
+    clearTimeout(orderSaveTimer);
+    orderSaveTimer = setTimeout(() => {
+      savePreferences({ sidebar_order: newOrder }).catch((e) => {
+        console.error("Failed to save sidebar order:", e);
+      });
+    }, 500);
+  }
+ 
+  function hasPageAccess(pageId) {
+    if (allowedPages === "*") return true;
+    return Array.isArray(allowedPages) && allowedPages.includes(pageId);
+  }
+ 
+  onMount(async () => {
+    // Theme init
+    if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
+      dark = true;
+      document.documentElement.classList.add('dark');
+    } else {
+      dark = false;
+      document.documentElement.classList.remove('dark');
+    }
+ 
+    // Try proxy auth first (Authelia forward-auth sets Remote-User header)
+    try {
+      const proxyRes = await fetch("/api/auth/proxy", { credentials: "same-origin" });
+      if (proxyRes.ok) {
+        const data = await proxyRes.json();
+        setToken(data.access_token);
+        authorized = true;
+        await fetchUserInfo();
+        const requestedPage = getRequestedPage();
+        if (canOpenPage(requestedPage)) page = requestedPage;
+        loading = false;
+        return;
+      }
+    } catch (e) {
+      // Proxy auth not available, fall through to regular auth
+    }
+ 
+    // Regular token auth check — always try refresh to get fresh cookies
+    const refreshed = await tryRefreshSession();
+    if (!refreshed && !getToken()) {
+      loading = false;
+      authorized = false;
+      return;
+    }
+ 
+    try {
+        await fetchUserInfo();
+        authorized = true;
+        const requestedPage = getRequestedPage();
+        if (canOpenPage(requestedPage)) page = requestedPage;
+    } catch (e) {
+        console.error("Auth check failed:", e);
+        setToken("");
+        authorized = false;
+    } finally {
+        loading = false;
+    }
+  });
+ 
+  $effect(() => {
+    if (!authorized || loading || !canOpenPage(page)) return;
+    syncPageQuery(page);
+  });
+ 
+  function toggleTheme() {
+    dark = !dark;
+    if (dark) {
+       document.documentElement.classList.add("dark");
+       localStorage.theme = 'dark';
+    } else {
+       document.documentElement.classList.remove("dark");
+       localStorage.theme = 'light';
+    }
+  }
+ 
+  async function logout() {
+      try {
+        await logoutSession();
+      } catch (e) {
+        console.error("Logout failed:", e);
+      }
+      setToken("");
+      window.location.reload();
+  }
+</script>
+ 
+{#if loading}
+  <div class="flex h-screen items-center justify-center bg-surface-50 dark:bg-surface-950">
+    <div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
+  </div>
+{:else if !authorized}
+  <Login />
+{:else}
+  <div class="flex h-screen overflow-hidden bg-surface-50 dark:bg-surface-900">
+    <Sidebar bind:page {dark} {toggleTheme} {logout} bind:sidebarOpen {allowedPages} {allowedSidebarLinks} {userRole} {sidebarOrder} onOrderChange={handleOrderChange} />
+    <main class="flex-1 overflow-auto">
+      <div class="max-w-[1400px] mx-auto p-6 lg:p-8">
+        <!-- Mobile hamburger -->
+        <button class="md:hidden mb-4 p-2 rounded-lg text-surface-600 dark:text-surface-300 hover:bg-surface-200 dark:hover:bg-surface-700" onclick={() => sidebarOpen = true} aria-label="Open menu">
+          <svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" /></svg>
+        </button>
+        {#if page === "dashboard" && hasPageAccess("dashboard")}
+          <Dashboard />
+        {:else if page === "docker" && hasPageAccess("docker")}
+          <Docker />
+        {:else if page === "gitea" && hasPageAccess("gitea")}
+          <Gitea />
+        {:else if page === "files" && hasPageAccess("files")}
+          <Files />
+        {:else if page === "openclaw" && hasPageAccess("openclaw")}
+          <OpenClaw />
+        {:else if page === "chat-digest" && hasPageAccess("chat-digest")}
+          <ChatSummary />
+        {:else if page === "conversations" && hasPageAccess("conversations")}
+          <Conversations />
+        {:else if page === "settings" && userRole === "admin"}
+          <Settings />
+        {:else if page === "security" && hasPageAccess("security")}
+          <Security />
+        {:else if page === "litellm" && hasPageAccess("dashboard")}
+          <LiteLLM />
+        {:else if page === "cc-connect" && hasPageAccess("dashboard")}
+          <CcConnect />
+        {:else if page === "info-engine" && hasPageAccess("dashboard")}
+          <InfoEngine />
+        {:else if page === "opc" && hasPageAccess("opc")}
+          <OPC />
+        {:else if page === "transmission" && hasPageAccess("transmission")}
+          <Transmission />
+        {:else if page !== "terminal"}
+          <Dashboard />
+        {/if}
+ 
+        {#if hasPageAccess("terminal")}
+          <div class:hidden={page !== "terminal"}>
+            <Terminal />
+          </div>
+        {/if}
+      </div>
+    </main>
+  </div>
+{/if}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/src/components/Sidebar.svelte.html b/dashboard/frontend/coverage/src/components/Sidebar.svelte.html new file mode 100644 index 0000000..3faebe3 --- /dev/null +++ b/dashboard/frontend/coverage/src/components/Sidebar.svelte.html @@ -0,0 +1,1339 @@ + + + + + + Code coverage report for src/components/Sidebar.svelte + + + + + + + + + +
+
+

All files / src/components Sidebar.svelte

+
+ +
+ 0% + Statements + 0/301 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/301 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
<script>
+  let { page = $bindable("dashboard"), dark = false, toggleTheme = () => {}, logout = () => {}, sidebarOpen = $bindable(false), allowedPages = "*", allowedSidebarLinks = "*", userRole = "admin", sidebarOrder = null, onOrderChange = () => {} } = $props();
+ 
+  function canAccess(id) {
+    if (!id) return true;
+    if (allowedPages === "*") return true;
+    if (!Array.isArray(allowedPages)) return false;
+    if (id === "info-engine") return allowedPages.includes("dashboard");
+    return allowedPages.includes(id);
+  }
+ 
+  function canSeeSidebarLink(item) {
+    if (allowedSidebarLinks === "*") return true;
+    // Check by label or id
+    const key = item.label || item.id;
+    return Array.isArray(allowedSidebarLinks) && allowedSidebarLinks.includes(key);
+  }
+ 
+  const defaultLinks = [
+    { id: "dashboard", label: "Overview", icon: "grid" },
+    { id: "info-engine", label: "Info Engine", icon: "sparkles" },
+    { id: "litellm", label: "LiteLLM", icon: "bolt" },
+    { id: "opc", label: "OPC", icon: "kanban" },
+    { id: "docker", label: "Docker", icon: "box" },
+    { id: "files", label: "Files", icon: "folder" },
+    { id: "terminal", label: "Terminal", icon: "terminal" },
+    { id: "security", label: "Security", icon: "shield" },
+  ];
+ 
+  let lanIp = $state("192.168.31.222");
+  let tsIp = $state("100.78.131.124");
+  let lanReachable = $state(false);
+  let serviceUrls = $state({});
+ 
+  if (typeof window !== "undefined") {
+    fetch("/api/client-ip").then(r => r.json()).then(d => {
+      if (d.lan) lanReachable = true;
+    }).catch(() => {});
+ 
+    // Fetch external service URLs from backend config
+    fetch("/api/system/external-services").then(r => r.json()).then(d => {
+      if (d.lan_ip) lanIp = d.lan_ip;
+      if (d.ts_ip) tsIp = d.ts_ip;
+      if (d.services) serviceUrls = d.services;
+    }).catch(() => {});
+  }
+ 
+  const defaultMedia = [
+    { label: "Navidrome", remoteHref: "https://music.jimmygan.com:8443", icon: "music" },
+    { label: "Jellyfin", remoteHref: "https://media.jimmygan.com:8443", icon: "play" },
+    { label: "Audiobookshelf", remoteHref: "https://books.jimmygan.com:8443", icon: "headphones" },
+    { label: "Immich", remoteHref: "https://photos.jimmygan.com:8443", icon: "image" },
+    { id: "transmission", label: "Transmission", icon: "download" },
+  ];
+ 
+  const defaultTools = [
+    { id: "openclaw", label: "OpenClaw", icon: "openclaw" },
+    { id: "cc-connect", label: "cc-connect", icon: "users" },
+    { id: "chat-digest", label: "Chat Digest", icon: "chat" },
+    { id: "conversations", label: "Code Sessions", icon: "code" },
+    { id: "gitea", label: "Repos", icon: "git" },
+    { label: "Gitea Web", remoteHref: "https://git.jimmygan.com:8443", icon: "git", external: true },
+    { label: "Stirling PDF", remoteHref: "https://pdf.jimmygan.com:8443", icon: "pdf", external: true },
+    { label: "Vaultwarden", remoteHref: "https://vault.jimmygan.com:8443", icon: "lock", external: true },
+    { label: "n8n", remoteHref: "https://n8n.jimmygan.com:8443", icon: "n8n", external: true },
+    { label: "Speedtest", remoteHref: "https://speed.jimmygan.com:8443", icon: "speedtest", external: true },
+  ];
+ 
+  const defaultTools2 = [];
+ 
+  function itemKey(item) { return item.id || item.label; }
+ 
+  // All items indexed by key for cross-category lookup
+  const allItems = new Map([...defaultLinks, ...defaultMedia, ...defaultTools, ...defaultTools2].map(i => [itemKey(i), i]));
+ 
+  // Build category lists from saved order, supporting cross-category moves
+  const categories = ["main", "media", "tools", "tools2"];
+  const categoryDefaults = { main: defaultLinks, media: defaultMedia, tools: defaultTools, tools2: defaultTools2 };
+ 
+  function buildLists(savedOrder) {
+    if (!savedOrder) return { main: [...defaultLinks], media: [...defaultMedia], tools: [...defaultTools], tools2: [...defaultTools2] };
+ 
+    const used = new Set();
+    const result = { main: [], media: [], tools: [], tools2: [] };
+ 
+    for (const cat of categories) {
+      const keys = savedOrder[cat] || [];
+      for (const k of keys) {
+        if (allItems.has(k) && !used.has(k)) {
+          result[cat].push(allItems.get(k));
+          used.add(k);
+        }
+      }
+    }
+ 
+    // Append any new items not in saved order to their default category
+    for (const cat of categories) {
+      for (const item of categoryDefaults[cat]) {
+        if (!used.has(itemKey(item))) {
+          result[cat].push(item);
+          used.add(itemKey(item));
+        }
+      }
+    }
+ 
+    return result;
+  }
+ 
+  let categoryLists = $derived(buildLists(sidebarOrder));
+  let links = $derived(categoryLists.main);
+  let media = $derived(categoryLists.media);
+  let tools = $derived(categoryLists.tools);
+  let tools2 = $derived(categoryLists.tools2);
+ 
+  // Collapsed state for categories (persisted in sidebarOrder)
+  let collapsed = $state(sidebarOrder?.collapsed ? { ...sidebarOrder.collapsed } : {});
+ 
+  // Drag-and-drop state
+  let dragCategory = $state(null);
+  let dragIndex = $state(-1);
+  let dropCategory = $state(null);
+  let dropIndex = $state(-1);
+ 
+  function handleDragStart(category, index, e) {
+    dragCategory = category;
+    dragIndex = index;
+    e.dataTransfer.effectAllowed = "move";
+    e.dataTransfer.setData("text/plain", "");
+  }
+ 
+  function handleDragOver(category, index, e) {
+    e.preventDefault();
+    e.dataTransfer.dropEffect = "move";
+    dropCategory = category;
+    dropIndex = index;
+  }
+ 
+  function handleDragEnd() {
+    dragCategory = null;
+    dragIndex = -1;
+    dropCategory = null;
+    dropIndex = -1;
+  }
+ 
+  function handleDrop(category, index, e) {
+    e.preventDefault();
+    if (dragCategory === null) { handleDragEnd(); return; }
+    if (dragCategory === category && dragIndex === index) { handleDragEnd(); return; }
+    const lists = { main: [...links], media: [...media], tools: [...tools], tools2: [...tools2] };
+    const [moved] = lists[dragCategory].splice(dragIndex, 1);
+    lists[category].splice(index, 0, moved);
+    const newOrder = {
+      main: lists.main.map(itemKey),
+      media: lists.media.map(itemKey),
+      tools: lists.tools.map(itemKey),
+      tools2: lists.tools2.map(itemKey),
+      collapsed,
+    };
+    onOrderChange(newOrder);
+    handleDragEnd();
+  }
+ 
+  // Allow dropping at end of a category (on the section divider/header area)
+  function handleCategoryDragOver(category, e) {
+    e.preventDefault();
+    e.dataTransfer.dropEffect = "move";
+    dropCategory = category;
+    dropIndex = -1;
+  }
+ 
+  function handleCategoryDrop(category, e) {
+    e.preventDefault();
+    if (dragCategory === null) { handleDragEnd(); return; }
+    const lists = { main: [...links], media: [...media], tools: [...tools], tools2: [...tools2] };
+    const [moved] = lists[dragCategory].splice(dragIndex, 1);
+    lists[category].push(moved);
+    const newOrder = {
+      main: lists.main.map(itemKey),
+      media: lists.media.map(itemKey),
+      tools: lists.tools.map(itemKey),
+      tools2: lists.tools2.map(itemKey),
+      collapsed,
+    };
+    onOrderChange(newOrder);
+    handleDragEnd();
+  }
+ 
+  function toPublicHttpsUrl(href) {
+    if (typeof window === "undefined") return href;
+    if (!href) return href;
+ 
+    const isStandardHttps = location.protocol === "https:" && (!location.port || location.port === "443");
+    if (!isStandardHttps) return href;
+ 
+    try {
+      const url = new URL(href);
+      if (url.protocol === "https:" && url.port === "8443") {
+        url.port = "";
+        return url.toString();
+      }
+      return href;
+    } catch {
+      return href;
+    }
+  }
+ 
+  function extHref(svc) {
+    // Look up service URL from fetched config by label (lowercased, underscored)
+    const key = svc.label ? svc.label.toLowerCase().replace(/\s+/g, "_") : "";
+    const configUrl = serviceUrls[key];
+    const remoteHref = configUrl || svc.remoteHref;
+ 
+    if (lanReachable && svc.port) return `http://${lanIp}:${svc.port}`;
+    if (remoteHref) return lanReachable ? remoteHref : toPublicHttpsUrl(remoteHref);
+    if (svc.port) {
+      const host = /^\d+\.\d+\.\d+\.\d+$/.test(location.hostname) ? location.hostname : tsIp;
+      return `http://${host}:${svc.port}`;
+    }
+    return svc.href;
+  }
+ 
+  function navigate(id) {
+    page = id;
+    sidebarOpen = false;
+  }
+ 
+  function dragClasses(category, index) {
+    if (dragCategory === category && index === dragIndex) return "opacity-30";
+    if (dropCategory === category && index === dropIndex) return "border-t-2 border-primary-500";
+    return "";
+  }
+ 
+  function toggleCollapse(cat) {
+    collapsed[cat] = !collapsed[cat];
+    const newOrder = {
+      main: links.map(itemKey),
+      media: media.map(itemKey),
+      tools: tools.map(itemKey),
+      tools2: tools2.map(itemKey),
+      collapsed,
+    };
+    onOrderChange(newOrder);
+  }
+</script>
+ 
+{#snippet iconSvg(icon)}
+  <span class="w-5 h-5 flex items-center justify-center">
+    {#if icon === "grid"}
+      <svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" /></svg>
+    {:else if icon === "sparkles"}
+      <svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M12 3l1.8 4.2L18 9l-4.2 1.8L12 15l-1.8-4.2L6 9l4.2-1.8L12 3zm6 11l.9 2.1L21 17l-2.1.9L18 20l-.9-2.1L15 17l2.1-.9L18 14zM5 14l.9 2.1L8 17l-2.1.9L5 20l-.9-2.1L2 17l2.1-.9L5 14z" /></svg>
+    {:else if icon === "box"}
+      <svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" /></svg>
+    {:else if icon === "bolt"}
+      <svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M13 3L4 14h6l-1 7 9-11h-6l1-7z" /></svg>
+    {:else if icon === "folder"}
+      <svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /></svg>
+    {:else if icon === "terminal"}
+      <svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
+    {:else if icon === "shield"}
+      <svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
+    {:else if icon === "kanban"}
+      <svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2zm8 0h-2a2 2 0 00-2 2v8a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2z" /></svg>
+    {:else if icon === "music"}
+      <svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2z" /></svg>
+    {:else if icon === "play"}
+      <svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
+    {:else if icon === "headphones"}
+      <svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M3 18v-6a9 9 0 0118 0v6M3 18a3 3 0 003 3h0a3 3 0 003-3v-2a3 3 0 00-3-3h0a3 3 0 00-3 3v2zm18 0a3 3 0 01-3 3h0a3 3 0 01-3-3v-2a3 3 0 013-3h0a3 3 0 013 3v2z" /></svg>
+    {:else if icon === "image"}
+      <svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
+    {:else if icon === "download"}
+      <svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" /></svg>
+    {:else if icon === "openclaw"}
+      <svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" /></svg>
+    {:else if icon === "chat"}
+      <svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a2 2 0 01-2-2v-1m0-3V6a2 2 0 012-2h8a2 2 0 012 2v3a2 2 0 01-2 2H9l-4 4V9z" /></svg>
+    {:else if icon === "code"}
+      <svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" /></svg>
+    {:else if icon === "git"}
+      <svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" /></svg>
+    {:else if icon === "pdf"}
+      <svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" /></svg>
+    {:else if icon === "lock"}
+      <svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
+    {:else if icon === "speedtest"}
+      <svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
+    {:else if icon === "users"}
+      <svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" /></svg>
+    {:else if icon === "n8n"}
+      <svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" /><circle cx="8" cy="6" r="1.5" fill="currentColor"/><circle cx="16" cy="12" r="1.5" fill="currentColor"/><circle cx="10" cy="18" r="1.5" fill="currentColor"/></svg>
+    {/if}
+  </span>
+{/snippet}
+ 
+<!-- Mobile overlay -->
+{#if sidebarOpen}
+  <button class="fixed inset-0 bg-black/40 z-40 md:hidden" onclick={() => sidebarOpen = false} aria-label="Close sidebar"></button>
+{/if}
+ 
+<aside class="fixed inset-y-0 left-0 z-50 w-[240px] h-screen flex flex-col shrink-0 border-r border-surface-200 dark:border-surface-700 bg-white dark:bg-surface-800 transition-transform duration-200 md:static md:translate-x-0 {sidebarOpen ? 'translate-x-0' : '-translate-x-full'}">
+  <!-- Logo -->
+  <div class="px-5 py-5 flex items-center gap-3">
+    <div class="w-8 h-8 rounded-lg bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center shadow-md">
+      <svg class="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2" /></svg>
+    </div>
+    <div>
+      <h1 class="text-sm font-bold tracking-tight text-surface-900 dark:text-white">NAS Dashboard</h1>
+      <p class="text-[10px] text-surface-400 font-medium">DS224+ · Synology</p>
+    </div>
+  </div>
+ 
+  <!-- Nav Links -->
+  <nav class="flex-1 px-3 mt-2 overflow-y-auto">
+    {#each [["main", "Main", links], ["media", "Media", media], ["tools", "Tools", tools], ["tools2", "Tools 2", tools2]] as [cat, label, items], ci}
+      {#if ci > 0}
+        <div class="my-4 border-t border-surface-200 dark:border-surface-700"></div>
+      {/if}
+      <button
+        class="w-full text-left text-[10px] font-semibold uppercase tracking-wider text-surface-400 px-3 mb-2 flex items-center justify-between {cat !== 'main' ? 'cursor-pointer' : ''}"
+        ondragover={(e) => handleCategoryDragOver(cat, e)}
+        ondrop={(e) => handleCategoryDrop(cat, e)}
+        onclick={() => cat !== 'main' && toggleCollapse(cat)}
+        disabled={cat === 'main'}
+      >
+        {label}
+        {#if cat !== "main"}
+          <svg class="w-3 h-3 opacity-30 hover:opacity-50 transition-all duration-150 {collapsed[cat] ? '-rotate-90' : ''}" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" /></svg>
+        {/if}
+      </button>
+      {#if cat === "main" || !collapsed[cat]}
+      {#if items.length === 0}
+        <div
+          class="px-3 py-3 mx-1 rounded-lg border border-dashed text-[11px] text-center transition-colors duration-150
+            {dropCategory === cat ? 'border-primary-400 bg-primary-50 dark:bg-primary-900/20 text-primary-500' : 'border-surface-300 dark:border-surface-600 text-surface-400'}"
+          ondragover={(e) => handleCategoryDragOver(cat, e)}
+          ondrop={(e) => handleCategoryDrop(cat, e)}
+        >
+          Drop items here
+        </div>
+      {/if}
+      {#each items as item, i}
+        {#if canAccess(item.id) && canSeeSidebarLink(item)}
+          {#if item.external || item.remoteHref || item.port}
+            <a
+              href={extHref(item)}
+              target="_blank"
+              rel="noopener"
+              draggable="true"
+              ondragstart={(e) => handleDragStart(cat, i, e)}
+              ondragover={(e) => handleDragOver(cat, i, e)}
+              ondrop={(e) => handleDrop(cat, i, e)}
+              ondragend={handleDragEnd}
+              class="w-full text-left px-3 py-2 rounded-lg text-[13px] font-medium flex items-center gap-2.5 mb-0.5 text-surface-500 hover:bg-surface-100 dark:hover:bg-surface-700 hover:text-surface-700 dark:hover:text-surface-200 dark:text-surface-400 transition-all duration-150 group {dragClasses(cat, i)}"
+            >
+              <svg class="w-3 h-3 opacity-0 group-hover:opacity-30 -ml-1 mr-0.5 shrink-0 cursor-grab" fill="currentColor" viewBox="0 0 24 24"><circle cx="9" cy="6" r="2"/><circle cx="15" cy="6" r="2"/><circle cx="9" cy="12" r="2"/><circle cx="15" cy="12" r="2"/><circle cx="9" cy="18" r="2"/><circle cx="15" cy="18" r="2"/></svg>
+              {@render iconSvg(item.icon)}
+              {item.label}
+              <svg class="w-3 h-3 ml-auto opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
+            </a>
+          {:else}
+            <button
+              draggable="true"
+              ondragstart={(e) => handleDragStart(cat, i, e)}
+              ondragover={(e) => handleDragOver(cat, i, e)}
+              ondrop={(e) => handleDrop(cat, i, e)}
+              ondragend={handleDragEnd}
+              class="w-full text-left px-3 py-2.5 rounded-lg text-[13px] font-medium flex items-center gap-2.5 mb-0.5 transition-all duration-150 group
+                {page === item.id
+                  ? 'bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 shadow-sm'
+                  : 'text-surface-500 dark:text-surface-400 hover:bg-surface-100 dark:hover:bg-surface-700 hover:text-surface-700 dark:hover:text-surface-200'
+                } {dragClasses(cat, i)}"
+              onclick={() => navigate(item.id)}
+            >
+              <svg class="w-3 h-3 opacity-0 group-hover:opacity-30 -ml-1 mr-0.5 shrink-0 cursor-grab" fill="currentColor" viewBox="0 0 24 24"><circle cx="9" cy="6" r="2"/><circle cx="15" cy="6" r="2"/><circle cx="9" cy="12" r="2"/><circle cx="15" cy="12" r="2"/><circle cx="9" cy="18" r="2"/><circle cx="15" cy="18" r="2"/></svg>
+              {@render iconSvg(item.icon)}
+              {item.label}
+            </button>
+          {/if}
+        {/if}
+      {/each}
+      {/if}
+    {/each}
+  </nav>
+ 
+  <!-- Bottom actions -->
+  <div class="px-3 pb-3 pt-2 border-t border-surface-200 dark:border-surface-700">
+    {#if userRole === "admin"}
+    <button
+      class="w-full px-3 py-2 rounded-lg text-[13px] font-medium flex items-center gap-2.5 text-surface-500 dark:text-surface-400 hover:bg-surface-100 dark:hover:bg-surface-700 transition-all duration-150 mb-1 {page === 'settings' ? 'bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 shadow-sm' : ''}"
+      onclick={() => navigate('settings')}
+    >
+      <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
+      Settings
+    </button>
+    {/if}
+    <div class="flex items-center gap-1">
+      <button
+        onclick={logout}
+        class="flex-1 px-3 py-2 rounded-lg text-[13px] font-medium flex items-center gap-2.5 text-surface-500 dark:text-surface-400 hover:bg-surface-100 dark:hover:bg-surface-700 transition-all duration-150 hover:text-red-600 dark:hover:text-red-400"
+      >
+        <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" /></svg>
+        Sign out
+      </button>
+      <button
+        onclick={toggleTheme}
+        class="p-2 rounded-lg text-surface-400 hover:bg-surface-100 dark:hover:bg-surface-700 hover:text-surface-600 dark:hover:text-surface-300 transition-all duration-150"
+        title={dark ? 'Light Mode' : 'Dark Mode'}
+      >
+        {#if dark}
+          <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" /></svg>
+        {:else}
+          <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" /></svg>
+        {/if}
+      </button>
+    </div>
+  </div>
+</aside>
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/src/components/index.html b/dashboard/frontend/coverage/src/components/index.html new file mode 100644 index 0000000..2b32033 --- /dev/null +++ b/dashboard/frontend/coverage/src/components/index.html @@ -0,0 +1,116 @@ + + + + + + Code coverage report for src/components + + + + + + + + + +
+
+

All files src/components

+
+ +
+ 0% + Statements + 0/301 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/301 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
Sidebar.svelte +
+
0%0/3010%0/10%0/10%0/301
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/src/components/opc/AgentPanel.svelte.html b/dashboard/frontend/coverage/src/components/opc/AgentPanel.svelte.html new file mode 100644 index 0000000..fea9785 --- /dev/null +++ b/dashboard/frontend/coverage/src/components/opc/AgentPanel.svelte.html @@ -0,0 +1,688 @@ + + + + + + Code coverage report for src/components/opc/AgentPanel.svelte + + + + + + + + + +
+
+

All files / src/components/opc AgentPanel.svelte

+
+ +
+ 0% + Statements + 0/117 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/117 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
<script>
+  import { onMount } from "svelte";
+  import { getAgents, getExecutions } from "../../lib/opc-api.js";
+ 
+  let agents = $state([]);
+  let executions = $state([]);
+  let loading = $state(true);
+  let selectedAgent = $state(null);
+ 
+  async function loadData() {
+    loading = true;
+    try {
+      const [agentsRes, executionsRes] = await Promise.all([
+        getAgents(),
+        getExecutions({ limit: 20 })
+      ]);
+      agents = agentsRes.items || [];
+      executions = executionsRes.items || [];
+    } catch (e) {
+      console.error("Failed to load agent data:", e);
+    } finally {
+      loading = false;
+    }
+  }
+ 
+  function getAgentIcon(agentId) {
+    const icons = {
+      pm: "📋",
+      cto: "💻",
+      coo: "⚙️",
+      ceo: "🎯",
+      marketing: "📢",
+      social_media: "📱"
+    };
+    return icons[agentId] || "🤖";
+  }
+ 
+  function getStatusColor(status) {
+    const colors = {
+      pending: "bg-slate-100 text-slate-700",
+      running: "bg-blue-100 text-blue-700",
+      completed: "bg-emerald-100 text-emerald-700",
+      failed: "bg-rose-100 text-rose-700",
+      pending_approval: "bg-amber-100 text-amber-700"
+    };
+    return colors[status] || colors.pending;
+  }
+ 
+  function getAgentExecutions(agentId) {
+    return executions.filter(e => e.agent_id === agentId);
+  }
+ 
+  function formatTimestamp(timestamp) {
+    if (!timestamp) return "N/A";
+    return new Date(timestamp).toLocaleString();
+  }
+ 
+  onMount(() => {
+    loadData();
+    // Refresh every 10 seconds
+    const interval = setInterval(loadData, 10000);
+    return () => clearInterval(interval);
+  });
+</script>
+ 
+<div class="space-y-6">
+  <h2 class="text-xl font-semibold text-surface-900 dark:text-surface-100">
+    AI Agents
+  </h2>
+ 
+  {#if loading}
+    <div class="text-surface-600 dark:text-surface-400">Loading agents...</div>
+  {:else}
+    <!-- Agent Cards -->
+    <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
+      {#each agents as agent}
+        {@const agentExecs = getAgentExecutions(agent.id)}
+        {@const runningExecs = agentExecs.filter(e => e.status === "running")}
+        {@const completedExecs = agentExecs.filter(e => e.status === "completed")}
+ 
+        <button
+          onclick={() => selectedAgent = selectedAgent?.id === agent.id ? null : agent}
+          class="bg-white dark:bg-surface-800 rounded-lg p-4 border-2 transition-all text-left hover:shadow-lg {selectedAgent?.id === agent.id ? 'border-primary-500' : 'border-surface-200 dark:border-surface-700'}"
+        >
+          <!-- Agent Header -->
+          <div class="flex items-start justify-between mb-3">
+            <div class="flex items-center gap-2">
+              <span class="text-3xl">{getAgentIcon(agent.id)}</span>
+              <div>
+                <h3 class="font-semibold text-surface-900 dark:text-surface-100">
+                  {agent.name}
+                </h3>
+                <p class="text-xs text-surface-600 dark:text-surface-400">
+                  {agent.role}
+                </p>
+              </div>
+            </div>
+            {#if runningExecs.length > 0}
+              <span class="flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400">
+                <span class="animate-pulse">●</span>
+                Working
+              </span>
+            {:else}
+              <span class="text-xs text-surface-500">Idle</span>
+            {/if}
+          </div>
+ 
+          <!-- Agent Stats -->
+          <div class="grid grid-cols-2 gap-2 text-sm">
+            <div class="bg-surface-50 dark:bg-surface-900 rounded p-2">
+              <div class="text-xs text-surface-600 dark:text-surface-400">Total Tasks</div>
+              <div class="text-lg font-semibold text-surface-900 dark:text-surface-100">
+                {agentExecs.length}
+              </div>
+            </div>
+            <div class="bg-surface-50 dark:bg-surface-900 rounded p-2">
+              <div class="text-xs text-surface-600 dark:text-surface-400">Completed</div>
+              <div class="text-lg font-semibold text-emerald-600 dark:text-emerald-400">
+                {completedExecs.length}
+              </div>
+            </div>
+          </div>
+ 
+          <!-- Capabilities -->
+          <div class="mt-3">
+            <div class="text-xs text-surface-600 dark:text-surface-400 mb-1">Capabilities:</div>
+            <div class="flex flex-wrap gap-1">
+              {#each agent.capabilities.slice(0, 3) as capability}
+                <span class="text-xs px-2 py-0.5 rounded bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300">
+                  {capability.replace(/_/g, ' ')}
+                </span>
+              {/each}
+              {#if agent.capabilities.length > 3}
+                <span class="text-xs text-surface-500">+{agent.capabilities.length - 3}</span>
+              {/if}
+            </div>
+          </div>
+        </button>
+      {/each}
+    </div>
+ 
+    <!-- Execution Log -->
+    <div class="bg-white dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700">
+      <div class="p-4 border-b border-surface-200 dark:border-surface-700">
+        <h3 class="font-semibold text-surface-900 dark:text-surface-100">
+          {selectedAgent ? `${selectedAgent.name} Execution Log` : "Recent Executions"}
+        </h3>
+      </div>
+ 
+      <div class="divide-y divide-surface-200 dark:divide-surface-700 max-h-96 overflow-y-auto">
+        {#each (selectedAgent ? getAgentExecutions(selectedAgent.id) : executions) as execution}
+          <div class="p-4 hover:bg-surface-50 dark:hover:bg-surface-900/50">
+            <div class="flex items-start justify-between mb-2">
+              <div class="flex items-center gap-2">
+                <span class="text-xl">{getAgentIcon(execution.agent_id)}</span>
+                <div>
+                  <div class="font-medium text-surface-900 dark:text-surface-100">
+                    Task #{execution.task_id}
+                  </div>
+                  <div class="text-xs text-surface-600 dark:text-surface-400">
+                    {formatTimestamp(execution.started_at)}
+                  </div>
+                </div>
+              </div>
+              <span class="text-xs px-2 py-1 rounded {getStatusColor(execution.status)}">
+                {execution.status.replace(/_/g, ' ')}
+              </span>
+            </div>
+ 
+            {#if execution.output_result?.reasoning}
+              <p class="text-sm text-surface-700 dark:text-surface-300 mb-2">
+                {execution.output_result.reasoning}
+              </p>
+            {/if}
+ 
+            {#if execution.actions_proposed && execution.actions_proposed.length > 0}
+              <div class="text-xs text-surface-600 dark:text-surface-400">
+                Actions: {execution.actions_proposed.length}
+                {#each execution.actions_proposed.slice(0, 2) as action}
+                  <span class="ml-2 px-1.5 py-0.5 rounded bg-surface-100 dark:bg-surface-700">
+                    {action.type}
+                  </span>
+                {/each}
+              </div>
+            {/if}
+ 
+            {#if execution.error_message}
+              <div class="mt-2 text-xs text-rose-600 dark:text-rose-400">
+                Error: {execution.error_message}
+              </div>
+            {/if}
+          </div>
+        {:else}
+          <div class="p-8 text-center text-surface-500">
+            No executions yet
+          </div>
+        {/each}
+      </div>
+    </div>
+  {/if}
+</div>
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/src/components/opc/KanbanBoard.svelte.html b/dashboard/frontend/coverage/src/components/opc/KanbanBoard.svelte.html new file mode 100644 index 0000000..b79b9fb --- /dev/null +++ b/dashboard/frontend/coverage/src/components/opc/KanbanBoard.svelte.html @@ -0,0 +1,205 @@ + + + + + + Code coverage report for src/components/opc/KanbanBoard.svelte + + + + + + + + + +
+
+

All files / src/components/opc KanbanBoard.svelte

+
+ +
+ 0% + Statements + 0/26 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/26 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
<script>
+  import KanbanColumn from "./KanbanColumn.svelte";
+  import { moveTask } from "../../lib/opc-api.js";
+ 
+  let { tasks = [], agents = [], onEdit, onDelete, onAssign, onTaskMoved } = $props();
+ 
+  const columns = [
+    { id: "parking_lot", title: "Parking Lot", status: "parking_lot" },
+    { id: "in_progress", title: "In Progress", status: "in_progress" },
+    { id: "done", title: "Done", status: "done" }
+  ];
+ 
+  function getTasksByStatus(status) {
+    return tasks.filter(t => t.status === status);
+  }
+ 
+  async function handleDrop(taskId, newStatus) {
+    try {
+      await moveTask(taskId, newStatus);
+      onTaskMoved?.(taskId, newStatus);
+    } catch (e) {
+      alert("Failed to move task: " + e.message);
+    }
+  }
+</script>
+ 
+<div class="grid grid-cols-1 md:grid-cols-3 gap-4 h-full">
+  {#each columns as column}
+    <KanbanColumn
+      title={column.title}
+      status={column.status}
+      tasks={getTasksByStatus(column.status)}
+      {agents}
+      {onEdit}
+      {onDelete}
+      {onAssign}
+      onDrop={handleDrop}
+    />
+  {/each}
+</div>
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/src/components/opc/KanbanColumn.svelte.html b/dashboard/frontend/coverage/src/components/opc/KanbanColumn.svelte.html new file mode 100644 index 0000000..1c9a2ae --- /dev/null +++ b/dashboard/frontend/coverage/src/components/opc/KanbanColumn.svelte.html @@ -0,0 +1,340 @@ + + + + + + Code coverage report for src/components/opc/KanbanColumn.svelte + + + + + + + + + +
+
+

All files / src/components/opc KanbanColumn.svelte

+
+ +
+ 0% + Statements + 0/46 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/46 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
<script>
+  import TaskCard from "./TaskCard.svelte";
+ 
+  let {
+    title,
+    status,
+    tasks = [],
+    agents = [],
+    onEdit,
+    onDelete,
+    onAssign,
+    onDrop
+  } = $props();
+ 
+  const columnColors = {
+    parking_lot: "bg-slate-100 dark:bg-slate-800 border-slate-300",
+    in_progress: "bg-blue-50 dark:bg-blue-900/20 border-blue-300",
+    done: "bg-emerald-50 dark:bg-emerald-900/20 border-emerald-300"
+  };
+ 
+  function handleDragOver(e) {
+    e.preventDefault();
+    e.currentTarget.classList.add("ring-2", "ring-primary-400");
+  }
+ 
+  function handleDragLeave(e) {
+    e.currentTarget.classList.remove("ring-2", "ring-primary-400");
+  }
+ 
+  function handleDrop(e) {
+    e.preventDefault();
+    e.currentTarget.classList.remove("ring-2", "ring-primary-400");
+ 
+    const taskId = parseInt(e.dataTransfer.getData("taskId"));
+    const fromStatus = e.dataTransfer.getData("fromStatus");
+ 
+    if (fromStatus !== status) {
+      onDrop?.(taskId, status);
+    }
+  }
+ 
+  function handleDragStart(e, task) {
+    e.dataTransfer.setData("taskId", task.id);
+    e.dataTransfer.setData("fromStatus", task.status);
+    e.dataTransfer.effectAllowed = "move";
+  }
+</script>
+ 
+<div class="flex flex-col h-full">
+  <!-- Column header -->
+  <div class="flex items-center justify-between mb-3 pb-2 border-b border-surface-300 dark:border-surface-600">
+    <h3 class="font-semibold text-surface-900 dark:text-surface-100">
+      {title}
+    </h3>
+    <span class="text-sm text-surface-600 dark:text-surface-400 bg-surface-200 dark:bg-surface-700 px-2 py-0.5 rounded-full">
+      {tasks.length}
+    </span>
+  </div>
+ 
+  <!-- Drop zone -->
+  <div
+    class="flex-1 min-h-[200px] p-2 rounded-lg border-2 border-dashed transition-all {columnColors[status] || columnColors.parking_lot}"
+    ondragover={handleDragOver}
+    ondragleave={handleDragLeave}
+    ondrop={handleDrop}
+  >
+    {#if tasks.length === 0}
+      <div class="flex items-center justify-center h-32 text-surface-400 dark:text-surface-500 text-sm">
+        Drop tasks here
+      </div>
+    {:else}
+      {#each tasks as task (task.id)}
+        <div draggable="true" ondragstart={(e) => handleDragStart(e, task)}>
+          <TaskCard
+            {task}
+            {agents}
+            {onEdit}
+            {onDelete}
+            {onAssign}
+          />
+        </div>
+      {/each}
+    {/if}
+  </div>
+</div>
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/src/components/opc/TaskCard.svelte.html b/dashboard/frontend/coverage/src/components/opc/TaskCard.svelte.html new file mode 100644 index 0000000..6838464 --- /dev/null +++ b/dashboard/frontend/coverage/src/components/opc/TaskCard.svelte.html @@ -0,0 +1,592 @@ + + + + + + Code coverage report for src/components/opc/TaskCard.svelte + + + + + + + + + +
+
+

All files / src/components/opc TaskCard.svelte

+
+ +
+ 0% + Statements + 0/94 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/94 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
<script>
+  import { deleteTask, assignTask, getTaskTime } from "../../lib/opc-api.js";
+ 
+  let { task, agents = [], onEdit, onDelete, onAssign } = $props();
+ 
+  let showActions = $state(false);
+  let timeInfo = $state(null);
+ 
+  const priorityColors = {
+    low: "bg-slate-200 text-slate-700",
+    medium: "bg-blue-100 text-blue-700",
+    high: "bg-amber-100 text-amber-700",
+    urgent: "bg-rose-100 text-rose-700"
+  };
+ 
+  async function loadTimeInfo() {
+    if (task.status === "in_progress") {
+      try {
+        timeInfo = await getTaskTime(task.id);
+      } catch (e) {
+        console.error("Failed to load time info:", e);
+      }
+    }
+  }
+ 
+  $effect(() => {
+    loadTimeInfo();
+  });
+ 
+  async function handleDelete() {
+    if (confirm(`Delete task "${task.title}"?`)) {
+      try {
+        await deleteTask(task.id);
+        onDelete?.(task.id);
+      } catch (e) {
+        alert("Failed to delete task: " + e.message);
+      }
+    }
+  }
+ 
+  async function handleAssignAgent(agentId) {
+    try {
+      await assignTask(task.id, agentId, "agent");
+      onAssign?.(task.id, agentId, "agent");
+    } catch (e) {
+      alert("Failed to assign agent: " + e.message);
+    }
+  }
+ 
+  function getAgentIcon(agentId) {
+    const icons = {
+      pm: "📋",
+      cto: "💻",
+      coo: "⚙️",
+      ceo: "🎯",
+      marketing: "📢",
+      social_media: "📱"
+    };
+    return icons[agentId] || "🤖";
+  }
+</script>
+ 
+<div
+  class="bg-white dark:bg-surface-800 rounded-lg p-3 mb-2 shadow-sm border border-surface-200 dark:border-surface-700 cursor-move hover:shadow-md transition-shadow"
+  onmouseenter={() => showActions = true}
+  onmouseleave={() => showActions = false}
+>
+  <!-- Priority badge -->
+  <div class="flex items-start justify-between mb-2">
+    <span class="text-xs px-2 py-0.5 rounded {priorityColors[task.priority] || priorityColors.medium}">
+      {task.priority}
+    </span>
+    {#if showActions}
+      <div class="flex gap-1">
+        <button
+          onclick={() => onEdit?.(task)}
+          class="text-xs text-surface-600 hover:text-primary-600 dark:text-surface-400"
+          title="Edit"
+        >
+          ✏️
+        </button>
+        <button
+          onclick={handleDelete}
+          class="text-xs text-surface-600 hover:text-rose-600 dark:text-surface-400"
+          title="Delete"
+        >
+          🗑️
+        </button>
+      </div>
+    {/if}
+  </div>
+ 
+  <!-- Title -->
+  <h4 class="font-medium text-surface-900 dark:text-surface-100 mb-1 text-sm">
+    {task.title}
+  </h4>
+ 
+  <!-- Description preview -->
+  {#if task.description}
+    <p class="text-xs text-surface-600 dark:text-surface-400 mb-2 line-clamp-2">
+      {task.description}
+    </p>
+  {/if}
+ 
+  <!-- Tags -->
+  {#if task.tags && task.tags.length > 0}
+    <div class="flex flex-wrap gap-1 mb-2">
+      {#each task.tags as tag}
+        <span class="text-xs px-1.5 py-0.5 rounded bg-surface-100 dark:bg-surface-700 text-surface-700 dark:text-surface-300">
+          {tag}
+        </span>
+      {/each}
+    </div>
+  {/if}
+ 
+  <!-- Assignee and time -->
+  <div class="flex items-center justify-between text-xs text-surface-600 dark:text-surface-400">
+    <div class="flex items-center gap-1">
+      {#if task.assigned_to}
+        {#if task.assigned_type === "agent"}
+          <span title={task.assigned_to}>
+            {getAgentIcon(task.assigned_to)}
+          </span>
+        {:else}
+          <span>👤 {task.assigned_to}</span>
+        {/if}
+      {:else}
+        <button
+          onclick={() => showActions = true}
+          class="text-surface-500 hover:text-primary-600"
+          title="Assign"
+        >
+          Unassigned
+        </button>
+      {/if}
+    </div>
+ 
+    {#if timeInfo && timeInfo.total_hours > 0}
+      <span class="text-xs text-surface-500">
+        ⏱️ {timeInfo.total_hours}h
+      </span>
+    {/if}
+  </div>
+ 
+  <!-- Due date -->
+  {#if task.due_date}
+    <div class="text-xs text-surface-500 mt-1">
+      📅 {new Date(task.due_date).toLocaleDateString()}
+    </div>
+  {/if}
+ 
+  <!-- Quick assign to agent (when hovering) -->
+  {#if showActions && !task.assigned_to}
+    <div class="mt-2 pt-2 border-t border-surface-200 dark:border-surface-700">
+      <div class="text-xs text-surface-600 mb-1">Assign to agent:</div>
+      <div class="flex gap-1">
+        {#each agents as agent}
+          <button
+            onclick={() => handleAssignAgent(agent.id)}
+            class="text-lg hover:scale-110 transition-transform"
+            title={agent.name}
+          >
+            {getAgentIcon(agent.id)}
+          </button>
+        {/each}
+      </div>
+    </div>
+  {/if}
+</div>
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/src/components/opc/TaskModal.svelte.html b/dashboard/frontend/coverage/src/components/opc/TaskModal.svelte.html new file mode 100644 index 0000000..a85267a --- /dev/null +++ b/dashboard/frontend/coverage/src/components/opc/TaskModal.svelte.html @@ -0,0 +1,835 @@ + + + + + + Code coverage report for src/components/opc/TaskModal.svelte + + + + + + + + + +
+
+

All files / src/components/opc TaskModal.svelte

+
+ +
+ 0% + Statements + 0/110 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/110 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
<script>
+  import { createTask, updateTask } from "../../lib/opc-api.js";
+ 
+  let { task = null, agents = [], onClose, onSave } = $props();
+ 
+  let isEdit = $state(!!task);
+  let formData = $state({
+    title: task?.title || "",
+    description: task?.description || "",
+    status: task?.status || "parking_lot",
+    priority: task?.priority || "medium",
+    assigned_to: task?.assigned_to || "",
+    assigned_type: task?.assigned_type || "human",
+    tags: task?.tags || [],
+    due_date: task?.due_date ? task.due_date.split("T")[0] : ""
+  });
+ 
+  let tagInput = $state("");
+  let saving = $state(false);
+ 
+  function addTag() {
+    if (tagInput.trim() && !formData.tags.includes(tagInput.trim())) {
+      formData.tags = [...formData.tags, tagInput.trim()];
+      tagInput = "";
+    }
+  }
+ 
+  function removeTag(tag) {
+    formData.tags = formData.tags.filter(t => t !== tag);
+  }
+ 
+  async function handleSubmit(e) {
+    e.preventDefault();
+    saving = true;
+ 
+    try {
+      const payload = {
+        ...formData,
+        due_date: formData.due_date ? new Date(formData.due_date).toISOString() : null,
+        assigned_to: formData.assigned_to || null
+      };
+ 
+      if (isEdit) {
+        await updateTask(task.id, payload);
+      } else {
+        await createTask(payload);
+      }
+ 
+      onSave?.();
+      onClose?.();
+    } catch (e) {
+      alert("Failed to save task: " + e.message);
+    } finally {
+      saving = false;
+    }
+  }
+</script>
+ 
+<!-- Modal backdrop -->
+<div
+  class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
+  onclick={(e) => e.target === e.currentTarget && onClose?.()}
+>
+  <!-- Modal content -->
+  <div class="bg-white dark:bg-surface-800 rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
+    <!-- Header -->
+    <div class="flex items-center justify-between p-4 border-b border-surface-200 dark:border-surface-700">
+      <h2 class="text-xl font-semibold text-surface-900 dark:text-surface-100">
+        {isEdit ? "Edit Task" : "Create Task"}
+      </h2>
+      <button
+        onclick={onClose}
+        class="text-surface-500 hover:text-surface-700 dark:hover:text-surface-300"
+      >
+        ✕
+      </button>
+    </div>
+ 
+    <!-- Form -->
+    <form onsubmit={handleSubmit} class="p-4 space-y-4">
+      <!-- Title -->
+      <div>
+        <label class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
+          Title *
+        </label>
+        <input
+          type="text"
+          bind:value={formData.title}
+          required
+          class="w-full px-3 py-2 border border-surface-300 dark:border-surface-600 rounded-lg bg-white dark:bg-surface-900 text-surface-900 dark:text-surface-100"
+          placeholder="Task title"
+        />
+      </div>
+ 
+      <!-- Description -->
+      <div>
+        <label class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
+          Description
+        </label>
+        <textarea
+          bind:value={formData.description}
+          rows="4"
+          class="w-full px-3 py-2 border border-surface-300 dark:border-surface-600 rounded-lg bg-white dark:bg-surface-900 text-surface-900 dark:text-surface-100"
+          placeholder="Task description"
+        ></textarea>
+      </div>
+ 
+      <!-- Status and Priority -->
+      <div class="grid grid-cols-2 gap-4">
+        <div>
+          <label class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
+            Status
+          </label>
+          <select
+            bind:value={formData.status}
+            class="w-full px-3 py-2 border border-surface-300 dark:border-surface-600 rounded-lg bg-white dark:bg-surface-900 text-surface-900 dark:text-surface-100"
+          >
+            <option value="parking_lot">Parking Lot</option>
+            <option value="in_progress">In Progress</option>
+            <option value="done">Done</option>
+          </select>
+        </div>
+ 
+        <div>
+          <label class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
+            Priority
+          </label>
+          <select
+            bind:value={formData.priority}
+            class="w-full px-3 py-2 border border-surface-300 dark:border-surface-600 rounded-lg bg-white dark:bg-surface-900 text-surface-900 dark:text-surface-100"
+          >
+            <option value="low">Low</option>
+            <option value="medium">Medium</option>
+            <option value="high">High</option>
+            <option value="urgent">Urgent</option>
+          </select>
+        </div>
+      </div>
+ 
+      <!-- Assignee -->
+      <div class="grid grid-cols-2 gap-4">
+        <div>
+          <label class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
+            Assign to
+          </label>
+          <select
+            bind:value={formData.assigned_type}
+            class="w-full px-3 py-2 border border-surface-300 dark:border-surface-600 rounded-lg bg-white dark:bg-surface-900 text-surface-900 dark:text-surface-100"
+          >
+            <option value="human">Human</option>
+            <option value="agent">Agent</option>
+          </select>
+        </div>
+ 
+        <div>
+          <label class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
+            {formData.assigned_type === "agent" ? "Agent" : "Username"}
+          </label>
+          {#if formData.assigned_type === "agent"}
+            <select
+              bind:value={formData.assigned_to}
+              class="w-full px-3 py-2 border border-surface-300 dark:border-surface-600 rounded-lg bg-white dark:bg-surface-900 text-surface-900 dark:text-surface-100"
+            >
+              <option value="">Unassigned</option>
+              {#each agents as agent}
+                <option value={agent.id}>{agent.name}</option>
+              {/each}
+            </select>
+          {:else}
+            <input
+              type="text"
+              bind:value={formData.assigned_to}
+              class="w-full px-3 py-2 border border-surface-300 dark:border-surface-600 rounded-lg bg-white dark:bg-surface-900 text-surface-900 dark:text-surface-100"
+              placeholder="Username"
+            />
+          {/if}
+        </div>
+      </div>
+ 
+      <!-- Due date -->
+      <div>
+        <label class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
+          Due Date
+        </label>
+        <input
+          type="date"
+          bind:value={formData.due_date}
+          class="w-full px-3 py-2 border border-surface-300 dark:border-surface-600 rounded-lg bg-white dark:bg-surface-900 text-surface-900 dark:text-surface-100"
+        />
+      </div>
+ 
+      <!-- Tags -->
+      <div>
+        <label class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
+          Tags
+        </label>
+        <div class="flex gap-2 mb-2">
+          <input
+            type="text"
+            bind:value={tagInput}
+            onkeydown={(e) => e.key === "Enter" && (e.preventDefault(), addTag())}
+            class="flex-1 px-3 py-2 border border-surface-300 dark:border-surface-600 rounded-lg bg-white dark:bg-surface-900 text-surface-900 dark:text-surface-100"
+            placeholder="Add tag and press Enter"
+          />
+          <button
+            type="button"
+            onclick={addTag}
+            class="px-4 py-2 bg-surface-200 dark:bg-surface-700 text-surface-700 dark:text-surface-300 rounded-lg hover:bg-surface-300 dark:hover:bg-surface-600"
+          >
+            Add
+          </button>
+        </div>
+        {#if formData.tags.length > 0}
+          <div class="flex flex-wrap gap-2">
+            {#each formData.tags as tag}
+              <span class="inline-flex items-center gap-1 px-2 py-1 bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 rounded text-sm">
+                {tag}
+                <button
+                  type="button"
+                  onclick={() => removeTag(tag)}
+                  class="hover:text-primary-900 dark:hover:text-primary-100"
+                >
+                  ✕
+                </button>
+              </span>
+            {/each}
+          </div>
+        {/if}
+      </div>
+ 
+      <!-- Actions -->
+      <div class="flex justify-end gap-2 pt-4 border-t border-surface-200 dark:border-surface-700">
+        <button
+          type="button"
+          onclick={onClose}
+          class="px-4 py-2 text-surface-700 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-700 rounded-lg"
+        >
+          Cancel
+        </button>
+        <button
+          type="submit"
+          disabled={saving}
+          class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
+        >
+          {saving ? "Saving..." : isEdit ? "Update" : "Create"}
+        </button>
+      </div>
+    </form>
+  </div>
+</div>
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/src/components/opc/index.html b/dashboard/frontend/coverage/src/components/opc/index.html new file mode 100644 index 0000000..037d2be --- /dev/null +++ b/dashboard/frontend/coverage/src/components/opc/index.html @@ -0,0 +1,176 @@ + + + + + + Code coverage report for src/components/opc + + + + + + + + + +
+
+

All files src/components/opc

+
+ +
+ 0% + Statements + 0/393 +
+ + +
+ 0% + Branches + 0/5 +
+ + +
+ 0% + Functions + 0/5 +
+ + +
+ 0% + Lines + 0/393 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
AgentPanel.svelte +
+
0%0/1170%0/10%0/10%0/117
KanbanBoard.svelte +
+
0%0/260%0/10%0/10%0/26
KanbanColumn.svelte +
+
0%0/460%0/10%0/10%0/46
TaskCard.svelte +
+
0%0/940%0/10%0/10%0/94
TaskModal.svelte +
+
0%0/1100%0/10%0/10%0/110
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/src/index.html b/dashboard/frontend/coverage/src/index.html new file mode 100644 index 0000000..2b0b65c --- /dev/null +++ b/dashboard/frontend/coverage/src/index.html @@ -0,0 +1,131 @@ + + + + + + Code coverage report for src + + + + + + + + + +
+
+

All files src

+
+ +
+ 0% + Statements + 0/182 +
+ + +
+ 0% + Branches + 0/2 +
+ + +
+ 0% + Functions + 0/2 +
+ + +
+ 0% + Lines + 0/182 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
App.svelte +
+
0%0/1770%0/10%0/10%0/177
main.js +
+
0%0/50%0/10%0/10%0/5
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/src/lib/api.js.html b/dashboard/frontend/coverage/src/lib/api.js.html new file mode 100644 index 0000000..17047f4 --- /dev/null +++ b/dashboard/frontend/coverage/src/lib/api.js.html @@ -0,0 +1,787 @@ + + + + + + Code coverage report for src/lib/api.js + + + + + + + + + +
+
+

All files / src/lib api.js

+
+ +
+ 64.92% + Statements + 124/191 +
+ + +
+ 90.69% + Branches + 39/43 +
+ + +
+ 57.69% + Functions + 15/26 +
+ + +
+ 64.92% + Lines + 124/191 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +2351x +1x +  +1x +1x +  +1x +1x +1x +  +1x +30x +30x +12x +30x +  +1x +5x +5x +  +1x +1x +  +1x +  +3x +3x +3x +  +3x +3x +3x +  +6x +6x +6x +6x +6x +6x +6x +6x +3x +6x +6x +6x +  +1x +5x +5x +5x +5x +5x +2x +2x +2x +  +3x +5x +1x +1x +1x +1x +1x +  +  +  +  +5x +5x +5x +5x +5x +5x +  +20x +20x +20x +  +20x +3x +3x +3x +3x +  +20x +20x +  +20x +20x +  +20x +2x +2x +1x +1x +1x +2x +  +20x +1x +1x +1x +  +20x +2x +2x +2x +2x +2x +16x +20x +4x +4x +4x +20x +  +1x +13x +13x +  +1x +2x +2x +  +1x +1x +1x +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +1x +  +1x +1x +1x +  +1x +1x +1x +  +1x +  +  +  +1x +  +  +  +1x +  +  +  +1x +  +  +  +1x +  +  +  +1x +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +1x +1x +1x +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
const BASE = "/api";
+let token = "";
+ 
+// Current user info populated after auth
+export let currentUser = { username: "", role: "", pages: [] };
+ 
+export function setCurrentUser(u) {
+  currentUser = u;
+}
+ 
+export function setToken(t) {
+  token = t;
+  if (t) localStorage.setItem("token", t);
+  else localStorage.removeItem("token");
+}
+ 
+export function getToken() {
+  return token;
+}
+ 
+// Refresh token is cookie-managed server-side.
+export function setRefreshToken() {}
+ 
+let refreshPromise = null;
+ 
+function getLegacyRefreshToken() {
+  return localStorage.getItem("refresh_token") || "";
+}
+ 
+function clearLegacyRefreshToken() {
+  localStorage.removeItem("refresh_token");
+}
+ 
+async function requestRefresh(body) {
+  const r = await fetch(BASE + "/auth/refresh", {
+    method: "POST",
+    headers: { "Content-Type": "application/json" },
+    credentials: "same-origin",
+    body: body ? JSON.stringify(body) : undefined,
+  });
+  if (!r.ok) return false;
+  const data = await r.json();
+  setToken(data.access_token || "");
+  return !!data.access_token;
+}
+ 
+export async function tryRefreshSession() {
+  if (refreshPromise) return refreshPromise;
+  refreshPromise = (async () => {
+    try {
+      const cookieRefreshed = await requestRefresh();
+      if (cookieRefreshed) {
+        clearLegacyRefreshToken();
+        return true;
+      }
+ 
+      const legacyRefreshToken = getLegacyRefreshToken();
+      if (!legacyRefreshToken) return false;
+      const legacyRefreshed = await requestRefresh({ refresh_token: legacyRefreshToken });
+      if (legacyRefreshed) {
+        clearLegacyRefreshToken();
+        return true;
+      }
+      clearLegacyRefreshToken();
+      return false;
+    } catch {
+      return false;
+    } finally {
+      refreshPromise = null;
+    }
+  })();
+  return refreshPromise;
+}
+ 
+async function request(path, opts = {}) {
+  const headers = opts.headers || {};
+  if (token) headers["Authorization"] = `Bearer ${token}`;
+ 
+  if (opts.json) {
+    headers["Content-Type"] = "application/json";
+    opts.body = JSON.stringify(opts.json);
+    delete opts.json;
+  }
+ 
+  const fetchOpts = { ...opts, headers };
+  if (path.startsWith("/auth/")) fetchOpts.credentials = "same-origin";
+ 
+  try {
+    let r = await fetch(BASE + path, fetchOpts);
+ 
+    if (r.status === 401 && !path.includes("/auth/refresh")) {
+      const refreshed = await tryRefreshSession();
+      if (refreshed) {
+        headers["Authorization"] = `Bearer ${token}`;
+        r = await fetch(BASE + path, fetchOpts);
+      }
+    }
+ 
+    if (r.status === 401) {
+      setToken("");
+      throw new Error("Unauthorized");
+    }
+ 
+    if (!r.ok) {
+      const error = new Error(`HTTP ${r.status}`);
+      error.status = r.status;
+      try { error.body = await r.json(); } catch {}
+      throw error;
+    }
+    return r.json();
+  } catch (e) {
+    console.error(`API ${opts.method || "GET"} ${path}:`, e);
+    throw e;
+  }
+}
+ 
+export function get(path) {
+  return request(path);
+}
+ 
+export function post(path, data) {
+  return request(path, { method: "POST", json: data });
+}
+ 
+export function del(path) {
+  return request(path, { method: "DELETE" });
+}
+ 
+export async function upload(path, file) {
+  const headers = {};
+  if (token) headers["Authorization"] = `Bearer ${token}`;
+ 
+  const form = new FormData();
+  form.append("file", file);
+ 
+  const r = await fetch(`${BASE}/files/upload?path=${encodeURIComponent(path)}`, {
+    method: "POST",
+    headers,
+    body: form,
+  });
+ 
+  if (!r.ok) throw new Error(`HTTP ${r.status}`);
+  return r.json();
+}
+ 
+export function login(creds) {
+  return request("/auth/login", { method: "POST", json: creds });
+}
+ 
+export function checkAuth() {
+  return request("/auth/me");
+}
+ 
+export function logout() {
+  return request("/auth/logout", { method: "POST" });
+}
+ 
+export function getPreferences() {
+  return get("/auth/preferences");
+}
+ 
+export function savePreferences(data) {
+  return request("/auth/preferences", { method: "PUT", json: data });
+}
+ 
+export function getLiteLLMHealth() {
+  return get("/litellm/health");
+}
+ 
+export function getCcConnectHealth() {
+  return get("/cc-connect/health");
+}
+ 
+export function startCcConnect() {
+  return post("/cc-connect/start");
+}
+ 
+export function stopCcConnect() {
+  return post("/cc-connect/stop");
+}
+ 
+export function getInfoEngineItems(params = {}) {
+  const query = new URLSearchParams();
+  if (params.limit !== undefined) query.set("limit", String(params.limit));
+  if (params.offset !== undefined) query.set("offset", String(params.offset));
+  if (params.source) query.set("source", params.source);
+  if (params.tag) query.set("tag", params.tag);
+  if (params.since) query.set("since", params.since);
+  const suffix = query.toString() ? `?${query.toString()}` : "";
+  return get(`/info-engine/items${suffix}`);
+}
+ 
+export function getInfoEngineItem(id) {
+  return get(`/info-engine/items/${id}`);
+}
+ 
+export function put(path, data) {
+  return request(path, { method: "PUT", json: data });
+}
+ 
+export async function download(path, filename) {
+  const headers = {};
+  if (token) headers["Authorization"] = `Bearer ${token}`;
+ 
+  try {
+    // Get a temporary download token
+    const tokenResponse = await fetch(`${BASE}/files/download-token?path=${encodeURIComponent(path)}`, {
+      method: "POST",
+      headers,
+    });
+ 
+    if (!tokenResponse.ok) {
+      const errorText = await tokenResponse.text();
+      console.error(`Download token failed: ${tokenResponse.status} - ${errorText}`);
+      throw new Error(`Download failed: ${tokenResponse.status}`);
+    }
+ 
+    const { token: downloadToken } = await tokenResponse.json();
+ 
+    // Use hidden iframe for download - avoids page navigation and popup blockers
+    const iframe = document.createElement('iframe');
+    iframe.style.display = 'none';
+    iframe.src = `${BASE}/files/download?token=${downloadToken}`;
+    document.body.appendChild(iframe);
+ 
+    // Clean up iframe after download starts
+    setTimeout(() => {
+      document.body.removeChild(iframe);
+    }, 5000);
+  } catch (e) {
+    console.error("Download error:", e);
+    throw e;
+  }
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/src/lib/index.html b/dashboard/frontend/coverage/src/lib/index.html new file mode 100644 index 0000000..acd6863 --- /dev/null +++ b/dashboard/frontend/coverage/src/lib/index.html @@ -0,0 +1,161 @@ + + + + + + Code coverage report for src/lib + + + + + + + + + +
+
+

All files src/lib

+
+ +
+ 22.06% + Statements + 124/562 +
+ + +
+ 84.78% + Branches + 39/46 +
+ + +
+ 51.72% + Functions + 15/29 +
+ + +
+ 22.06% + Lines + 124/562 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
api.js +
+
64.92%124/19190.69%39/4357.69%15/2664.92%124/191
opc-api.js +
+
0%0/640%0/10%0/10%0/64
opc-ws.js +
+
0%0/750%0/10%0/10%0/75
voice.js +
+
0%0/2320%0/10%0/10%0/232
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/src/lib/opc-api.js.html b/dashboard/frontend/coverage/src/lib/opc-api.js.html new file mode 100644 index 0000000..d9a456b --- /dev/null +++ b/dashboard/frontend/coverage/src/lib/opc-api.js.html @@ -0,0 +1,325 @@ + + + + + + Code coverage report for src/lib/opc-api.js + + + + + + + + + +
+
+

All files / src/lib opc-api.js

+
+ +
+ 0% + Statements + 0/64 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/64 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
/**
+ * OPC API Client
+ */
+import { get, post, put, del } from "./api.js";
+ 
+// Tasks
+export async function getTasks(filters = {}) {
+  const params = new URLSearchParams();
+  if (filters.status) params.append("status", filters.status);
+  if (filters.assigned_to) params.append("assigned_to", filters.assigned_to);
+  if (filters.project_id) params.append("project_id", filters.project_id);
+  if (filters.priority) params.append("priority", filters.priority);
+  if (filters.tags) params.append("tags", filters.tags.join(","));
+  if (filters.limit) params.append("limit", filters.limit);
+  if (filters.offset) params.append("offset", filters.offset);
+ 
+  const query = params.toString();
+  return get(`/opc/tasks${query ? "?" + query : ""}`);
+}
+ 
+export async function createTask(task) {
+  return post("/opc/tasks", task);
+}
+ 
+export async function updateTask(taskId, updates) {
+  return put(`/opc/tasks/${taskId}`, updates);
+}
+ 
+export async function deleteTask(taskId) {
+  return del(`/opc/tasks/${taskId}`);
+}
+ 
+export async function moveTask(taskId, status) {
+  return put(`/opc/tasks/${taskId}/move`, { status });
+}
+ 
+export async function assignTask(taskId, assigned_to, assigned_type = "human") {
+  return post(`/opc/tasks/${taskId}/assign`, { assigned_to, assigned_type });
+}
+ 
+export async function getTaskHistory(taskId) {
+  return get(`/opc/tasks/${taskId}/history`);
+}
+ 
+export async function getTaskTime(taskId) {
+  return get(`/opc/tasks/${taskId}/time`);
+}
+ 
+// Agents
+export async function getAgents(enabledOnly = true) {
+  return get(`/opc/agents?enabled_only=${enabledOnly}`);
+}
+ 
+export async function getAgent(agentId) {
+  return get(`/opc/agents/${agentId}`);
+}
+ 
+export async function triggerAgent(agentId, taskId) {
+  return post(`/opc/agents/${agentId}/execute?task_id=${taskId}`);
+}
+ 
+// Executions
+export async function getExecutions(filters = {}) {
+  const params = new URLSearchParams();
+  if (filters.task_id) params.append("task_id", filters.task_id);
+  if (filters.agent_id) params.append("agent_id", filters.agent_id);
+  if (filters.status) params.append("status", filters.status);
+  if (filters.limit) params.append("limit", filters.limit);
+ 
+  const query = params.toString();
+  return get(`/opc/executions${query ? "?" + query : ""}`);
+}
+ 
+export async function getExecution(executionId) {
+  return get(`/opc/executions/${executionId}`);
+}
+ 
+export async function approveExecution(executionId, approved) {
+  return post(`/opc/executions/${executionId}/approve`, { approved });
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/src/lib/opc-ws.js.html b/dashboard/frontend/coverage/src/lib/opc-ws.js.html new file mode 100644 index 0000000..c01cf73 --- /dev/null +++ b/dashboard/frontend/coverage/src/lib/opc-ws.js.html @@ -0,0 +1,352 @@ + + + + + + Code coverage report for src/lib/opc-ws.js + + + + + + + + + +
+
+

All files / src/lib opc-ws.js

+
+ +
+ 0% + Statements + 0/75 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/75 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
/**
+ * OPC WebSocket Client - Real-time updates
+ */
+ 
+import { getToken } from "./api.js";
+ 
+let ws = null;
+let reconnectTimer = null;
+let listeners = new Set();
+ 
+export function connect() {
+  if (ws && ws.readyState === WebSocket.OPEN) {
+    return;
+  }
+ 
+  const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
+  const token = getToken();
+  const tokenParam = token ? `?token=${encodeURIComponent(token)}` : "";
+  const wsUrl = `${protocol}//${window.location.host}/ws/opc${tokenParam}`;
+ 
+  ws = new WebSocket(wsUrl);
+ 
+  ws.onopen = () => {
+    console.log("OPC WebSocket connected");
+    if (reconnectTimer) {
+      clearTimeout(reconnectTimer);
+      reconnectTimer = null;
+    }
+ 
+    // Send ping every 30 seconds to keep connection alive
+    const pingInterval = setInterval(() => {
+      if (ws && ws.readyState === WebSocket.OPEN) {
+        ws.send("ping");
+      } else {
+        clearInterval(pingInterval);
+      }
+    }, 30000);
+  };
+ 
+  ws.onmessage = (event) => {
+    try {
+      const message = JSON.parse(event.data);
+      notifyListeners(message);
+    } catch (e) {
+      console.error("Failed to parse WebSocket message:", e);
+    }
+  };
+ 
+  ws.onerror = (error) => {
+    console.error("WebSocket error:", error);
+  };
+ 
+  ws.onclose = () => {
+    console.log("OPC WebSocket disconnected");
+    ws = null;
+ 
+    // Reconnect after 5 seconds
+    reconnectTimer = setTimeout(() => {
+      console.log("Reconnecting WebSocket...");
+      connect();
+    }, 5000);
+  };
+}
+ 
+export function disconnect() {
+  if (ws) {
+    ws.close();
+    ws = null;
+  }
+  if (reconnectTimer) {
+    clearTimeout(reconnectTimer);
+    reconnectTimer = null;
+  }
+}
+ 
+export function subscribe(callback) {
+  listeners.add(callback);
+  return () => listeners.delete(callback);
+}
+ 
+function notifyListeners(message) {
+  listeners.forEach((callback) => {
+    try {
+      callback(message);
+    } catch (e) {
+      console.error("Listener error:", e);
+    }
+  });
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/src/lib/voice.js.html b/dashboard/frontend/coverage/src/lib/voice.js.html new file mode 100644 index 0000000..22d40a3 --- /dev/null +++ b/dashboard/frontend/coverage/src/lib/voice.js.html @@ -0,0 +1,847 @@ + + + + + + Code coverage report for src/lib/voice.js + + + + + + + + + +
+
+

All files / src/lib voice.js

+
+ +
+ 0% + Statements + 0/232 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/232 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
const LANGS = ["en-US", "zh-CN", "zh-HK"];
+const LANG_LABELS = ["EN", "普", "粤"];
+const TALK_MODES = ["auto", "tap", "hold"];
+const MODE_LABELS = { auto: "Auto", tap: "Tap", hold: "Hold" };
+const NOISE = /^[\s│┃┆┇┊┋─━┄┅┈┉═╌╍╔╗╚╝╠╣╦╩╬├┤┬┴┼╭╮╯╰░▒▓█▌▐▀▄⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏◐◑◒◓⣾⣽⣻⢿⡿⣟⣯⣷⠁⠂⠄⡀⢀⠠⠐⠈\-–—_=+*#~<>/.\\|,;:!?(){}\[\]]+$/;
+const SKIP = /shortcuts|\/help|\/exit|press esc|ctrl[+-]|⌘|⎋|❯|^\$\s|^root@|^claude|^jimmy|^\[.*\]$/i;
+const MODE_COMMANDS = {
+  "hold mode": "hold", "tap mode": "tap", "auto mode": "auto",
+  "按住模式": "hold", "点击模式": "tap", "自动模式": "auto",
+};
+ 
+export class VoiceSession {
+  constructor(onChange) {
+    this.onChange = onChange;
+    this.ttsEnabled = false;
+    this.speaking = false;
+    this.listening = false;
+    this.sttAvailable = !!(window.SpeechRecognition || window.webkitSpeechRecognition);
+    this.langIdx = 0;
+    this.voiceMode = false;
+    this.transcript = "";
+    this.talkMode = "auto";
+    this.voices = [];
+    this.voiceIdx = -1;
+    this.term = null;
+    this.ws = null;
+    this._lastLine = 0;
+    this._debounce = null;
+    this._recog = null;
+    this._onWriteDispose = null;
+    this._ttsUnlocked = false;
+    this._silenceTimer = null;
+  }
+ 
+  get lang() { return LANGS[this.langIdx]; }
+  get langLabel() { return LANG_LABELS[this.langIdx]; }
+  get modeLabel() { return MODE_LABELS[this.talkMode]; }
+  get voiceName() { return this.voices[this.voiceIdx]?.name || "Default"; }
+ 
+  loadVoices() {
+    const all = speechSynthesis.getVoices();
+    const prefix = this.lang.split("-")[0];
+    this.voices = all.filter(v => v.lang.startsWith(prefix));
+    // Try to keep current selection
+    if (this.voiceIdx >= this.voices.length) this.voiceIdx = 0;
+    if (!this.voices.length) this.voiceIdx = -1;
+    this._notify();
+  }
+ 
+  setVoice(idx) {
+    this.voiceIdx = idx;
+    this._notify();
+  }
+ 
+  attach(term, ws) {
+    this.term = term;
+    this.ws = ws;
+    this._lastLine = term.buffer.active.cursorY + term.buffer.active.baseY;
+    this._onWriteDispose = term.onWriteParsed(() => this._onOutput());
+  }
+ 
+  _notify() { this.onChange?.(); }
+ 
+  // --- Voice Mode ---
+  enterVoiceMode() {
+    this.voiceMode = true;
+    this.ttsEnabled = true;
+    if (!this._ttsUnlocked) {
+      speechSynthesis.speak(new SpeechSynthesisUtterance(""));
+      this._ttsUnlocked = true;
+    }
+    this.loadVoices();
+    speechSynthesis.onvoiceschanged = () => this.loadVoices();
+    if (this.term) {
+      this._lastLine = this.term.buffer.active.baseY + this.term.buffer.active.cursorY;
+    }
+    if (this.talkMode === "auto") this._startSTT(true);
+    this._notify();
+  }
+ 
+  exitVoiceMode() {
+    this.voiceMode = false;
+    this.ttsEnabled = false;
+    this.transcript = "";
+    speechSynthesis.cancel();
+    this.speaking = false;
+    this._stopSTT();
+    this._notify();
+  }
+ 
+  setTalkMode(mode) {
+    this.talkMode = mode;
+    this._stopSTT();
+    this.transcript = "";
+    if (mode === "auto" && this.voiceMode) this._startSTT(true);
+    this._notify();
+  }
+ 
+  cycleTalkMode() {
+    const i = (TALK_MODES.indexOf(this.talkMode) + 1) % TALK_MODES.length;
+    this.setTalkMode(TALK_MODES[i]);
+  }
+ 
+  // Hold mode
+  startHold() {
+    if (this.talkMode !== "hold") return;
+    this._startSTT(false);
+  }
+ 
+  releaseHold() {
+    if (this.talkMode !== "hold") return;
+    this._sendAndStop();
+  }
+ 
+  // Tap mode
+  tapMic() {
+    if (this.talkMode !== "tap") return;
+    if (this.listening) { this._sendAndStop(); }
+    else { this._startSTT(false); }
+  }
+ 
+  _sendAndStop() {
+    const text = this.transcript.trim();
+    this._stopSTT();
+    if (text && this.ws?.readyState === 1) this._typeText(text + "\r");
+    this.transcript = "";
+    this._notify();
+  }
+ 
+  // --- STT ---
+  _startSTT(continuous) {
+    const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
+    if (!SR) return;
+    speechSynthesis.cancel();
+    this.speaking = false;
+    this._recog = new SR();
+    this._recog.continuous = continuous;
+    this._recog.interimResults = true;
+    this._recog.lang = this.lang;
+    this._finalText = "";
+    this._recog.onresult = (e) => {
+      let interim = "";
+      for (let i = e.resultIndex; i < e.results.length; i++) {
+        const t = e.results[i][0].transcript;
+        if (e.results[i].isFinal) {
+          // Check voice commands
+          const cmd = MODE_COMMANDS[t.trim().toLowerCase()];
+          if (cmd) { this.setTalkMode(cmd); return; }
+          this._finalText += t;
+        } else { interim = t; }
+      }
+      this.transcript = (this._finalText + interim).trim();
+      this._notify();
+      // Auto mode: reset silence timer on new results
+      if (this.talkMode === "auto" && this._finalText) {
+        clearTimeout(this._silenceTimer);
+        this._silenceTimer = setTimeout(() => this._autoSend(), 2000);
+      }
+    };
+    this._recog.onerror = () => { this.listening = false; this._notify(); };
+    this._recog.onend = () => {
+      if (this.listening && continuous) {
+        // Auto mode: send accumulated text, restart
+        if (this.talkMode === "auto" && this._finalText.trim()) this._autoSend();
+        try { this._recog.start(); } catch { this.listening = false; this._notify(); }
+      } else { this.listening = false; this._notify(); }
+    };
+    this._recog.start();
+    this.listening = true;
+    this._notify();
+  }
+ 
+  _stopSTT() {
+    this.listening = false;
+    clearTimeout(this._silenceTimer);
+    this._recog?.stop();
+    this._recog = null;
+    this._finalText = "";
+  }
+ 
+  _autoSend() {
+    clearTimeout(this._silenceTimer);
+    const text = this._finalText.trim();
+    this._finalText = "";
+    this.transcript = "";
+    if (text && this.ws?.readyState === 1) this._typeText(text + "\r");
+    this._notify();
+  }
+ 
+  // --- TTS ---
+  _onOutput() {
+    if (!this.ttsEnabled || this.transcript) return;
+    clearTimeout(this._debounce);
+    this._debounce = setTimeout(() => this._readBuffer(), 2000);
+  }
+ 
+  _readBuffer() {
+    const buf = this.term?.buffer?.active;
+    if (!buf || buf.type !== "normal") return;
+    const curLine = buf.baseY + buf.cursorY;
+    const lines = [];
+    for (let y = this._lastLine + 1; y <= curLine; y++) {
+      const row = buf.getLine(y);
+      if (!row) continue;
+      const text = row.translateToString(true).trim();
+      if (text.length >= 4 && !NOISE.test(text) && !SKIP.test(text)) lines.push(text);
+    }
+    this._lastLine = curLine;
+    if (lines.length) this._speak(lines.join(". "));
+  }
+ 
+  _speak(text) {
+    if (!text) return;
+    speechSynthesis.cancel();
+    const u = new SpeechSynthesisUtterance(text);
+    u.lang = this.lang;
+    if (this.voices[this.voiceIdx]) u.voice = this.voices[this.voiceIdx];
+    u.onstart = () => { this.speaking = true; this._notify(); };
+    u.onend = () => { this.speaking = false; this._notify(); };
+    u.onerror = () => { this.speaking = false; this._notify(); };
+    speechSynthesis.speak(u);
+  }
+ 
+  _typeText(text) {
+    let i = 0;
+    const next = () => {
+      if (i < text.length && this.ws?.readyState === 1) {
+        this.ws.send(new TextEncoder().encode(text[i++]));
+        setTimeout(next, 20);
+      }
+    };
+    next();
+  }
+ 
+  cycleLang() {
+    this.langIdx = (this.langIdx + 1) % LANGS.length;
+    this.loadVoices();
+    if (this.listening) {
+      const wasContinuous = this._recog?.continuous;
+      this._stopSTT();
+      this._startSTT(!!wasContinuous);
+    }
+    this._notify();
+  }
+ 
+  destroy() {
+    clearTimeout(this._debounce);
+    clearTimeout(this._silenceTimer);
+    this._onWriteDispose?.dispose();
+    this._stopSTT();
+    speechSynthesis.cancel();
+    this.term = null;
+    this.ws = null;
+  }
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/src/main.js.html b/dashboard/frontend/coverage/src/main.js.html new file mode 100644 index 0000000..3761d6b --- /dev/null +++ b/dashboard/frontend/coverage/src/main.js.html @@ -0,0 +1,103 @@ + + + + + + Code coverage report for src/main.js + + + + + + + + + +
+
+

All files / src main.js

+
+ +
+ 0% + Statements + 0/5 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/5 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7  +  +  +  +  +  + 
import "./app.css";
+import App from "./App.svelte";
+import { mount } from "svelte";
+ 
+const app = mount(App, { target: document.getElementById("app") });
+export default app;
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/src/routes/CcConnect.svelte.html b/dashboard/frontend/coverage/src/routes/CcConnect.svelte.html new file mode 100644 index 0000000..413f0f2 --- /dev/null +++ b/dashboard/frontend/coverage/src/routes/CcConnect.svelte.html @@ -0,0 +1,550 @@ + + + + + + Code coverage report for src/routes/CcConnect.svelte + + + + + + + + + +
+
+

All files / src/routes CcConnect.svelte

+
+ +
+ 0% + Statements + 0/87 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/87 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
<script>
+  import { onMount } from "svelte";
+  import { getCcConnectHealth, startCcConnect, stopCcConnect } from "../lib/api.js";
+ 
+  let loading = $state(true);
+  let health = $state(null);
+  let requestError = $state("");
+  let actionLoading = $state("");
+ 
+  async function loadHealth() {
+    loading = true;
+    requestError = "";
+    try {
+      health = await getCcConnectHealth();
+    } catch (e) {
+      requestError = e?.body?.detail || e?.message || "Failed to load cc-connect health";
+      health = null;
+    } finally {
+      loading = false;
+    }
+  }
+ 
+  async function runAction(action) {
+    actionLoading = action;
+    requestError = "";
+    try {
+      const result = action === "start" ? await startCcConnect() : await stopCcConnect();
+      if (!result?.ok) {
+        requestError = result?.error || `Failed to ${action} cc-connect`;
+      }
+    } catch (e) {
+      requestError = e?.body?.detail || e?.message || `Failed to ${action} cc-connect`;
+    } finally {
+      actionLoading = "";
+      await loadHealth();
+    }
+  }
+ 
+  function statusBadgeClass(status) {
+    if (status === "up") {
+      return "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300";
+    }
+    return "bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300";
+  }
+ 
+  function valueOrDash(value) {
+    return value === null || value === undefined || value === "" ? "—" : value;
+  }
+ 
+  onMount(loadHealth);
+</script>
+ 
+<div class="space-y-6">
+  <div class="flex items-center justify-between">
+    <div>
+      <h1 class="text-2xl font-bold text-surface-900 dark:text-white tracking-tight">cc-connect</h1>
+      <p class="text-sm text-surface-400 mt-1">Mobile bridge operational status</p>
+      <div class="mt-2 flex flex-wrap items-center gap-2 text-[11px] font-medium">
+        <span class="px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">up</span>
+        <span class="px-2 py-0.5 rounded-full bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300">down</span>
+      </div>
+    </div>
+    <div class="flex items-center gap-2">
+      {#if health?.container_status === "running"}
+        <button
+          onclick={() => runAction("stop")}
+          disabled={loading || actionLoading === "stop"}
+          class="px-3 py-1.5 text-xs font-medium text-rose-600 bg-rose-50 hover:bg-rose-100 rounded-lg transition-colors disabled:opacity-50"
+        >
+          {actionLoading === "stop" ? "Stopping..." : "Stop"}
+        </button>
+      {:else}
+        <button
+          onclick={() => runAction("start")}
+          disabled={loading || actionLoading === "start"}
+          class="px-3 py-1.5 text-xs font-medium text-emerald-600 bg-emerald-50 hover:bg-emerald-100 rounded-lg transition-colors disabled:opacity-50"
+        >
+          {actionLoading === "start" ? "Starting..." : "Start"}
+        </button>
+      {/if}
+      <button onclick={loadHealth} disabled={loading || Boolean(actionLoading)} class="px-3 py-1.5 text-xs font-medium text-primary-600 bg-primary-50 hover:bg-primary-100 rounded-lg transition-colors disabled:opacity-50 dark:bg-primary-900/30 dark:text-primary-300">
+        {loading ? "Loading..." : "Refresh"}
+      </button>
+    </div>
+  </div>
+ 
+  {#if requestError}
+    <div class="bg-rose-50 dark:bg-rose-900/20 border border-rose-200 dark:border-rose-700 rounded-xl p-4 shadow-sm">
+      <p class="text-sm font-medium text-rose-700 dark:text-rose-300">cc-connect action failed</p>
+      <p class="text-xs text-rose-600 dark:text-rose-400 mt-1">{requestError}</p>
+    </div>
+  {/if}
+ 
+  <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
+    {#if loading && !health}
+      <div class="space-y-3">
+        <div class="h-6 w-36 rounded bg-surface-100 dark:bg-surface-700 animate-pulse"></div>
+        <div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
+          {#each Array(8) as _}
+            <div class="h-16 rounded-lg bg-surface-100 dark:bg-surface-700 animate-pulse"></div>
+          {/each}
+        </div>
+      </div>
+    {:else if health}
+      <div class="flex items-center justify-between mb-4">
+        <h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200">Health Status</h3>
+        <span class="px-2.5 py-1 text-xs font-semibold rounded-full {statusBadgeClass(health.status)}">{valueOrDash(health.status)}</span>
+      </div>
+ 
+      <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
+        <div class="rounded-lg border border-surface-200 dark:border-surface-700 p-3">
+          <p class="text-xs font-medium text-surface-400 uppercase tracking-wider">OK</p>
+          <p class="text-sm font-semibold text-surface-800 dark:text-surface-100 mt-1">{String(Boolean(health.ok))}</p>
+        </div>
+ 
+        <div class="rounded-lg border border-surface-200 dark:border-surface-700 p-3">
+          <p class="text-xs font-medium text-surface-400 uppercase tracking-wider">Source</p>
+          <p class="text-sm font-semibold text-surface-800 dark:text-surface-100 mt-1">{valueOrDash(health.source)}</p>
+        </div>
+ 
+        <div class="rounded-lg border border-surface-200 dark:border-surface-700 p-3">
+          <p class="text-xs font-medium text-surface-400 uppercase tracking-wider">Container</p>
+          <p class="text-sm font-semibold text-surface-800 dark:text-surface-100 mt-1">{valueOrDash(health.container_name)}</p>
+        </div>
+ 
+        <div class="rounded-lg border border-surface-200 dark:border-surface-700 p-3">
+          <p class="text-xs font-medium text-surface-400 uppercase tracking-wider">Container Status</p>
+          <p class="text-sm font-semibold text-surface-800 dark:text-surface-100 mt-1">{valueOrDash(health.container_status)}</p>
+        </div>
+ 
+        <div class="rounded-lg border border-surface-200 dark:border-surface-700 p-3">
+          <p class="text-xs font-medium text-surface-400 uppercase tracking-wider">Endpoint</p>
+          <p class="text-sm font-semibold text-surface-800 dark:text-surface-100 mt-1 break-all">{valueOrDash(health.endpoint)}</p>
+        </div>
+ 
+        <div class="rounded-lg border border-surface-200 dark:border-surface-700 p-3">
+          <p class="text-xs font-medium text-surface-400 uppercase tracking-wider">HTTP Status</p>
+          <p class="text-sm font-semibold text-surface-800 dark:text-surface-100 mt-1">{valueOrDash(health.http_status)}</p>
+        </div>
+ 
+        <div class="rounded-lg border border-surface-200 dark:border-surface-700 p-3">
+          <p class="text-xs font-medium text-surface-400 uppercase tracking-wider">Latency</p>
+          <p class="text-sm font-semibold text-surface-800 dark:text-surface-100 mt-1">{health.latency_ms === null || health.latency_ms === undefined ? "—" : `${health.latency_ms} ms`}</p>
+        </div>
+ 
+        <div class="rounded-lg border border-surface-200 dark:border-surface-700 p-3 sm:col-span-2 lg:col-span-2">
+          <p class="text-xs font-medium text-surface-400 uppercase tracking-wider">Error</p>
+          <p class="text-sm font-semibold text-surface-800 dark:text-surface-100 mt-1 break-all">{valueOrDash(health.error)}</p>
+        </div>
+      </div>
+    {:else}
+      <p class="text-sm text-surface-400">No health data available.</p>
+    {/if}
+  </div>
+</div>
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/src/routes/ChatSummary.svelte.html b/dashboard/frontend/coverage/src/routes/ChatSummary.svelte.html new file mode 100644 index 0000000..f5032c4 --- /dev/null +++ b/dashboard/frontend/coverage/src/routes/ChatSummary.svelte.html @@ -0,0 +1,454 @@ + + + + + + Code coverage report for src/routes/ChatSummary.svelte + + + + + + + + + +
+
+

All files / src/routes ChatSummary.svelte

+
+ +
+ 0% + Statements + 0/91 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/91 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
<script>
+  import { onMount } from "svelte";
+  import { get, post } from "../lib/api.js";
+ 
+  let dates = $state([]);
+  let selectedDate = $state("");
+  let summary = $state(null);
+  let messages = $state([]);
+  let showMessages = $state(false);
+  let loading = $state(false);
+  let triggering = $state(false);
+  let error = $state("");
+ 
+  onMount(async () => {
+    try {
+      dates = await get("/api/chat-summary/dates");
+      if (dates.length) selectDate(dates[0]);
+    } catch (e) {
+      error = e.body?.detail || "Failed to load dates";
+    }
+  });
+ 
+  async function selectDate(d) {
+    selectedDate = d;
+    showMessages = false;
+    loading = true;
+    error = "";
+    try {
+      summary = await get(`/api/chat-summary/summary/${d}`);
+    } catch (e) {
+      summary = null;
+      if (e.status !== 404) error = e.body?.detail || "Failed to load summary";
+    } finally {
+      loading = false;
+    }
+  }
+ 
+  async function loadMessages() {
+    if (showMessages) { showMessages = false; return; }
+    try {
+      messages = await get(`/api/chat-summary/messages/${selectedDate}`);
+      showMessages = true;
+    } catch (e) {
+      error = "Failed to load messages";
+    }
+  }
+ 
+  async function trigger() {
+    triggering = true;
+    try {
+      await post("/api/chat-summary/trigger");
+    } catch (e) {
+      error = "Trigger failed";
+    } finally {
+      triggering = false;
+    }
+  }
+ 
+  function markdownToHtml(md) {
+    if (!md) return "";
+    return md
+      .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
+      .replace(/^### (.+)$/gm, '<h3 class="text-lg font-semibold mt-4 mb-2">$1</h3>')
+      .replace(/^## (.+)$/gm, '<h2 class="text-xl font-bold mt-5 mb-2">$1</h2>')
+      .replace(/^# (.+)$/gm, '<h1 class="text-2xl font-bold mt-6 mb-3">$1</h1>')
+      .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
+      .replace(/\*(.+?)\*/g, '<em>$1</em>')
+      .replace(/^- (.+)$/gm, '<li class="ml-4">$1</li>')
+      .replace(/\n{2,}/g, '<br><br>')
+      .replace(/\n/g, '<br>');
+  }
+</script>
+ 
+<div>
+  <div class="flex items-center justify-between mb-6">
+    <h2 class="text-xl font-bold text-surface-800 dark:text-surface-100">Chat Digest</h2>
+    <button onclick={trigger} disabled={triggering}
+      class="px-3 py-1.5 text-sm font-medium rounded-lg bg-primary-600 text-white hover:bg-primary-700 disabled:opacity-50">
+      {triggering ? "Generating..." : "Generate Now"}
+    </button>
+  </div>
+ 
+  {#if error}
+    <div class="mb-4 p-3 rounded-lg bg-red-50 text-red-700 text-sm dark:bg-red-900/20 dark:text-red-400">{error}</div>
+  {/if}
+ 
+  <div class="flex gap-2 mb-6 flex-wrap">
+    {#each dates as d}
+      <button onclick={() => selectDate(d)}
+        class="px-3 py-1.5 text-sm rounded-lg transition-colors {selectedDate === d ? 'bg-primary-600 text-white' : 'bg-surface-100 text-surface-600 hover:bg-surface-200 dark:bg-surface-700 dark:text-surface-300 dark:hover:bg-surface-600'}">
+        {d}
+      </button>
+    {/each}
+    {#if !dates.length && !error}
+      <p class="text-sm text-surface-400">No summaries yet.</p>
+    {/if}
+  </div>
+ 
+  {#if loading}
+    <div class="flex justify-center py-12"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div></div>
+  {:else if summary}
+    <div class="prose dark:prose-invert max-w-none bg-white dark:bg-surface-800 rounded-xl p-6 shadow-sm border border-surface-200 dark:border-surface-700">
+      {@html markdownToHtml(summary.content)}
+    </div>
+ 
+    <button onclick={loadMessages}
+      class="mt-4 text-sm text-primary-600 hover:text-primary-700 dark:text-primary-400">
+      {showMessages ? "Hide raw messages" : "Show raw messages"}
+    </button>
+ 
+    {#if showMessages}
+      <div class="mt-3 bg-white dark:bg-surface-800 rounded-xl p-4 shadow-sm border border-surface-200 dark:border-surface-700 max-h-96 overflow-y-auto">
+        {#each messages as m}
+          <div class="py-1.5 border-b border-surface-100 dark:border-surface-700 last:border-0 text-sm">
+            <span class="text-surface-400 text-xs">{m.time?.slice(11,16)}</span>
+            <span class="font-medium text-primary-600 dark:text-primary-400 ml-1">[{m.group}] {m.sender}:</span>
+            <span class="text-surface-700 dark:text-surface-300 ml-1">{m.text}</span>
+          </div>
+        {/each}
+      </div>
+    {/if}
+  {/if}
+</div>
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/src/routes/Conversations.svelte.html b/dashboard/frontend/coverage/src/routes/Conversations.svelte.html new file mode 100644 index 0000000..e00af43 --- /dev/null +++ b/dashboard/frontend/coverage/src/routes/Conversations.svelte.html @@ -0,0 +1,1003 @@ + + + + + + Code coverage report for src/routes/Conversations.svelte + + + + + + + + + +
+
+

All files / src/routes Conversations.svelte

+
+ +
+ 0% + Statements + 0/201 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/201 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
<script>
+  import { onMount } from "svelte";
+  import { get, post } from "../lib/api.js";
+ 
+  let dates = $state([]);
+  let selectedDate = $state("");
+  let summary = $state(null);
+  let conversations = $state([]);
+  let selectedConversation = $state(null);
+  let messages = $state([]);
+  let searchQuery = $state("");
+  let searchResults = $state([]);
+  let stats = $state(null);
+  let loading = $state(false);
+  let triggering = $state(false);
+  let error = $state("");
+  let view = $state("summary"); // summary, list, detail, search, stats
+ 
+  onMount(async () => {
+    try {
+      dates = await get("/conversations/dates");
+      if (dates.length) selectDate(dates[0]);
+      loadStats();
+    } catch (e) {
+      error = e.body?.detail || "Failed to load dates";
+    }
+  });
+ 
+  async function selectDate(d) {
+    selectedDate = d;
+    view = "summary";
+    loading = true;
+    error = "";
+    try {
+      summary = await get(`/conversations/summary/${d}`);
+      conversations = await get(`/conversations/list?date=${d}`);
+    } catch (e) {
+      summary = null;
+      if (e.status !== 404) error = e.body?.detail || "Failed to load summary";
+    } finally {
+      loading = false;
+    }
+  }
+ 
+  async function selectConversation(sessionId) {
+    view = "detail";
+    loading = true;
+    try {
+      selectedConversation = await get(`/conversations/${sessionId}`);
+      messages = await get(`/conversations/${sessionId}/messages`);
+    } catch (e) {
+      error = "Failed to load conversation";
+    } finally {
+      loading = false;
+    }
+  }
+ 
+  async function search() {
+    if (!searchQuery || searchQuery.length < 2) return;
+    view = "search";
+    loading = true;
+    try {
+      searchResults = await get(`/conversations/search?q=${encodeURIComponent(searchQuery)}`);
+    } catch (e) {
+      error = "Search failed";
+    } finally {
+      loading = false;
+    }
+  }
+ 
+  async function loadStats() {
+    try {
+      stats = await get("/conversations/stats");
+    } catch (e) {
+      console.error("Failed to load stats", e);
+    }
+  }
+ 
+  async function trigger() {
+    triggering = true;
+    try {
+      await post("/conversations/trigger");
+      setTimeout(() => selectDate(selectedDate), 5000);
+    } catch (e) {
+      error = "Trigger failed";
+    } finally {
+      triggering = false;
+    }
+  }
+ 
+  function markdownToHtml(md) {
+    if (!md) return "";
+    return md
+      .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
+      .replace(/^### (.+)$/gm, '<h3 class="text-lg font-semibold mt-4 mb-2">$1</h3>')
+      .replace(/^## (.+)$/gm, '<h2 class="text-xl font-bold mt-5 mb-2">$1</h2>')
+      .replace(/^# (.+)$/gm, '<h1 class="text-2xl font-bold mt-6 mb-3">$1</h1>')
+      .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
+      .replace(/\*(.+?)\*/g, '<em>$1</em>')
+      .replace(/^- (.+)$/gm, '<li class="ml-4">$1</li>')
+      .replace(/\n{2,}/g, '<br><br>')
+      .replace(/\n/g, '<br>');
+  }
+ 
+  function formatTokens(n) {
+    return n ? n.toLocaleString() : "0";
+  }
+</script>
+ 
+<div>
+  <div class="flex items-center justify-between mb-6">
+    <h2 class="text-xl font-bold text-surface-800 dark:text-surface-100">Code Sessions</h2>
+    <div class="flex gap-2">
+      <button onclick={() => view = "stats"} class="px-3 py-1.5 text-sm font-medium rounded-lg bg-surface-100 hover:bg-surface-200 dark:bg-surface-700 dark:hover:bg-surface-600 transition-colors">
+        Stats
+      </button>
+      <button onclick={trigger} disabled={triggering}
+        class="px-3 py-1.5 text-sm font-medium rounded-lg bg-primary-600 text-white hover:bg-primary-700 disabled:opacity-50">
+        {triggering ? "Generating..." : "Generate Summary"}
+      </button>
+    </div>
+  </div>
+ 
+  {#if error}
+    <div class="mb-4 p-3 rounded-lg bg-red-50 text-red-700 text-sm dark:bg-red-900/20 dark:text-red-400">{error}</div>
+  {/if}
+ 
+  <!-- Search Bar -->
+  <div class="mb-4">
+    <input
+      type="text"
+      bind:value={searchQuery}
+      onkeydown={(e) => e.key === "Enter" && search()}
+      placeholder="Search conversations..."
+      class="w-full px-4 py-2 text-sm border border-surface-200 dark:border-surface-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-surface-700 dark:text-white"
+    />
+  </div>
+ 
+  <!-- Date Selector -->
+  {#if view !== "search" && view !== "stats"}
+    <div class="flex gap-2 mb-6 flex-wrap">
+      {#each dates as d}
+        <button onclick={() => selectDate(d)}
+          class="px-3 py-1.5 text-sm rounded-lg transition-colors {selectedDate === d ? 'bg-primary-600 text-white' : 'bg-surface-100 text-surface-600 hover:bg-surface-200 dark:bg-surface-700 dark:text-surface-300 dark:hover:bg-surface-600'}">
+          {d}
+        </button>
+      {/each}
+      {#if !dates.length && !error}
+        <p class="text-sm text-surface-400">No conversations yet.</p>
+      {/if}
+    </div>
+  {/if}
+ 
+  {#if loading}
+    <div class="flex justify-center py-12"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div></div>
+  {:else if view === "summary" && summary}
+    <!-- Summary View -->
+    <div class="mb-4 grid grid-cols-4 gap-3">
+      <div class="bg-white dark:bg-surface-800 rounded-lg p-3 border border-surface-200 dark:border-surface-700">
+        <div class="text-xs text-surface-400">Conversations</div>
+        <div class="text-xl font-bold text-surface-900 dark:text-white">{summary.conversation_count}</div>
+      </div>
+      <div class="bg-white dark:bg-surface-800 rounded-lg p-3 border border-surface-200 dark:border-surface-700">
+        <div class="text-xs text-surface-400">Messages</div>
+        <div class="text-xl font-bold text-surface-900 dark:text-white">{summary.total_messages}</div>
+      </div>
+      <div class="bg-white dark:bg-surface-800 rounded-lg p-3 border border-surface-200 dark:border-surface-700">
+        <div class="text-xs text-surface-400">Tokens</div>
+        <div class="text-xl font-bold text-surface-900 dark:text-white">{formatTokens(summary.total_tokens)}</div>
+      </div>
+      <div class="bg-white dark:bg-surface-800 rounded-lg p-3 border border-surface-200 dark:border-surface-700">
+        <div class="text-xs text-surface-400">Projects</div>
+        <div class="text-sm font-medium text-surface-900 dark:text-white truncate">{summary.projects?.split(',')[0] || 'N/A'}</div>
+      </div>
+    </div>
+ 
+    <div class="prose dark:prose-invert max-w-none bg-white dark:bg-surface-800 rounded-xl p-6 shadow-sm border border-surface-200 dark:border-surface-700 mb-4">
+      {@html markdownToHtml(summary.content)}
+    </div>
+ 
+    <!-- Conversation List -->
+    <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 shadow-sm overflow-hidden">
+      <div class="px-4 py-2 text-xs font-semibold uppercase tracking-wider text-surface-400 border-b border-surface-100 dark:border-surface-700">
+        Conversations ({conversations.length})
+      </div>
+      {#each conversations as conv}
+        <button
+          onclick={() => selectConversation(conv.session_id)}
+          class="w-full px-4 py-3 hover:bg-surface-50 dark:hover:bg-surface-700 border-b border-surface-100 dark:border-surface-700 last:border-0 transition-colors text-left">
+          <div class="flex items-center justify-between">
+            <div class="flex-1 min-w-0">
+              <div class="text-sm font-medium text-surface-700 dark:text-surface-200 truncate">{conv.slug || 'Untitled'}</div>
+              <div class="text-xs text-surface-400 truncate">{conv.project_path?.split('/').pop() || 'unknown'}</div>
+            </div>
+            <div class="flex items-center gap-3 text-xs text-surface-400">
+              <span>{conv.message_count} msgs</span>
+              <span>{formatTokens(conv.total_tokens)} tokens</span>
+            </div>
+          </div>
+        </button>
+      {/each}
+    </div>
+ 
+  {:else if view === "detail" && selectedConversation}
+    <!-- Conversation Detail -->
+    <button onclick={() => view = "summary"} class="mb-4 text-sm text-primary-600 hover:text-primary-700">← Back to summary</button>
+ 
+    <div class="bg-white dark:bg-surface-800 rounded-xl p-4 border border-surface-200 dark:border-surface-700 mb-4">
+      <h3 class="text-lg font-bold text-surface-900 dark:text-white mb-2">{selectedConversation.slug || 'Untitled'}</h3>
+      <div class="grid grid-cols-2 gap-2 text-sm">
+        <div><span class="text-surface-400">Project:</span> <span class="text-surface-700 dark:text-surface-300">{selectedConversation.project_path?.split('/').pop()}</span></div>
+        <div><span class="text-surface-400">Branch:</span> <span class="text-surface-700 dark:text-surface-300">{selectedConversation.git_branch || 'N/A'}</span></div>
+        <div><span class="text-surface-400">Messages:</span> <span class="text-surface-700 dark:text-surface-300">{selectedConversation.message_count}</span></div>
+        <div><span class="text-surface-400">Tokens:</span> <span class="text-surface-700 dark:text-surface-300">{formatTokens(selectedConversation.total_input_tokens + selectedConversation.total_output_tokens)}</span></div>
+      </div>
+    </div>
+ 
+    <!-- Messages -->
+    <div class="space-y-3">
+      {#each messages as msg}
+        <div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
+          <div class="flex items-center justify-between mb-2">
+            <span class="text-xs font-semibold uppercase {msg.role === 'user' ? 'text-blue-600' : 'text-green-600'}">{msg.role}</span>
+            <span class="text-xs text-surface-400">{msg.timestamp?.slice(11, 19)}</span>
+          </div>
+          <div class="text-sm text-surface-700 dark:text-surface-300 whitespace-pre-wrap">{msg.content?.slice(0, 500)}{msg.content?.length > 500 ? '...' : ''}</div>
+          {#if msg.tool_calls?.length}
+            <div class="mt-2 pt-2 border-t border-surface-100 dark:border-surface-700">
+              <div class="text-xs font-medium text-surface-400 mb-1">Tools: {msg.tool_calls.map(t => t.name).join(', ')}</div>
+            </div>
+          {/if}
+          {#if msg.input_tokens || msg.output_tokens}
+            <div class="mt-2 text-xs text-surface-400">
+              {msg.input_tokens ? `${msg.input_tokens} in` : ''} {msg.output_tokens ? `${msg.output_tokens} out` : ''}
+            </div>
+          {/if}
+        </div>
+      {/each}
+    </div>
+ 
+  {:else if view === "search"}
+    <!-- Search Results -->
+    <button onclick={() => view = "summary"} class="mb-4 text-sm text-primary-600 hover:text-primary-700">← Back</button>
+    <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 shadow-sm overflow-hidden">
+      <div class="px-4 py-2 text-xs font-semibold uppercase tracking-wider text-surface-400 border-b border-surface-100 dark:border-surface-700">
+        Search Results ({searchResults.length})
+      </div>
+      {#each searchResults as conv}
+        <button
+          onclick={() => selectConversation(conv.session_id)}
+          class="w-full px-4 py-3 hover:bg-surface-50 dark:hover:bg-surface-700 border-b border-surface-100 dark:border-surface-700 last:border-0 transition-colors text-left">
+          <div class="flex items-center justify-between">
+            <div class="flex-1 min-w-0">
+              <div class="text-sm font-medium text-surface-700 dark:text-surface-200 truncate">{conv.slug || 'Untitled'}</div>
+              <div class="text-xs text-surface-400">{conv.started_at?.slice(0, 10)} • {conv.project_path?.split('/').pop()}</div>
+            </div>
+            <div class="text-xs text-surface-400">{conv.message_count} msgs</div>
+          </div>
+        </button>
+      {/each}
+      {#if searchResults.length === 0}
+        <div class="px-4 py-8 text-center text-sm text-surface-400">No results found</div>
+      {/if}
+    </div>
+ 
+  {:else if view === "stats" && stats}
+    <!-- Statistics -->
+    <button onclick={() => view = "summary"} class="mb-4 text-sm text-primary-600 hover:text-primary-700">← Back</button>
+    <div class="grid grid-cols-3 gap-4 mb-6">
+      <div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
+        <div class="text-sm text-surface-400">Total Conversations</div>
+        <div class="text-2xl font-bold text-surface-900 dark:text-white">{stats.total_conversations}</div>
+      </div>
+      <div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
+        <div class="text-sm text-surface-400">Total Messages</div>
+        <div class="text-2xl font-bold text-surface-900 dark:text-white">{stats.total_messages}</div>
+      </div>
+      <div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
+        <div class="text-sm text-surface-400">Total Tokens</div>
+        <div class="text-2xl font-bold text-surface-900 dark:text-white">{formatTokens(stats.total_tokens)}</div>
+      </div>
+    </div>
+ 
+    <div class="grid grid-cols-2 gap-4">
+      <div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
+        <h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200 mb-3">Top Projects</h3>
+        {#each stats.top_projects as proj}
+          <div class="flex justify-between text-sm py-1">
+            <span class="text-surface-600 dark:text-surface-300 truncate">{proj.path?.split('/').pop()}</span>
+            <span class="text-surface-400">{proj.count}</span>
+          </div>
+        {/each}
+      </div>
+ 
+      <div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
+        <h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200 mb-3">Top Tools</h3>
+        {#each stats.top_tools as tool}
+          <div class="flex justify-between text-sm py-1">
+            <span class="text-surface-600 dark:text-surface-300">{tool.name}</span>
+            <span class="text-surface-400">{tool.count}</span>
+          </div>
+        {/each}
+      </div>
+    </div>
+  {/if}
+</div>
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/src/routes/Dashboard.svelte.html b/dashboard/frontend/coverage/src/routes/Dashboard.svelte.html new file mode 100644 index 0000000..77e7b51 --- /dev/null +++ b/dashboard/frontend/coverage/src/routes/Dashboard.svelte.html @@ -0,0 +1,622 @@ + + + + + + Code coverage report for src/routes/Dashboard.svelte + + + + + + + + + +
+
+

All files / src/routes Dashboard.svelte

+
+ +
+ 0% + Statements + 0/87 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/87 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
<script>
+  import { onMount, onDestroy } from "svelte";
+  import { get } from "../lib/api.js";
+ 
+  let containers = $state([]);
+  let repos = $state([]);
+  let stats = $state(null);
+  let loading = $state(true);
+ 
+  async function load() {
+    const [c, g, s] = await Promise.all([
+      get("/docker/containers"),
+      get("/gitea/repos"),
+      get("/system/stats"),
+    ]);
+    containers = c || [];
+    repos = Array.isArray(g) ? g : g?.data || [];
+    stats = s;
+    loading = false;
+  }
+ 
+  function fmtBytes(b) {
+    if (!b) return "0 B";
+    const u = ["B", "KB", "MB", "GB", "TB"];
+    const i = Math.floor(Math.log(b) / Math.log(1024));
+    return (b / Math.pow(1024, i)).toFixed(1) + " " + u[i];
+  }
+ 
+  function pctColor(pct) {
+    if (pct > 90) return "text-rose-500";
+    if (pct > 70) return "text-amber-500";
+    return "text-emerald-500";
+  }
+ 
+  let interval;
+ 
+  onMount(() => { load(); interval = setInterval(load, 10000); });
+  onDestroy(() => clearInterval(interval));
+</script>
+ 
+<div class="space-y-8">
+  <div>
+    <h1 class="text-2xl font-bold text-surface-900 dark:text-white tracking-tight">Overview</h1>
+    <p class="text-sm text-surface-400 mt-1">System status at a glance</p>
+  </div>
+ 
+  {#if loading}
+    <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
+      {#each Array(4) as _}
+        <div class="h-[120px] rounded-xl bg-surface-100 dark:bg-surface-800 animate-pulse"></div>
+      {/each}
+    </div>
+  {:else}
+    <!-- Stats Cards -->
+    <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
+      <!-- CPU -->
+      <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm hover:shadow-md transition-shadow duration-200">
+        <div class="flex items-center justify-between mb-3">
+          <p class="text-xs font-semibold uppercase tracking-wider text-surface-400">CPU</p>
+          <div class="w-8 h-8 rounded-lg bg-sky-50 flex items-center justify-center">
+            <svg class="w-4 h-4 text-sky-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
+          </div>
+        </div>
+        <p class="text-2xl font-bold {pctColor(stats?.cpu_percent || 0)}">{stats?.cpu_percent || 0}%</p>
+        <p class="text-xs text-surface-400 mt-1">{stats?.cpu_count || 0} cores · Load {stats?.load_avg?.[0] || 0}</p>
+      </div>
+ 
+      <!-- Memory -->
+      <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm hover:shadow-md transition-shadow duration-200">
+        <div class="flex items-center justify-between mb-3">
+          <p class="text-xs font-semibold uppercase tracking-wider text-surface-400">Memory</p>
+          <div class="w-8 h-8 rounded-lg bg-violet-50 flex items-center justify-center">
+            <svg class="w-4 h-4 text-violet-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
+          </div>
+        </div>
+        <p class="text-2xl font-bold {pctColor(stats?.memory?.percent || 0)}">{stats?.memory?.percent || 0}%</p>
+        <p class="text-xs text-surface-400 mt-1">{fmtBytes(stats?.memory?.used)} / {fmtBytes(stats?.memory?.total)}</p>
+      </div>
+ 
+      <!-- Disk -->
+      <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm hover:shadow-md transition-shadow duration-200">
+        <div class="flex items-center justify-between mb-3">
+          <p class="text-xs font-semibold uppercase tracking-wider text-surface-400">Disk</p>
+          <div class="w-8 h-8 rounded-lg bg-amber-50 flex items-center justify-center">
+            <svg class="w-4 h-4 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" /></svg>
+          </div>
+        </div>
+        <p class="text-2xl font-bold {pctColor(stats?.disk?.percent || 0)}">{stats?.disk?.percent || 0}%</p>
+        <p class="text-xs text-surface-400 mt-1">{fmtBytes(stats?.disk?.free)} free of {fmtBytes(stats?.disk?.total)}</p>
+      </div>
+ 
+      <!-- Uptime -->
+      <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm hover:shadow-md transition-shadow duration-200">
+        <div class="flex items-center justify-between mb-3">
+          <p class="text-xs font-semibold uppercase tracking-wider text-surface-400">Uptime</p>
+          <div class="w-8 h-8 rounded-lg bg-emerald-50 flex items-center justify-center">
+            <svg class="w-4 h-4 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
+          </div>
+        </div>
+        <p class="text-2xl font-bold text-surface-800 dark:text-surface-100">{stats?.uptime || "—"}</p>
+        <p class="text-xs text-surface-400 mt-1 truncate" title={stats?.platform}>{stats?.platform || "—"}</p>
+      </div>
+    </div>
+ 
+    <!-- Services Grid -->
+    <div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
+      <!-- Docker Summary -->
+      <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
+        <div class="flex items-center justify-between mb-4">
+          <h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200">Docker Containers</h3>
+          <span class="text-xs font-medium text-emerald-600 bg-emerald-50 px-2 py-0.5 rounded-full">{containers.filter(c => c.status === "running").length}/{containers.length} running</span>
+        </div>
+        <div class="space-y-2">
+          {#each containers.slice(0, 5) as c}
+            <div class="flex items-center justify-between py-1.5 text-sm">
+              <div class="flex items-center gap-2">
+                <span class="w-1.5 h-1.5 rounded-full {c.status !== 'running' ? 'bg-surface-300' : c.health === 'unhealthy' ? 'bg-rose-400' : c.health === 'healthy' ? 'bg-emerald-400' : 'bg-sky-400'}"></span>
+                <span class="font-medium text-surface-700 dark:text-surface-200">{c.name}</span>
+              </div>
+              <span class="text-xs text-surface-400 truncate max-w-[180px]">{c.image}</span>
+            </div>
+          {/each}
+          {#if containers.length > 5}
+            <p class="text-xs text-primary-500 font-medium pt-1">+{containers.length - 5} more</p>
+          {/if}
+        </div>
+      </div>
+ 
+      <!-- Repos Summary -->
+      <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
+        <div class="flex items-center justify-between mb-4">
+          <h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200">Git Repositories</h3>
+          <span class="text-xs font-medium text-primary-600 bg-primary-50 px-2 py-0.5 rounded-full">{repos.length} repos</span>
+        </div>
+        <div class="space-y-2">
+          {#each repos.slice(0, 5) as r}
+            <div class="flex items-center justify-between py-1.5 text-sm">
+              <div class="flex items-center gap-2">
+                <svg class="w-3.5 h-3.5 text-surface-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /></svg>
+                <span class="font-medium text-surface-700 dark:text-surface-200">{r.name}</span>
+              </div>
+              <span class="text-xs text-surface-400">{r.language || "—"}</span>
+            </div>
+          {/each}
+          {#if repos.length > 5}
+            <p class="text-xs text-primary-500 font-medium pt-1">+{repos.length - 5} more</p>
+          {/if}
+        </div>
+      </div>
+    </div>
+ 
+    <!-- Storage Usage -->
+    {#if stats?.volumes?.length}
+      <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
+        <h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200 mb-3">Storage Usage</h3>
+        <div class="space-y-3">
+          {#each stats.volumes as vol}
+            <div>
+              <div class="flex justify-between text-xs text-surface-500 mb-1">
+                <span class="font-medium">{vol.mount}</span>
+                <span>{vol.percent}%</span>
+              </div>
+              <div class="w-full h-3 bg-surface-100 dark:bg-surface-700 rounded-full overflow-hidden">
+                <div
+                  class="h-full rounded-full transition-all duration-700 ease-out {vol.percent > 90 ? 'bg-gradient-to-r from-rose-400 to-rose-500' : vol.percent > 70 ? 'bg-gradient-to-r from-amber-400 to-amber-500' : 'bg-gradient-to-r from-emerald-400 to-emerald-500'}"
+                  style="width: {vol.percent}%"
+                ></div>
+              </div>
+              <div class="flex justify-between mt-1 text-xs text-surface-400">
+                <span>{fmtBytes(vol.used)} used</span>
+                <span>{fmtBytes(vol.free)} free</span>
+              </div>
+            </div>
+          {/each}
+        </div>
+      </div>
+    {/if}
+  {/if}
+</div>
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/src/routes/Docker.svelte.html b/dashboard/frontend/coverage/src/routes/Docker.svelte.html new file mode 100644 index 0000000..d65c762 --- /dev/null +++ b/dashboard/frontend/coverage/src/routes/Docker.svelte.html @@ -0,0 +1,565 @@ + + + + + + Code coverage report for src/routes/Docker.svelte + + + + + + + + + +
+
+

All files / src/routes Docker.svelte

+
+ +
+ 0% + Statements + 0/94 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/94 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
<script>
+  import { onMount } from "svelte";
+  import { get, post } from "../lib/api.js";
+ 
+  let containers = $state([]);
+  let loading = $state(true);
+  let error = $state("");
+  let logTarget = $state("");
+  let logContent = $state("");
+  let loadingLogs = $state(false);
+  let actionLoading = $state("");
+ 
+  async function load() {
+    loading = true;
+    error = "";
+    try {
+      containers = (await get("/docker/containers")) || [];
+    } catch (e) {
+      error = e.message || "Docker API unavailable";
+      containers = [];
+    }
+    loading = false;
+  }
+ 
+  async function action(id, act) {
+    actionLoading = id + act;
+    try {
+      await post(`/docker/containers/${id}/${act}`);
+      await load();
+    } catch (e) {
+      error = e.message || "Action failed";
+    }
+    actionLoading = "";
+  }
+ 
+  async function showLogs(id, name) {
+    logTarget = name;
+    loadingLogs = true;
+    try {
+      const r = await get(`/docker/containers/${id}/logs?tail=200`);
+      logContent = r?.logs || "No logs available";
+    } catch (e) {
+      logContent = "Failed to load logs";
+    }
+    loadingLogs = false;
+  }
+ 
+  onMount(load);
+</script>
+ 
+<div class="space-y-6">
+  <div class="flex items-center justify-between">
+    <div>
+      <h1 class="text-2xl font-bold text-surface-900 dark:text-white tracking-tight">Docker</h1>
+      <p class="text-sm text-surface-400 mt-1">{containers.length} containers · {containers.filter(c => c.status === "running").length} running</p>
+    </div>
+    <button onclick={load} class="text-xs font-medium text-primary-600 bg-primary-50 hover:bg-primary-100 px-3 py-1.5 rounded-lg transition-colors">
+      Refresh
+    </button>
+  </div>
+ 
+  {#if loading}
+    <div class="space-y-3">
+      {#each Array(5) as _}
+        <div class="h-[72px] rounded-xl bg-surface-100 dark:bg-surface-800 animate-pulse"></div>
+      {/each}
+    </div>
+  {:else if error}
+    <div class="bg-rose-50 dark:bg-rose-900/20 border border-rose-200 dark:border-rose-800 rounded-xl p-4">
+      <div class="flex items-center justify-between">
+        <div class="flex items-center gap-2">
+          <svg class="w-5 h-5 text-rose-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" /></svg>
+          <p class="text-sm font-medium text-rose-700 dark:text-rose-300">{error}</p>
+        </div>
+        <button onclick={load} class="text-xs font-medium text-rose-600 bg-rose-100 hover:bg-rose-200 px-3 py-1.5 rounded-lg transition-colors">
+          Retry
+        </button>
+      </div>
+    </div>
+  {:else}
+    <div class="space-y-2">
+      {#each containers as c}
+        <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-4 flex items-center justify-between shadow-sm hover:shadow-md transition-all duration-200 group">
+          <div class="flex items-center gap-3 min-w-0">
+            <span class="relative flex h-2.5 w-2.5 shrink-0">
+              {#if c.status === "running"}
+                <span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
+                <span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500"></span>
+              {:else}
+                <span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-surface-300"></span>
+              {/if}
+            </span>
+            <div class="min-w-0">
+              <p class="text-sm font-semibold text-surface-800 dark:text-surface-100 truncate">{c.name}</p>
+              <p class="text-xs text-surface-400 truncate mt-0.5">{c.image}</p>
+            </div>
+          </div>
+          <div class="flex items-center gap-1.5 opacity-70 group-hover:opacity-100 transition-opacity">
+            {#if c.status === "running"}
+              <button
+                class="text-xs font-medium px-2.5 py-1.5 rounded-lg text-rose-600 bg-rose-50 hover:bg-rose-100 transition-colors disabled:opacity-50"
+                onclick={() => action(c.id, "stop")}
+                disabled={actionLoading === c.id + "stop"}
+              >
+                {actionLoading === c.id + "stop" ? "..." : "Stop"}
+              </button>
+              <button
+                class="text-xs font-medium px-2.5 py-1.5 rounded-lg text-amber-600 bg-amber-50 hover:bg-amber-100 transition-colors disabled:opacity-50"
+                onclick={() => action(c.id, "restart")}
+                disabled={actionLoading === c.id + "restart"}
+              >
+                {actionLoading === c.id + "restart" ? "..." : "Restart"}
+              </button>
+            {:else}
+              <button
+                class="text-xs font-medium px-2.5 py-1.5 rounded-lg text-emerald-600 bg-emerald-50 hover:bg-emerald-100 transition-colors disabled:opacity-50"
+                onclick={() => action(c.id, "start")}
+                disabled={actionLoading === c.id + "start"}
+              >
+                {actionLoading === c.id + "start" ? "..." : "Start"}
+              </button>
+            {/if}
+            <button
+              class="text-xs font-medium px-2.5 py-1.5 rounded-lg text-surface-600 bg-surface-100 hover:bg-surface-200 transition-colors"
+              onclick={() => showLogs(c.id, c.name)}
+            >
+              Logs
+            </button>
+          </div>
+        </div>
+      {/each}
+    </div>
+  {/if}
+ 
+  <!-- Log Modal -->
+  {#if logTarget}
+    <div class="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50" role="dialog" aria-modal="true" onclick={() => { logTarget = ""; logContent = ""; }} onkeydown={(e) => e.key === "Escape" && (logTarget = "", logContent = "")}>
+      <div class="bg-white dark:bg-surface-800 rounded-2xl shadow-2xl w-[90vw] max-w-4xl max-h-[80vh] flex flex-col" role="document" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
+        <div class="flex items-center justify-between px-5 py-4 border-b border-surface-200 dark:border-surface-700">
+          <div>
+            <h3 class="text-sm font-bold text-surface-800 dark:text-surface-100">Container Logs</h3>
+            <p class="text-xs text-surface-400 mt-0.5">{logTarget}</p>
+          </div>
+          <button aria-label="Close logs" class="text-surface-400 hover:text-surface-600 transition-colors" onclick={() => { logTarget = ""; logContent = ""; }}>
+            <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
+          </button>
+        </div>
+        <div class="flex-1 overflow-auto p-1">
+          {#if loadingLogs}
+            <div class="flex items-center justify-center h-40">
+              <div class="w-6 h-6 border-2 border-primary-500 border-t-transparent rounded-full animate-spin"></div>
+            </div>
+          {:else}
+            <pre class="bg-surface-900 text-emerald-400 text-xs p-4 rounded-xl whitespace-pre-wrap leading-relaxed font-mono min-h-[200px]">{logContent}</pre>
+          {/if}
+        </div>
+      </div>
+    </div>
+  {/if}
+</div>
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/src/routes/Files.svelte.html b/dashboard/frontend/coverage/src/routes/Files.svelte.html new file mode 100644 index 0000000..1b71af6 --- /dev/null +++ b/dashboard/frontend/coverage/src/routes/Files.svelte.html @@ -0,0 +1,616 @@ + + + + + + Code coverage report for src/routes/Files.svelte + + + + + + + + + +
+
+

All files / src/routes Files.svelte

+
+ +
+ 0% + Statements + 0/105 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/105 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
<script>
+  import { onMount } from "svelte";
+  import { get, del, upload, download } from "../lib/api.js";
+ 
+  let entries = $state([]);
+  let currentPath = $state("");
+  let loading = $state(true);
+  let uploading = $state(false);
+  let error = $state("");
+  let fileInput;
+ 
+  async function browse(path) {
+    loading = true;
+    currentPath = path;
+    const data = await get(`/files/browse?path=${encodeURIComponent(path)}`);
+    entries = data?.entries || [];
+    loading = false;
+  }
+ 
+  function goUp() {
+    const parts = currentPath.split("/").filter(Boolean);
+    parts.pop();
+    browse(parts.join("/"));
+  }
+ 
+  async function remove(name) {
+    if (!confirm(`Delete "${name}"? This cannot be undone.`)) return;
+    const path = currentPath ? `${currentPath}/${name}` : name;
+    await del(`/files/delete?path=${encodeURIComponent(path)}`);
+    browse(currentPath);
+  }
+ 
+  async function handleDownload(name) {
+    const path = currentPath ? `${currentPath}/${name}` : name;
+    error = "";
+    try {
+      await download(path, name);
+    } catch (e) {
+      error = `Failed to download "${name}": ${e.message}`;
+      console.error("Download failed:", e);
+    }
+  }
+ 
+  async function handleUpload() {
+    const file = fileInput?.files?.[0];
+    if (!file) return;
+    uploading = true;
+    await upload(currentPath, file);
+    fileInput.value = "";
+    uploading = false;
+    browse(currentPath);
+  }
+ 
+  function formatSize(bytes) {
+    if (!bytes) return "—";
+    const u = ["B", "KB", "MB", "GB", "TB"];
+    const i = Math.floor(Math.log(bytes) / Math.log(1024));
+    return (bytes / Math.pow(1024, i)).toFixed(1) + " " + u[i];
+  }
+ 
+  function fileIcon(name) {
+    const ext = name.split(".").pop().toLowerCase();
+    if (["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(ext)) return "image";
+    if (["mp4", "mkv", "avi", "mov"].includes(ext)) return "video";
+    if (["mp3", "flac", "wav", "m4a", "ogg"].includes(ext)) return "audio";
+    if (["zip", "tar", "gz", "7z", "rar"].includes(ext)) return "archive";
+    if (["py", "js", "ts", "go", "rs", "sh", "yml", "yaml", "json", "md"].includes(ext)) return "code";
+    return "file";
+  }
+ 
+  const crumbs = $derived(currentPath.split("/").filter(Boolean));
+ 
+  onMount(() => browse(""));
+</script>
+ 
+<div class="space-y-5">
+  <div class="flex items-center justify-between">
+    <div>
+      <h1 class="text-2xl font-bold text-surface-900 dark:text-white tracking-tight">Files</h1>
+      <p class="text-sm text-surface-400 mt-1">/volume1{currentPath ? "/" + currentPath : ""}</p>
+    </div>
+    <div class="flex items-center gap-2">
+      <input type="file" bind:this={fileInput} onchange={handleUpload} class="hidden" />
+      <button
+        onclick={() => fileInput.click()}
+        class="text-xs font-medium text-primary-600 bg-primary-50 hover:bg-primary-100 px-3 py-1.5 rounded-lg transition-colors disabled:opacity-50"
+        disabled={uploading}
+      >
+        {uploading ? "Uploading..." : "Upload"}
+      </button>
+    </div>
+  </div>
+ 
+  <!-- Error message -->
+  {#if error}
+    <div class="bg-rose-50 dark:bg-rose-900/20 border border-rose-200 dark:border-rose-800 rounded-lg px-4 py-3 flex items-start gap-3">
+      <svg class="w-5 h-5 text-rose-500 shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
+        <path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
+      </svg>
+      <div class="flex-1">
+        <p class="text-sm text-rose-700 dark:text-rose-300">{error}</p>
+      </div>
+      <button onclick={() => error = ""} class="text-rose-400 hover:text-rose-600 transition-colors">
+        <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
+          <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
+        </svg>
+      </button>
+    </div>
+  {/if}
+ 
+  <!-- Breadcrumbs -->
+  <div class="flex items-center gap-1 text-sm flex-wrap">
+    <button class="text-primary-600 hover:text-primary-700 font-medium transition-colors" onclick={() => browse("")}>volume1</button>
+    {#each crumbs as part, i}
+      <svg class="w-3.5 h-3.5 text-surface-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" /></svg>
+      <button
+        class="text-primary-600 hover:text-primary-700 font-medium transition-colors"
+        onclick={() => browse(crumbs.slice(0, i + 1).join("/"))}
+      >{part}</button>
+    {/each}
+    {#if currentPath}
+      <button
+        class="ml-auto text-xs font-medium text-surface-500 bg-surface-100 dark:bg-surface-700 hover:bg-surface-200 dark:hover:bg-surface-600 px-2.5 py-1 rounded-lg transition-colors flex items-center gap-1"
+        onclick={goUp}
+      >
+        <svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M5 10l7-7m0 0l7 7m-7-7v18" /></svg>
+        Up
+      </button>
+    {/if}
+  </div>
+ 
+  <!-- File List -->
+  {#if loading}
+    <div class="space-y-1">
+      {#each Array(8) as _}
+        <div class="h-11 rounded-lg bg-surface-100 dark:bg-surface-800 animate-pulse"></div>
+      {/each}
+    </div>
+  {:else}
+    <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 shadow-sm overflow-hidden">
+      <!-- Header -->
+      <div class="grid grid-cols-[1fr_100px_100px] px-4 py-2 text-[11px] font-semibold uppercase tracking-wider text-surface-400 border-b border-surface-100 dark:border-surface-700">
+        <span>Name</span>
+        <span class="text-right">Size</span>
+        <span class="text-right">Actions</span>
+      </div>
+      <!-- Entries -->
+      {#each entries as e}
+        <div class="grid grid-cols-[1fr_100px_100px] items-center px-4 py-2.5 hover:bg-surface-50 dark:hover:bg-surface-700 border-b border-surface-100 dark:border-surface-700 last:border-0 transition-colors group">
+          {#if e.is_dir}
+            <button class="flex items-center gap-2.5 text-sm font-medium text-surface-700 dark:text-surface-200 hover:text-primary-600 transition-colors text-left truncate" onclick={() => browse(currentPath ? `${currentPath}/${e.name}` : e.name)}>
+              <svg class="w-4 h-4 text-amber-400 shrink-0" fill="currentColor" viewBox="0 0 20 20"><path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" /></svg>
+              {e.name}
+            </button>
+          {:else}
+            <span class="flex items-center gap-2.5 text-sm text-surface-600 dark:text-surface-300 truncate">
+              <svg class="w-4 h-4 text-surface-300 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" /></svg>
+              {e.name}
+            </span>
+          {/if}
+          <span class="text-xs text-surface-400 text-right">{e.is_dir ? "—" : formatSize(e.size)}</span>
+          <div class="flex items-center justify-end gap-1 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity">
+            {#if !e.is_dir}
+              <button onclick={() => handleDownload(e.name)} class="text-[11px] text-primary-500 hover:text-primary-700 font-medium transition-colors">
+                DL
+              </button>
+            {/if}
+            <button class="text-[11px] text-rose-400 hover:text-rose-600 font-medium transition-colors" onclick={() => remove(e.name)}>Del</button>
+          </div>
+        </div>
+      {/each}
+      {#if entries.length === 0}
+        <div class="px-4 py-8 text-center text-sm text-surface-400">Empty directory</div>
+      {/if}
+    </div>
+  {/if}
+</div>
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/src/routes/Gitea.svelte.html b/dashboard/frontend/coverage/src/routes/Gitea.svelte.html new file mode 100644 index 0000000..b454fd7 --- /dev/null +++ b/dashboard/frontend/coverage/src/routes/Gitea.svelte.html @@ -0,0 +1,457 @@ + + + + + + Code coverage report for src/routes/Gitea.svelte + + + + + + + + + +
+
+

All files / src/routes Gitea.svelte

+
+ +
+ 0% + Statements + 0/74 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/74 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
<script>
+  import { onMount } from "svelte";
+  import { get } from "../lib/api.js";
+ 
+  let repos = $state([]);
+  let commits = $state([]);
+  let selectedRepo = $state("");
+  let loading = $state(true);
+  let loadingCommits = $state(false);
+ 
+  async function load() {
+    loading = true;
+    const data = await get("/gitea/repos");
+    repos = Array.isArray(data) ? data : data?.data || [];
+    loading = false;
+  }
+ 
+  async function showCommits(owner, repo) {
+    selectedRepo = `${owner}/${repo}`;
+    loadingCommits = true;
+    const data = await get(`/gitea/repos/${owner}/${repo}/commits`);
+    commits = Array.isArray(data) ? data : [];
+    loadingCommits = false;
+  }
+ 
+  function timeAgo(d) {
+    const s = Math.floor((Date.now() - new Date(d)) / 1000);
+    if (s < 60) return "just now";
+    if (s < 3600) return Math.floor(s / 60) + "m ago";
+    if (s < 86400) return Math.floor(s / 3600) + "h ago";
+    return Math.floor(s / 86400) + "d ago";
+  }
+ 
+  const langColors = {
+    Python: "#3572A5", JavaScript: "#f1e05a", Shell: "#89e051", Go: "#00ADD8",
+    TypeScript: "#3178c6", Markdown: "#083fa1", CSS: "#563d7c", HTML: "#e34c26",
+  };
+ 
+  onMount(load);
+</script>
+ 
+<div class="space-y-6">
+  <div>
+    <h1 class="text-2xl font-bold text-surface-900 dark:text-white tracking-tight">Repositories</h1>
+    <p class="text-sm text-surface-400 mt-1">{repos.length} repositories on Gitea</p>
+  </div>
+ 
+  {#if loading}
+    <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
+      {#each Array(4) as _}
+        <div class="h-[100px] rounded-xl bg-surface-100 dark:bg-surface-800 animate-pulse"></div>
+      {/each}
+    </div>
+  {:else}
+    <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
+      {#each repos as r}
+        <button
+          class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-4 text-left shadow-sm hover:shadow-md hover:border-primary-200 dark:hover:border-primary-700 transition-all duration-200 group {selectedRepo === `${r.owner?.login}/${r.name}` ? 'ring-2 ring-primary-500 border-primary-300' : ''}"
+          onclick={() => showCommits(r.owner?.login, r.name)}
+        >
+          <div class="flex items-start justify-between">
+            <div class="min-w-0">
+              <p class="text-sm font-semibold text-surface-800 dark:text-surface-100 group-hover:text-primary-600 transition-colors truncate">{r.name}</p>
+              <p class="text-xs text-surface-400 mt-1 line-clamp-1">{r.description || "No description"}</p>
+            </div>
+            {#if r.language}
+              <span class="flex items-center gap-1 text-[11px] text-surface-500 shrink-0 ml-2">
+                <span class="w-2 h-2 rounded-full" style="background: {langColors[r.language] || '#888'}"></span>
+                {r.language}
+              </span>
+            {/if}
+          </div>
+          <p class="text-[11px] text-surface-400 mt-2.5">Updated {timeAgo(r.updated_at)}</p>
+        </button>
+      {/each}
+    </div>
+  {/if}
+ 
+  <!-- Commits Panel -->
+  {#if selectedRepo}
+    <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
+      <div class="flex items-center justify-between mb-4">
+        <div>
+          <h3 class="text-sm font-bold text-surface-800 dark:text-surface-100">Recent Commits</h3>
+          <p class="text-xs text-surface-400 mt-0.5">{selectedRepo}</p>
+        </div>
+        <button
+          aria-label="Close commits"
+          class="text-xs text-surface-400 hover:text-surface-600 transition-colors"
+          onclick={() => { selectedRepo = ""; commits = []; }}
+        >
+          <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
+        </button>
+      </div>
+      {#if loadingCommits}
+        <div class="space-y-3">
+          {#each Array(3) as _}
+            <div class="h-12 rounded-lg bg-surface-100 animate-pulse"></div>
+          {/each}
+        </div>
+      {:else}
+        <div class="space-y-0">
+          {#each commits.slice(0, 15) as c, i}
+            <div class="flex gap-3 py-2.5 {i < commits.length - 1 ? 'border-b border-surface-100 dark:border-surface-700' : ''}">
+              <div class="flex flex-col items-center pt-1">
+                <div class="w-2 h-2 rounded-full bg-primary-400 shrink-0"></div>
+                {#if i < commits.length - 1}
+                  <div class="w-px flex-1 bg-surface-200 mt-1"></div>
+                {/if}
+              </div>
+              <div class="min-w-0 flex-1">
+                <p class="text-sm text-surface-700 dark:text-surface-200 truncate">{c.commit?.message?.split("\n")[0]}</p>
+                <p class="text-[11px] text-surface-400 mt-0.5">
+                  <span class="font-medium">{c.commit?.author?.name}</span> · {timeAgo(c.commit?.author?.date)}
+                </p>
+              </div>
+              <span class="text-[10px] font-mono text-surface-400 bg-surface-50 dark:bg-surface-700 px-1.5 py-0.5 rounded h-fit shrink-0">{c.sha?.slice(0, 7)}</span>
+            </div>
+          {/each}
+        </div>
+      {/if}
+    </div>
+  {/if}
+</div>
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/src/routes/InfoEngine.svelte.html b/dashboard/frontend/coverage/src/routes/InfoEngine.svelte.html new file mode 100644 index 0000000..40aa46d --- /dev/null +++ b/dashboard/frontend/coverage/src/routes/InfoEngine.svelte.html @@ -0,0 +1,502 @@ + + + + + + Code coverage report for src/routes/InfoEngine.svelte + + + + + + + + + +
+
+

All files / src/routes InfoEngine.svelte

+
+ +
+ 0% + Statements + 0/92 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/92 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
<script>
+  import { onMount } from "svelte";
+  import { getInfoEngineItems, getInfoEngineItem } from "../lib/api.js";
+ 
+  let loading = $state(true);
+  let loadingDetail = $state(false);
+  let requestError = $state("");
+  let items = $state([]);
+  let total = $state(0);
+  let selectedId = $state(null);
+  let selectedItem = $state(null);
+ 
+  async function loadItems() {
+    loading = true;
+    requestError = "";
+    try {
+      const res = await getInfoEngineItems({ limit: 50, offset: 0 });
+      items = res.items || [];
+      total = res.total || 0;
+      if (items.length > 0) {
+        await selectItem(items[0].id);
+      } else {
+        selectedId = null;
+        selectedItem = null;
+      }
+    } catch (e) {
+      requestError = e?.body?.detail || e?.message || "Failed to load items";
+      items = [];
+      total = 0;
+      selectedId = null;
+      selectedItem = null;
+    } finally {
+      loading = false;
+    }
+  }
+ 
+  async function selectItem(id) {
+    selectedId = id;
+    loadingDetail = true;
+    requestError = "";
+    try {
+      selectedItem = await getInfoEngineItem(id);
+    } catch (e) {
+      selectedItem = null;
+      requestError = e?.body?.detail || e?.message || "Failed to load item";
+    } finally {
+      loadingDetail = false;
+    }
+  }
+ 
+  function formatDate(value) {
+    if (!value) return "—";
+    try {
+      return new Date(value).toLocaleString();
+    } catch {
+      return value;
+    }
+  }
+ 
+  onMount(loadItems);
+</script>
+ 
+<div class="space-y-6">
+  <div class="flex items-center justify-between">
+    <div>
+      <h1 class="text-2xl font-bold text-surface-900 dark:text-white tracking-tight">Info Engine</h1>
+      <p class="text-sm text-surface-400 mt-1">Context-aware stream of curated signals</p>
+    </div>
+    <button onclick={loadItems} disabled={loading} class="px-3 py-1.5 text-xs font-medium text-primary-600 bg-primary-50 hover:bg-primary-100 rounded-lg transition-colors disabled:opacity-50 dark:bg-primary-900/30 dark:text-primary-300">
+      {loading ? "Loading..." : "Refresh"}
+    </button>
+  </div>
+ 
+  {#if requestError}
+    <div class="bg-rose-50 dark:bg-rose-900/20 border border-rose-200 dark:border-rose-700 rounded-xl p-4 shadow-sm">
+      <p class="text-sm font-medium text-rose-700 dark:text-rose-300">{requestError}</p>
+    </div>
+  {/if}
+ 
+  <div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
+    <div class="lg:col-span-1 bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 shadow-sm">
+      <div class="px-4 py-3 border-b border-surface-200 dark:border-surface-700 text-xs text-surface-400">
+        Latest items ({total})
+      </div>
+      {#if loading}
+        <div class="p-4 space-y-2">
+          {#each Array(6) as _}
+            <div class="h-12 rounded-lg bg-surface-100 dark:bg-surface-700 animate-pulse"></div>
+          {/each}
+        </div>
+      {:else if items.length === 0}
+        <div class="p-4 text-sm text-surface-400">No items yet.</div>
+      {:else}
+        <div class="max-h-[560px] overflow-y-auto p-2">
+          {#each items as item}
+            <button onclick={() => selectItem(item.id)} class="w-full text-left px-3 py-2 rounded-lg mb-1 transition-colors {selectedId === item.id ? 'bg-primary-50 dark:bg-primary-900/30' : 'hover:bg-surface-100 dark:hover:bg-surface-700'}">
+              <p class="text-sm font-semibold text-surface-800 dark:text-surface-100 line-clamp-2">{item.title}</p>
+              <p class="text-xs text-surface-400 mt-1">{item.source}</p>
+            </button>
+          {/each}
+        </div>
+      {/if}
+    </div>
+ 
+    <div class="lg:col-span-2 bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
+      {#if loadingDetail}
+        <div class="space-y-3">
+          <div class="h-7 w-3/4 rounded bg-surface-100 dark:bg-surface-700 animate-pulse"></div>
+          <div class="h-4 w-full rounded bg-surface-100 dark:bg-surface-700 animate-pulse"></div>
+          <div class="h-4 w-5/6 rounded bg-surface-100 dark:bg-surface-700 animate-pulse"></div>
+          <div class="h-24 w-full rounded bg-surface-100 dark:bg-surface-700 animate-pulse"></div>
+        </div>
+      {:else if selectedItem}
+        <h2 class="text-xl font-bold text-surface-900 dark:text-white">{selectedItem.title}</h2>
+        <div class="mt-2 text-xs text-surface-400 space-y-1">
+          <p>Source: {selectedItem.source}</p>
+          <p>Published: {formatDate(selectedItem.published_at)}</p>
+          <p>Collected: {formatDate(selectedItem.collected_at)}</p>
+        </div>
+ 
+        {#if selectedItem.tags?.length}
+          <div class="mt-3 flex flex-wrap gap-2">
+            {#each selectedItem.tags as tag}
+              <span class="px-2 py-0.5 text-xs rounded-full bg-surface-100 text-surface-600 dark:bg-surface-700 dark:text-surface-300">{tag}</span>
+            {/each}
+          </div>
+        {/if}
+ 
+        <p class="mt-4 text-sm text-surface-700 dark:text-surface-300 whitespace-pre-wrap">{selectedItem.summary || selectedItem.content_text || "No summary available."}</p>
+ 
+        <a href={selectedItem.url} target="_blank" rel="noopener" class="inline-flex mt-4 px-3 py-1.5 text-xs font-medium rounded-lg bg-primary-600 text-white hover:bg-primary-700">
+          Open source
+        </a>
+      {:else}
+        <p class="text-sm text-surface-400">Select an item to view details.</p>
+      {/if}
+    </div>
+  </div>
+</div>
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/src/routes/LiteLLM.svelte.html b/dashboard/frontend/coverage/src/routes/LiteLLM.svelte.html new file mode 100644 index 0000000..70fb837 --- /dev/null +++ b/dashboard/frontend/coverage/src/routes/LiteLLM.svelte.html @@ -0,0 +1,409 @@ + + + + + + Code coverage report for src/routes/LiteLLM.svelte + + + + + + + + + +
+
+

All files / src/routes LiteLLM.svelte

+
+ +
+ 0% + Statements + 0/58 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/58 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
<script>
+  import { onMount } from "svelte";
+  import { getLiteLLMHealth } from "../lib/api.js";
+ 
+  let loading = $state(true);
+  let health = $state(null);
+  let requestError = $state("");
+ 
+  async function loadHealth() {
+    loading = true;
+    requestError = "";
+    try {
+      health = await getLiteLLMHealth();
+    } catch (e) {
+      requestError = e?.body?.detail || e?.message || "Failed to load LiteLLM health";
+      health = null;
+    } finally {
+      loading = false;
+    }
+  }
+ 
+  function statusBadgeClass(status) {
+    if (status === "up") {
+      return "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300";
+    }
+    if (status === "auth_required") {
+      return "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300";
+    }
+    return "bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300";
+  }
+ 
+  function valueOrDash(value) {
+    return value === null || value === undefined || value === "" ? "—" : value;
+  }
+ 
+  onMount(loadHealth);
+</script>
+ 
+<div class="space-y-6">
+  <div class="flex items-center justify-between">
+    <div>
+      <h1 class="text-2xl font-bold text-surface-900 dark:text-white tracking-tight">LiteLLM</h1>
+      <p class="text-sm text-surface-400 mt-1">Gateway health and connectivity status</p>
+      <div class="mt-2 flex flex-wrap items-center gap-2 text-[11px] font-medium">
+        <span class="px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">up</span>
+        <span class="px-2 py-0.5 rounded-full bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">auth_required</span>
+        <span class="px-2 py-0.5 rounded-full bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300">down</span>
+      </div>
+    </div>
+    <button onclick={loadHealth} disabled={loading} class="px-3 py-1.5 text-xs font-medium text-primary-600 bg-primary-50 hover:bg-primary-100 rounded-lg transition-colors disabled:opacity-50 dark:bg-primary-900/30 dark:text-primary-300">
+      {loading ? "Loading..." : "Refresh"}
+    </button>
+  </div>
+ 
+  {#if requestError}
+    <div class="bg-rose-50 dark:bg-rose-900/20 border border-rose-200 dark:border-rose-700 rounded-xl p-4 shadow-sm">
+      <p class="text-sm font-medium text-rose-700 dark:text-rose-300">Failed to fetch LiteLLM health</p>
+      <p class="text-xs text-rose-600 dark:text-rose-400 mt-1">{requestError}</p>
+    </div>
+  {/if}
+ 
+  <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
+    {#if loading && !health}
+      <div class="space-y-3">
+        <div class="h-6 w-36 rounded bg-surface-100 dark:bg-surface-700 animate-pulse"></div>
+        <div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
+          {#each Array(6) as _}
+            <div class="h-16 rounded-lg bg-surface-100 dark:bg-surface-700 animate-pulse"></div>
+          {/each}
+        </div>
+      </div>
+    {:else if health}
+      <div class="flex items-center justify-between mb-4">
+        <h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200">Health Status</h3>
+        <span class="px-2.5 py-1 text-xs font-semibold rounded-full {statusBadgeClass(health.status)}">{valueOrDash(health.status)}</span>
+      </div>
+ 
+      <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
+        <div class="rounded-lg border border-surface-200 dark:border-surface-700 p-3">
+          <p class="text-xs font-medium text-surface-400 uppercase tracking-wider">OK</p>
+          <p class="text-sm font-semibold text-surface-800 dark:text-surface-100 mt-1">{String(Boolean(health.ok))}</p>
+        </div>
+ 
+        <div class="rounded-lg border border-surface-200 dark:border-surface-700 p-3">
+          <p class="text-xs font-medium text-surface-400 uppercase tracking-wider">Latency</p>
+          <p class="text-sm font-semibold text-surface-800 dark:text-surface-100 mt-1">{health.latency_ms === null || health.latency_ms === undefined ? "—" : `${health.latency_ms} ms`}</p>
+        </div>
+ 
+        <div class="rounded-lg border border-surface-200 dark:border-surface-700 p-3">
+          <p class="text-xs font-medium text-surface-400 uppercase tracking-wider">Endpoint</p>
+          <p class="text-sm font-semibold text-surface-800 dark:text-surface-100 mt-1">{valueOrDash(health.endpoint)}</p>
+        </div>
+ 
+        <div class="rounded-lg border border-surface-200 dark:border-surface-700 p-3">
+          <p class="text-xs font-medium text-surface-400 uppercase tracking-wider">HTTP Status</p>
+          <p class="text-sm font-semibold text-surface-800 dark:text-surface-100 mt-1">{valueOrDash(health.http_status)}</p>
+        </div>
+ 
+        <div class="rounded-lg border border-surface-200 dark:border-surface-700 p-3 sm:col-span-2 lg:col-span-2">
+          <p class="text-xs font-medium text-surface-400 uppercase tracking-wider">Error</p>
+          <p class="text-sm font-semibold text-surface-800 dark:text-surface-100 mt-1 break-all">{valueOrDash(health.error)}</p>
+        </div>
+      </div>
+    {:else}
+      <p class="text-sm text-surface-400">No health data available.</p>
+    {/if}
+  </div>
+</div>
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/src/routes/Login.svelte.html b/dashboard/frontend/coverage/src/routes/Login.svelte.html new file mode 100644 index 0000000..3cb03c8 --- /dev/null +++ b/dashboard/frontend/coverage/src/routes/Login.svelte.html @@ -0,0 +1,775 @@ + + + + + + Code coverage report for src/routes/Login.svelte + + + + + + + + + +
+
+

All files / src/routes Login.svelte

+
+ +
+ 41.02% + Statements + 48/117 +
+ + +
+ 30% + Branches + 3/10 +
+ + +
+ 33.33% + Functions + 2/6 +
+ + +
+ 41.02% + Lines + 48/117 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +2313x +  +  +  +3x +3x +3x +3x +3x +3x +3x +  +3x +  +  +  +  +  +3x +  +  +  +  +  +  +3x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +3x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +3x +  +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +1x +  +1x +1x +  +  +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +1x +1x +1x +1x +1x +  +1x +  +  +  +  +1x +  +  +  +  +1x +  +1x +  +  +  +  +1x +  +  +  +  +  +1x +  +1x +  +  +  +  +  +  +  +  +1x +  +  +  +  +1x +1x +  +  +  +  +  +  +  +1x +  +1x +  +  +1x +1x +  +  +  +  +1x +  +  +  +  +1x +1x +1x +  +  +  +  +  +1x +1x +  +  +  +  +  +  +  + 
<script>
+  import { login, setToken } from "../lib/api.js";
+  import { fade, fly } from "svelte/transition";
+ 
+  let username = $state("");
+  let password = $state("");
+  let totp_code = $state("");
+  let error = $state("");
+  let loading = $state(false);
+  let require2FA = $state(false);
+  let showPasswordForm = $state(!window.isSecureContext);
+ 
+  function base64urlToBuffer(b64) {
+    const s = b64.replace(/-/g, '+').replace(/_/g, '/');
+    const raw = atob(s);
+    return Uint8Array.from(raw, c => c.charCodeAt(0)).buffer;
+  }
+ 
+  function bufferToBase64url(buf) {
+    const bytes = new Uint8Array(buf);
+    let s = '';
+    bytes.forEach(b => s += String.fromCharCode(b));
+    return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+  }
+ 
+  async function loginWithPasskey() {
+    loading = true;
+    error = "";
+    try {
+      const r = await fetch("/api/auth/passkey/login/options", { method: "POST" });
+      if (!r.ok) throw new Error("No passkeys registered");
+      const opts = await r.json();
+      opts.challenge = base64urlToBuffer(opts.challenge);
+      if (opts.allowCredentials) {
+        opts.allowCredentials = opts.allowCredentials.map(c => ({
+          ...c, id: base64urlToBuffer(c.id)
+        }));
+      }
+      const cred = await navigator.credentials.get({ publicKey: opts });
+      const body = {
+        id: cred.id,
+        rawId: bufferToBase64url(cred.rawId),
+        type: cred.type,
+        response: {
+          authenticatorData: bufferToBase64url(cred.response.authenticatorData),
+          clientDataJSON: bufferToBase64url(cred.response.clientDataJSON),
+          signature: bufferToBase64url(cred.response.signature),
+          userHandle: cred.response.userHandle ? bufferToBase64url(cred.response.userHandle) : null,
+        },
+      };
+      const res = await fetch("/api/auth/passkey/login/verify", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify(body),
+      });
+      if (!res.ok) { const e = await res.json(); throw new Error(e.detail || "Passkey verification failed"); }
+      const data = await res.json();
+      setToken(data.access_token);
+      window.location.reload();
+    } catch (e) {
+      if (e.name === "NotAllowedError") { error = "Passkey authentication cancelled"; }
+      else { error = e.message || "Passkey login failed"; }
+      showPasswordForm = true;
+    } finally {
+      loading = false;
+    }
+  }
+ 
+  async function handleSubmit(e) {
+    e.preventDefault();
+    loading = true;
+    error = "";
+    
+    try {
+      const payload = { username, password };
+      if (require2FA) payload.totp_code = totp_code;
+      
+      const res = await login(payload); // api.js helper sends JSON
+      
+      setToken(res.access_token);
+      // Determine if we need to reload or just update state. 
+      // Reload is safest to clear any stale state.
+      window.location.reload(); 
+      
+    } catch (e) {
+      if (e.status === 403 && e.body?.detail === "2FA code required") {
+         require2FA = true;
+         // Clear error if any
+         error = "";
+      } else {
+         error = e.body?.detail || "Login failed. Please check your credentials.";
+         // Reset 2FA state on general failure (maybe password was wrong this time)
+         if (!require2FA) {
+            password = "";
+         }
+      }
+    } finally {
+      loading = false;
+    }
+  }
+</script>
+ 
+<div class="flex min-h-screen items-center justify-center bg-surface-50 dark:bg-surface-950 px-4">
+  <div class="w-full max-w-sm space-y-8 bg-white dark:bg-surface-900 p-8 rounded-2xl shadow-xl border border-surface-200 dark:border-surface-800" in:fly={{ y: 20, duration: 400 }}>
+    <div class="text-center">
+      <div class="mx-auto h-12 w-12 bg-gradient-to-br from-primary-500 to-primary-600 rounded-xl flex items-center justify-center shadow-lg mb-4">
+        <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
+        </svg>
+      </div>
+      <h2 class="text-2xl font-bold tracking-tight text-surface-900 dark:text-white">Sign in to NAS</h2>
+      <p class="mt-2 text-sm text-surface-600 dark:text-surface-400">
+        Enter your credentials to access the dashboard
+      </p>
+    </div>
+ 
+    <div class="mt-8 space-y-6">
+      {#if !showPasswordForm && !require2FA}
+        <button
+          type="button"
+          disabled={loading}
+          onclick={loginWithPasskey}
+          class="flex w-full justify-center rounded-lg bg-surface-800 dark:bg-surface-700 px-3 py-2.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-surface-700 dark:hover:bg-surface-600 disabled:opacity-70 transition-all active:scale-[0.98]"
+        >
+          {#if loading}
+            <svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
+              <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
+              <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
+            </svg>
+          {/if}
+          Sign in with Passkey
+        </button>
+ 
+        <div class="relative">
+          <div class="absolute inset-0 flex items-center"><div class="w-full border-t border-surface-200 dark:border-surface-700"></div></div>
+          <div class="relative flex justify-center text-xs"><span class="bg-white dark:bg-surface-900 px-2 text-surface-400">or</span></div>
+        </div>
+ 
+        <button type="button" onclick={() => showPasswordForm = true} class="flex w-full justify-center text-sm text-primary-500 hover:text-primary-400">
+          Use password instead
+        </button>
+      {/if}
+ 
+      {#if showPasswordForm || require2FA}
+      <form class="space-y-6" onsubmit={handleSubmit}>
+      <div class="space-y-4">
+        {#if !require2FA}
+          <div>
+            <label for="username" class="sr-only">Username</label>
+            <input
+              id="username"
+              name="username"
+              type="text"
+              required
+              bind:value={username}
+              class="relative block w-full rounded-lg border-0 py-2.5 px-3 text-surface-900 dark:text-white ring-1 ring-inset ring-surface-300 dark:ring-surface-700 placeholder:text-surface-400 focus:z-10 focus:ring-2 focus:ring-inset focus:ring-primary-600 dark:bg-surface-800 sm:text-sm sm:leading-6"
+              placeholder="Username"
+            />
+          </div>
+          <div>
+            <label for="password" class="sr-only">Password</label>
+            <input
+              id="password"
+              name="password"
+              type="password"
+              required
+              bind:value={password}
+              class="relative block w-full rounded-lg border-0 py-2.5 px-3 text-surface-900 dark:text-white ring-1 ring-inset ring-surface-300 dark:ring-surface-700 placeholder:text-surface-400 focus:z-10 focus:ring-2 focus:ring-inset focus:ring-primary-600 dark:bg-surface-800 sm:text-sm sm:leading-6"
+              placeholder="Password"
+            />
+          </div>
+        {:else}
+          <div in:fade>
+            <label for="totp" class="block text-sm font-medium leading-6 text-surface-900 dark:text-white text-center mb-2">Authenticator Code</label>
+            <input
+              id="totp"
+              name="totp"
+              type="text"
+              inputmode="numeric"
+              pattern="[0-9]*"
+              autocomplete="one-time-code"
+              required
+              autofocus
+              bind:value={totp_code}
+              class="block w-full text-center tracking-[0.5em] text-2xl font-mono rounded-lg border-0 py-3 text-surface-900 dark:text-white ring-1 ring-inset ring-surface-300 dark:ring-surface-700 placeholder:text-surface-400 focus:ring-2 focus:ring-inset focus:ring-primary-600 dark:bg-surface-800"
+              placeholder="000000"
+              maxlength="6"
+            />
+            <p class="mt-4 text-center text-sm text-surface-500">
+              <button type="button" class="text-primary-500 hover:text-primary-400" onclick={() => require2FA = false}>
+                Back to credentials
+              </button>
+            </p>
+          </div>
+        {/if}
+      </div>
+ 
+        <button
+          type="submit"
+          disabled={loading}
+          class="flex w-full justify-center rounded-lg bg-primary-600 px-3 py-2.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-primary-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 disabled:opacity-70 transition-all active:scale-[0.98]"
+        >
+          {#if loading}
+            <svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
+              <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
+              <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
+            </svg>
+          {/if}
+          {require2FA ? "Verify Code" : "Sign in"}
+        </button>
+      </form>
+      {/if}
+ 
+      {#if error}
+        <div class="rounded-md bg-red-50 dark:bg-red-900/30 p-3" in:fade>
+          <div class="flex">
+            <div class="flex-shrink-0">
+              <svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
+                <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
+              </svg>
+            </div>
+            <div class="ml-3">
+              <h3 class="text-sm font-medium text-red-800 dark:text-red-200">{error}</h3>
+            </div>
+          </div>
+        </div>
+      {/if}
+    </div>
+  </div>
+</div>
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/src/routes/OPC.svelte.html b/dashboard/frontend/coverage/src/routes/OPC.svelte.html new file mode 100644 index 0000000..b0a674a --- /dev/null +++ b/dashboard/frontend/coverage/src/routes/OPC.svelte.html @@ -0,0 +1,709 @@ + + + + + + Code coverage report for src/routes/OPC.svelte + + + + + + + + + +
+
+

All files / src/routes OPC.svelte

+
+ +
+ 0% + Statements + 0/129 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/129 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
<script>
+  import { onMount, onDestroy } from "svelte";
+  import KanbanBoard from "../components/opc/KanbanBoard.svelte";
+  import TaskModal from "../components/opc/TaskModal.svelte";
+  import AgentPanel from "../components/opc/AgentPanel.svelte";
+  import { getTasks, getAgents } from "../lib/opc-api.js";
+  import * as opcWs from "../lib/opc-ws.js";
+ 
+  let tasks = $state([]);
+  let agents = $state([]);
+  let loading = $state(true);
+  let showTaskModal = $state(false);
+  let editingTask = $state(null);
+  let showAgentPanel = $state(
+    typeof localStorage !== 'undefined'
+      ? localStorage.getItem('opc_showAgentPanel') !== 'false'
+      : true
+  );
+  let unsubscribe = null;
+ 
+  // Persist agent panel visibility
+  $effect(() => {
+    if (typeof localStorage !== 'undefined') {
+      localStorage.setItem('opc_showAgentPanel', showAgentPanel);
+    }
+  });
+ 
+  async function loadData() {
+    loading = true;
+    try {
+      const [tasksRes, agentsRes] = await Promise.all([
+        getTasks(),
+        getAgents()
+      ]);
+      tasks = tasksRes.items || [];
+      agents = agentsRes.items || [];
+    } catch (e) {
+      console.error("Failed to load data:", e);
+    } finally {
+      loading = false;
+    }
+  }
+ 
+  function handleWebSocketMessage(message) {
+    if (message.type === "task_update") {
+      const { task_id, action, data } = message;
+ 
+      if (action === "created") {
+        tasks = [...tasks, data];
+      } else if (action === "updated" || action === "moved") {
+        tasks = tasks.map(t => t.id === task_id ? data : t);
+      } else if (action === "deleted") {
+        tasks = tasks.filter(t => t.id !== task_id);
+      }
+    } else if (message.type === "agent_execution") {
+      // Could show agent status in UI
+      console.log("Agent execution update:", message);
+    }
+  }
+ 
+  function handleCreateTask() {
+    editingTask = null;
+    showTaskModal = true;
+  }
+ 
+  function handleEditTask(task) {
+    editingTask = task;
+    showTaskModal = true;
+  }
+ 
+  function handleDeleteTask(taskId) {
+    tasks = tasks.filter(t => t.id !== taskId);
+  }
+ 
+  function handleAssignTask(taskId, assignedTo, assignedType) {
+    const task = tasks.find(t => t.id === taskId);
+    if (task) {
+      task.assigned_to = assignedTo;
+      task.assigned_type = assignedType;
+      tasks = [...tasks];
+    }
+  }
+ 
+  function handleTaskMoved(taskId, newStatus) {
+    const task = tasks.find(t => t.id === taskId);
+    if (task) {
+      task.status = newStatus;
+      tasks = [...tasks];
+    }
+  }
+ 
+  function handleModalClose() {
+    showTaskModal = false;
+    editingTask = null;
+  }
+ 
+  function handleModalSave() {
+    loadData();
+  }
+ 
+  onMount(() => {
+    loadData();
+ 
+    // Connect WebSocket
+    opcWs.connect();
+    unsubscribe = opcWs.subscribe(handleWebSocketMessage);
+  });
+ 
+  onDestroy(() => {
+    if (unsubscribe) {
+      unsubscribe();
+    }
+    opcWs.disconnect();
+  });
+</script>
+ 
+<div class="space-y-6 h-full flex flex-col">
+  <!-- Header -->
+  <div class="flex items-center justify-between">
+    <div>
+      <h1 class="text-2xl font-bold text-surface-900 dark:text-surface-100">
+        OPC Management
+      </h1>
+      <p class="text-surface-600 dark:text-surface-400 mt-1">
+        Manage your tasks with AI agents
+      </p>
+    </div>
+    <div class="flex gap-2">
+      <button
+        onclick={() => showAgentPanel = !showAgentPanel}
+        class="px-4 py-2 bg-surface-200 dark:bg-surface-700 text-surface-900 dark:text-surface-100 rounded-lg hover:bg-surface-300 dark:hover:bg-surface-600 flex items-center gap-2"
+      >
+        <span>🤖</span>
+        <span>{showAgentPanel ? "Hide" : "Show"} Agents</span>
+      </button>
+      <button
+        onclick={handleCreateTask}
+        class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 flex items-center gap-2"
+      >
+        <span>➕</span>
+        <span>New Task</span>
+      </button>
+    </div>
+  </div>
+ 
+  <!-- Stats -->
+  <div class="grid grid-cols-1 md:grid-cols-4 gap-4">
+    <div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
+      <div class="text-sm text-surface-600 dark:text-surface-400">Total Tasks</div>
+      <div class="text-2xl font-bold text-surface-900 dark:text-surface-100 mt-1">
+        {tasks.length}
+      </div>
+    </div>
+    <div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
+      <div class="text-sm text-surface-600 dark:text-surface-400">Parking Lot</div>
+      <div class="text-2xl font-bold text-slate-600 dark:text-slate-400 mt-1">
+        {tasks.filter(t => t.status === "parking_lot").length}
+      </div>
+    </div>
+    <div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
+      <div class="text-sm text-surface-600 dark:text-surface-400">In Progress</div>
+      <div class="text-2xl font-bold text-blue-600 dark:text-blue-400 mt-1">
+        {tasks.filter(t => t.status === "in_progress").length}
+      </div>
+    </div>
+    <div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
+      <div class="text-sm text-surface-600 dark:text-surface-400">Done</div>
+      <div class="text-2xl font-bold text-emerald-600 dark:text-emerald-400 mt-1">
+        {tasks.filter(t => t.status === "done").length}
+      </div>
+    </div>
+  </div>
+ 
+  <!-- Kanban Board -->
+  {#if loading}
+    <div class="flex-1 flex items-center justify-center">
+      <div class="text-surface-600 dark:text-surface-400">Loading...</div>
+    </div>
+  {:else}
+    <div class="flex-1 overflow-hidden">
+      <KanbanBoard
+        {tasks}
+        {agents}
+        onEdit={handleEditTask}
+        onDelete={handleDeleteTask}
+        onAssign={handleAssignTask}
+        onTaskMoved={handleTaskMoved}
+      />
+    </div>
+  {/if}
+ 
+  <!-- Agent Panel -->
+  {#if showAgentPanel}
+    <div class="mt-6">
+      <AgentPanel />
+    </div>
+  {/if}
+</div>
+ 
+<!-- Task Modal -->
+{#if showTaskModal}
+  <TaskModal
+    task={editingTask}
+    {agents}
+    onClose={handleModalClose}
+    onSave={handleModalSave}
+  />
+{/if}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/src/routes/OpenClaw.svelte.html b/dashboard/frontend/coverage/src/routes/OpenClaw.svelte.html new file mode 100644 index 0000000..67cf897 --- /dev/null +++ b/dashboard/frontend/coverage/src/routes/OpenClaw.svelte.html @@ -0,0 +1,283 @@ + + + + + + Code coverage report for src/routes/OpenClaw.svelte + + + + + + + + + +
+
+

All files / src/routes OpenClaw.svelte

+
+ +
+ 0% + Statements + 0/22 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/22 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
<script>
+  import { onMount } from "svelte";
+  import { get } from "../lib/api.js";
+ 
+  let isTailscale = location.hostname.startsWith("100.");
+  let isLocal = location.hostname === "localhost" || location.hostname === "127.0.0.1" || location.hostname.startsWith("192.168.");
+  let canAccess = isTailscale || isLocal;
+  let token = $state("");
+ 
+  onMount(async () => {
+    if (canAccess) {
+      const res = await get("/system/openclaw-token");
+      token = res?.token || "";
+    }
+  });
+ 
+  function clawUrl() {
+    const base = `${location.protocol}//${location.hostname}:3100`;
+    return token ? `${base}/#token=${token}` : base;
+  }
+</script>
+ 
+<div class="space-y-4">
+  <div>
+    <h1 class="text-2xl font-bold text-surface-900 tracking-tight">OpenClaw</h1>
+    <p class="text-sm text-surface-400 mt-1">AI Assistant</p>
+  </div>
+ 
+  {#if canAccess}
+    <div class="flex flex-col items-center justify-center p-12 text-center bg-surface-50 dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 h-[calc(100vh-10rem)]">
+      <div class="h-16 w-16 bg-primary-100 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400 rounded-2xl flex items-center justify-center mb-6">
+        <svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
+        </svg>
+      </div>
+      <h2 class="text-xl font-semibold text-surface-900 dark:text-white mb-2">OpenClaw Ready</h2>
+      <p class="text-surface-600 dark:text-surface-400 max-w-md mb-8">
+        OpenClaw's security settings prevent it from being embedded inside the dashboard. Click below to launch the assistant securely in a new tab.
+      </p>
+      <a
+        href={clawUrl()}
+        target="_blank"
+        rel="noopener noreferrer"
+        class="inline-flex items-center justify-center px-6 py-3 border border-transparent text-base font-medium rounded-lg text-white bg-primary-600 hover:bg-primary-700 shadow-sm transition-colors"
+      >
+        Launch OpenClaw
+        <svg xmlns="http://www.w3.org/2000/svg" class="ml-2 -mr-1 h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
+        </svg>
+      </a>
+    </div>
+  {:else}
+    <div class="flex flex-col items-center justify-center p-12 text-center bg-surface-50 dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 h-[calc(100vh-10rem)]">
+      <svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-surface-400 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
+      </svg>
+      <h2 class="text-xl font-semibold text-surface-900 dark:text-white mb-2">Tailscale Network Required</h2>
+      <p class="text-surface-600 dark:text-surface-400 max-w-md">
+        For security reasons, OpenClaw is only accessible when connected directly to the NAS via the internal Tailscale network.
+      </p>
+      <p class="mt-4 text-sm text-surface-500">
+        Connect to Tailscale and visit <a href="http://100.78.131.124:4000" class="text-primary-500 hover:underline font-medium">http://100.78.131.124:4000</a> to use the AI Assistant.
+      </p>
+    </div>
+  {/if}
+</div>
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/src/routes/Security.svelte.html b/dashboard/frontend/coverage/src/routes/Security.svelte.html new file mode 100644 index 0000000..2bfd797 --- /dev/null +++ b/dashboard/frontend/coverage/src/routes/Security.svelte.html @@ -0,0 +1,934 @@ + + + + + + Code coverage report for src/routes/Security.svelte + + + + + + + + + +
+
+

All files / src/routes Security.svelte

+
+ +
+ 0% + Statements + 0/171 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/171 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
<script>
+  import { get } from "../lib/api.js";
+  import { onMount } from "svelte";
+ 
+  let logs = $state([]);
+  let stats = $state(null);
+  let loading = $state(false);
+  let filter = $state("all");
+  let ipFilter = $state("");
+  let startDate = $state("");
+  let endDate = $state("");
+  let filterError = $state("");
+ 
+  onMount(loadAll);
+ 
+  async function loadAll() {
+    loading = true;
+    await Promise.all([loadStats(), loadLogs()]);
+    loading = false;
+  }
+ 
+  async function loadStats() {
+    try {
+      stats = await get("/security/stats");
+    } catch (e) {
+      console.error("Failed to load security stats:", e);
+    }
+  }
+ 
+  function buildLogsQuery() {
+    const params = new URLSearchParams({ limit: "300" });
+    if (filter !== "all") params.set("type", filter);
+    if (ipFilter.trim()) params.set("ip", ipFilter.trim());
+    if (startDate) params.set("start_date", startDate);
+    if (endDate) params.set("end_date", endDate);
+    return params.toString();
+  }
+ 
+  function validateFilters() {
+    if (startDate && endDate && startDate > endDate) {
+      filterError = "From date must be on or before To date";
+      return false;
+    }
+    filterError = "";
+    return true;
+  }
+ 
+  async function loadLogs() {
+    if (!validateFilters()) {
+      logs = [];
+      return;
+    }
+ 
+    try {
+      const res = await get(`/security/logs?${buildLogsQuery()}`);
+      logs = res.logs;
+    } catch (e) {
+      console.error("Failed to load security logs:", e);
+    }
+  }
+ 
+  async function applyTypeFilter(nextFilter) {
+    filter = nextFilter;
+    await loadLogs();
+  }
+ 
+  async function applyIpFilter(ip) {
+    ipFilter = ip;
+    await loadLogs();
+  }
+ 
+  async function clearFilters() {
+    filter = "all";
+    ipFilter = "";
+    startDate = "";
+    endDate = "";
+    filterError = "";
+    await loadLogs();
+  }
+ 
+  let maxTimelineCount = $derived(
+    stats ? Math.max(1, ...stats.timeline.map(t => t.count)) : 1
+  );
+ 
+  let hasActiveFilters = $derived(
+    filter !== "all" || !!ipFilter.trim() || !!startDate || !!endDate
+  );
+ 
+  function isBlockedScannerEvent(entry) {
+    if (entry.type !== "unauthorized") return false;
+ 
+    const message = (entry.message || "").toLowerCase();
+    return [
+      "/.git/",
+      "phpinfo",
+      "debug.log",
+      "laravel.log",
+      "stripe",
+      "swagger.json",
+      "wp-config",
+      "credentials",
+      "secrets",
+      "config",
+      "@vite",
+      "manifest.json",
+    ].some((needle) => message.includes(needle));
+  }
+ 
+  const logTypeFilters = ["all", "auth_failure", "suspicious_request", "unauthorized"];
+  const logTypeButtonLabels = {
+    all: "All",
+    auth_failure: "Auth",
+    suspicious_request: "Requests",
+    unauthorized: "Blocked",
+  };
+ 
+  const typeLabels = { auth_failure: "Auth", suspicious_request: "Request", unauthorized: "Blocked", error: "Error" };
+  const typeColors = {
+    auth_failure: "text-red-400",
+    suspicious_request: "text-amber-400",
+    unauthorized: "text-orange-400",
+    error: "text-surface-500"
+  };
+</script>
+ 
+<div class="space-y-6">
+  <div class="flex items-center justify-between">
+    <h2 class="text-lg font-bold text-surface-800 dark:text-surface-100">Security</h2>
+    <button onclick={loadAll} disabled={loading} class="px-3 py-1.5 text-xs font-medium text-primary-600 bg-primary-50 hover:bg-primary-100 rounded-lg transition-colors disabled:opacity-50 dark:bg-primary-900/30 dark:text-primary-300">
+      {loading ? "Loading..." : "Refresh"}
+    </button>
+  </div>
+ 
+  <!-- Stats cards -->
+  {#if stats}
+    <div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
+      <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
+        <p class="text-xs font-medium text-surface-400 uppercase tracking-wider">Failed Logins (24h)</p>
+        <p class="text-2xl font-bold text-red-600 dark:text-red-400 mt-1">{stats.failed_logins_24h}</p>
+      </div>
+      <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
+        <p class="text-xs font-medium text-surface-400 uppercase tracking-wider">Suspicious Requests</p>
+        <p class="text-2xl font-bold text-amber-600 dark:text-amber-400 mt-1">{stats.suspicious_requests}</p>
+      </div>
+      <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
+        <p class="text-xs font-medium text-surface-400 uppercase tracking-wider">Unique IPs</p>
+        <p class="text-2xl font-bold text-surface-700 dark:text-surface-200 mt-1">{stats.unique_ips}</p>
+      </div>
+    </div>
+  {/if}
+ 
+  <!-- Timeline chart -->
+  {#if stats?.timeline}
+    <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
+      <h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200 mb-3">Activity (Last 24h)</h3>
+      <div class="flex items-end gap-1 h-24">
+        {#each [...stats.timeline].reverse() as bar}
+          <div class="flex-1 flex flex-col items-center justify-end h-full">
+            <div
+              class="w-full rounded-t transition-all {bar.count > 0 ? 'bg-primary-500 dark:bg-primary-400' : 'bg-surface-100 dark:bg-surface-700'}"
+              style="height: {bar.count > 0 ? Math.max(8, (bar.count / maxTimelineCount) * 100) : 4}%"
+              title="{bar.hours_ago}h ago: {bar.count} events"
+            ></div>
+          </div>
+        {/each}
+      </div>
+      <div class="flex justify-between mt-1">
+        <span class="text-[10px] text-surface-400">24h ago</span>
+        <span class="text-[10px] text-surface-400">now</span>
+      </div>
+    </div>
+  {/if}
+ 
+  <!-- Top IPs -->
+  {#if stats?.top_ips?.length}
+    <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
+      <h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200 mb-3">Top IPs</h3>
+      <div class="space-y-2">
+        {#each stats.top_ips as entry}
+          <div class="flex items-center gap-3 text-sm">
+            <button onclick={() => applyIpFilter(entry.ip)} class="text-xs font-mono text-surface-600 hover:text-primary-600 dark:text-surface-300 dark:hover:text-primary-300 w-36 shrink-0 text-left transition-colors">{entry.ip}</button>
+            <div class="flex-1 bg-surface-100 dark:bg-surface-700 rounded-full h-2 overflow-hidden">
+              <div class="bg-red-500 dark:bg-red-400 h-full rounded-full" style="width: {(entry.count / stats.top_ips[0].count) * 100}%"></div>
+            </div>
+            <span class="text-xs text-surface-500 w-8 text-right">{entry.count}</span>
+          </div>
+        {/each}
+      </div>
+    </div>
+  {/if}
+ 
+  <!-- Log table -->
+  <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
+    <div class="flex items-start justify-between gap-3 mb-3">
+      <div>
+        <h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200">Event Log</h3>
+        <p class="text-xs text-surface-400 mt-1">Filter recent events by type, IP, or date range.</p>
+      </div>
+      {#if hasActiveFilters}
+        <button onclick={clearFilters} class="px-2.5 py-1 text-xs rounded-md transition-colors bg-surface-100 text-surface-500 hover:bg-surface-200 dark:bg-surface-700 dark:text-surface-300 dark:hover:bg-surface-600">
+          Clear filters
+        </button>
+      {/if}
+    </div>
+ 
+    <div class="flex flex-wrap gap-1.5 mb-3">
+      {#each logTypeFilters as f}
+        <button onclick={() => applyTypeFilter(f)} class="px-2.5 py-1 text-xs rounded-md transition-colors {filter === f ? 'bg-surface-800 text-white dark:bg-surface-200 dark:text-surface-900' : 'bg-surface-100 text-surface-500 hover:bg-surface-200 dark:bg-surface-700 dark:text-surface-300 dark:hover:bg-surface-600'}">
+          {logTypeButtonLabels[f]}
+        </button>
+      {/each}
+    </div>
+ 
+    <div class="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-3">
+      <label class="block">
+        <span class="block text-xs font-medium text-surface-500 mb-1">Filter by IP</span>
+        <input
+          type="text"
+          bind:value={ipFilter}
+          placeholder="e.g. 1.2.3.4"
+          onkeydown={async (e) => { if (e.key === 'Enter') await loadLogs(); }}
+          class="w-full px-3 py-2 text-sm border border-surface-200 dark:border-surface-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-surface-700 dark:text-white"
+        />
+      </label>
+      <label class="block">
+        <span class="block text-xs font-medium text-surface-500 mb-1">From</span>
+        <input
+          type="date"
+          bind:value={startDate}
+          onchange={loadLogs}
+          class="w-full px-3 py-2 text-sm border border-surface-200 dark:border-surface-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-surface-700 dark:text-white"
+        />
+      </label>
+      <label class="block">
+        <span class="block text-xs font-medium text-surface-500 mb-1">To</span>
+        <input
+          type="date"
+          bind:value={endDate}
+          onchange={loadLogs}
+          class="w-full px-3 py-2 text-sm border border-surface-200 dark:border-surface-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-surface-700 dark:text-white"
+        />
+      </label>
+    </div>
+ 
+    <div class="flex items-center gap-2 mb-3">
+      <button onclick={loadLogs} class="px-3 py-1.5 text-xs font-medium text-primary-600 bg-primary-50 hover:bg-primary-100 rounded-lg transition-colors dark:bg-primary-900/30 dark:text-primary-300">
+        Apply filters
+      </button>
+      {#if ipFilter.trim()}
+        <button onclick={() => applyIpFilter(ipFilter.trim())} class="px-2.5 py-1 text-xs rounded-md transition-colors bg-surface-100 text-surface-500 hover:bg-surface-200 dark:bg-surface-700 dark:text-surface-300 dark:hover:bg-surface-600">
+          Use typed IP
+        </button>
+      {/if}
+    </div>
+ 
+    {#if filterError}
+      <p class="text-sm text-rose-500 mb-3">{filterError}</p>
+    {/if}
+ 
+    {#if logs.length > 0}
+      <div class="max-h-96 overflow-auto rounded-lg bg-surface-950 p-3 space-y-0.5">
+        {#each logs as e}
+          <div class="text-xs font-mono flex gap-2 leading-5">
+            <span class="{typeColors[e.type] || 'text-surface-500'} w-12 shrink-0">{typeLabels[e.type] || e.type}</span>
+            <span class="text-surface-500 shrink-0">{e.timestamp}</span>
+            {#if e.ip}
+              <button onclick={() => applyIpFilter(e.ip)} class="text-surface-400 hover:text-primary-300 shrink-0 w-28 text-left transition-colors">{e.ip}</button>
+            {/if}
+            {#if e.username}
+              <span class="text-red-400 shrink-0">{e.username}</span>
+            {/if}
+            {#if isBlockedScannerEvent(e)}
+              <span class="shrink-0 rounded-md bg-orange-500/15 px-1.5 py-0.5 text-[10px] font-medium text-orange-300 border border-orange-500/30">scanner</span>
+            {/if}
+            <span class="text-surface-300 truncate">{e.message}</span>
+          </div>
+        {/each}
+      </div>
+    {:else if !loading}
+      <p class="text-sm text-surface-400">{hasActiveFilters ? "No security events match the current filters" : "No security events found"}</p>
+    {/if}
+  </div>
+</div>
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/src/routes/Settings.svelte.html b/dashboard/frontend/coverage/src/routes/Settings.svelte.html new file mode 100644 index 0000000..e2b81bf --- /dev/null +++ b/dashboard/frontend/coverage/src/routes/Settings.svelte.html @@ -0,0 +1,1597 @@ + + + + + + Code coverage report for src/routes/Settings.svelte + + + + + + + + + +
+
+

All files / src/routes Settings.svelte

+
+ +
+ 0% + Statements + 0/362 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/362 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504 +505  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
<script>
+  import { post, get, del, put } from "../lib/api.js";
+  import { onMount } from "svelte";
+  import { currentUser } from "../lib/api.js";
+ 
+  let currentPassword = $state("");
+  let newPassword = $state("");
+  let confirmPassword = $state("");
+  let message = $state("");
+  let error = $state("");
+  let saving = $state(false);
+ 
+  let passkeys = $state([]);
+  let passkeyMsg = $state("");
+  let passkeyErr = $state("");
+  let passkeyLoading = $state(false);
+ 
+  async function loadPasskeys() {
+    try {
+      const res = await get("/auth/passkey/list");
+      passkeys = res.passkeys;
+    } catch {}
+  }
+ 
+  function base64urlToBuffer(b64) {
+    const s = b64.replace(/-/g, '+').replace(/_/g, '/');
+    const raw = atob(s);
+    return Uint8Array.from(raw, c => c.charCodeAt(0)).buffer;
+  }
+ 
+  function bufferToBase64url(buf) {
+    const bytes = new Uint8Array(buf);
+    let s = '';
+    bytes.forEach(b => s += String.fromCharCode(b));
+    return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+  }
+ 
+  async function registerPasskey() {
+    alert("registerPasskey called!");
+    passkeyLoading = true;
+    passkeyMsg = ""; passkeyErr = "";
+    try {
+      alert("Checking PublicKeyCredential support...");
+      if (!window.PublicKeyCredential) {
+        alert("PublicKeyCredential NOT supported!");
+        throw new Error("Passkeys are not supported in this browser or context");
+      }
+      alert("Fetching registration options...");
+      const opts = await post("/auth/passkey/register/options");
+      alert("Got options: " + JSON.stringify(opts).substring(0, 100));
+      opts.challenge = base64urlToBuffer(opts.challenge);
+      opts.user.id = base64urlToBuffer(opts.user.id);
+      if (opts.excludeCredentials) {
+        opts.excludeCredentials = opts.excludeCredentials.map(c => ({ ...c, id: base64urlToBuffer(c.id) }));
+      }
+      const cred = await navigator.credentials.create({ publicKey: opts });
+      if (!cred) {
+        throw new Error("Passkey creation was cancelled");
+      }
+      const name = prompt("Name this passkey (e.g. MacBook, iPhone):", "Passkey") || "Passkey";
+      const body = {
+        id: cred.id,
+        rawId: bufferToBase64url(cred.rawId),
+        type: cred.type,
+        name,
+        response: {
+          attestationObject: bufferToBase64url(cred.response.attestationObject),
+          clientDataJSON: bufferToBase64url(cred.response.clientDataJSON),
+        },
+      };
+      await post("/auth/passkey/register/verify", body);
+      passkeyMsg = "Passkey registered successfully";
+      await loadPasskeys();
+    } catch (e) {
+      console.error("Passkey registration error:", e);
+      const errorMsg = e.body?.detail || e.message || "Failed to register passkey";
+      passkeyErr = errorMsg;
+      alert("Passkey Error: " + errorMsg + "\n\nFull error: " + JSON.stringify(e, null, 2));
+    } finally {
+      passkeyLoading = false;
+    }
+  }
+ 
+  async function clearPasskeys() {
+    if (!confirm("Remove all passkeys?")) return;
+    passkeyLoading = true;
+    passkeyMsg = ""; passkeyErr = "";
+    try {
+      await post("/auth/passkey/clear");
+      passkeyMsg = "All passkeys removed";
+      passkeys = [];
+    } catch (e) {
+      passkeyErr = e.body?.detail || "Failed to remove passkeys";
+    }
+    passkeyLoading = false;
+  }
+ 
+  async function deletePasskey(id, name) {
+    if (!confirm(`Remove passkey "${name}"?`)) return;
+    passkeyLoading = true;
+    passkeyMsg = ""; passkeyErr = "";
+    try {
+      await post("/auth/passkey/delete", { id });
+      passkeyMsg = `Passkey "${name}" removed`;
+      await loadPasskeys();
+    } catch (e) {
+      passkeyErr = e.body?.detail || "Failed to remove passkey";
+    }
+    passkeyLoading = false;
+  }
+ 
+  async function changePassword() {
+    error = "";
+    message = "";
+    if (newPassword !== confirmPassword) { error = "Passwords do not match"; return; }
+    if (newPassword.length < 8) { error = "Password must be at least 8 characters"; return; }
+    saving = true;
+    try {
+      await post("/auth/change-password", { current_password: currentPassword, new_password: newPassword });
+      message = "Password changed successfully";
+      currentPassword = ""; newPassword = ""; confirmPassword = "";
+    } catch (e) {
+      error = e.body?.detail || "Failed to change password";
+    }
+    saving = false;
+  }
+ 
+  let auditEntries = $state([]);
+  let auditLoading = $state(false);
+  let auditFilter = $state("all");
+ 
+  const levelColors = { high: "text-rose-400", medium: "text-amber-400", low: "text-emerald-400", info: "text-surface-400" };
+  const levelLabels = { high: "HIGH", medium: "MED", low: "LOW", info: "INFO" };
+ 
+  let filteredEntries = $derived(auditFilter === "all" ? auditEntries : auditEntries.filter(e => e.level === auditFilter));
+ 
+  async function loadAuditLog() {
+    auditLoading = true;
+    try {
+      const res = await get("/system/audit-log?lines=200");
+      auditEntries = res.entries.reverse();
+    } catch {}
+    auditLoading = false;
+  }
+ 
+  // Access Control
+  const allPages = ["dashboard", "docker", "files", "terminal", "security", "openclaw", "chat-digest", "gitea", "settings"];
+  const allSidebarLinks = ["Overview", "Docker", "Files", "Terminal", "Security", "Navidrome", "Jellyfin", "Audiobookshelf", "Immich", "OpenClaw", "Chat Digest", "Repos", "Gitea Web", "Stirling PDF", "Vaultwarden", "n8n", "Speedtest"];
+  let rbacConfig = $state(null);
+  let rbacLoading = $state(false);
+  let rbacMsg = $state("");
+  let rbacErr = $state("");
+  let editingUser = $state("");
+  let editPages = $state([]);
+  let newUsername = $state("");
+  let editingRole = $state("");
+  let editRolePages = $state([]);
+  let editRoleSidebarLinks = $state([]);
+ 
+  async function loadRbac() {
+    rbacLoading = true;
+    try {
+      rbacConfig = await get("/auth/rbac/config");
+    } catch {}
+    rbacLoading = false;
+  }
+ 
+  function startEdit(username, pages) {
+    editingUser = username;
+    editPages = [...pages];
+    newUsername = "";
+    editingRole = "";
+  }
+ 
+  function startAdd() {
+    editingUser = "__new__";
+    editPages = ["dashboard"];
+    newUsername = "";
+    editingRole = "";
+  }
+ 
+  function cancelEdit() {
+    editingUser = "";
+    editPages = [];
+    newUsername = "";
+    editingRole = "";
+    editRolePages = [];
+    editRoleSidebarLinks = [];
+  }
+ 
+  function startEditRole(role, pages, sidebarLinks) {
+    editingRole = role;
+    editRolePages = pages === "*" ? ["*"] : [...pages];
+    editRoleSidebarLinks = sidebarLinks === "*" ? ["*"] : (sidebarLinks ? [...sidebarLinks] : ["*"]);
+    editingUser = "";
+  }
+ 
+  function toggleRolePage(p) {
+    if (p === "*") {
+      editRolePages = ["*"];
+    } else {
+      if (editRolePages.includes("*")) editRolePages = [];
+      if (editRolePages.includes(p)) editRolePages = editRolePages.filter(x => x !== p);
+      else editRolePages = [...editRolePages, p];
+    }
+  }
+ 
+  function toggleRoleSidebarLink(link) {
+    if (link === "*") {
+      editRoleSidebarLinks = ["*"];
+    } else {
+      if (editRoleSidebarLinks.includes("*")) editRoleSidebarLinks = [];
+      if (editRoleSidebarLinks.includes(link)) editRoleSidebarLinks = editRoleSidebarLinks.filter(x => x !== link);
+      else editRoleSidebarLinks = [...editRoleSidebarLinks, link];
+    }
+  }
+ 
+  function togglePage(p) {
+    if (editPages.includes(p)) editPages = editPages.filter(x => x !== p);
+    else editPages = [...editPages, p];
+  }
+ 
+  async function saveOverride() {
+    rbacMsg = ""; rbacErr = "";
+    const uname = editingUser === "__new__" ? newUsername.trim() : editingUser;
+    if (!uname) { rbacErr = "Username required"; return; }
+    if (!editPages.length) { rbacErr = "Select at least one page"; return; }
+    try {
+      await put(`/auth/rbac/overrides/${encodeURIComponent(uname)}`, { pages: editPages });
+      rbacMsg = `Saved override for ${uname}`;
+      cancelEdit();
+      await loadRbac();
+    } catch (e) {
+      rbacErr = e.body?.detail || "Failed to save";
+    }
+  }
+ 
+  async function deleteOverride(username) {
+    if (!confirm(`Remove override for "${username}"?`)) return;
+    rbacMsg = ""; rbacErr = "";
+    try {
+      await del(`/auth/rbac/overrides/${encodeURIComponent(username)}`);
+      rbacMsg = `Removed override for ${username}`;
+      await loadRbac();
+    } catch (e) {
+      rbacErr = e.body?.detail || "Failed to delete";
+    }
+  }
+ 
+  async function saveRoleDefault() {
+    rbacMsg = ""; rbacErr = "";
+    if (!editingRole) return;
+    if (!editRolePages.length) { rbacErr = "Select at least one page or '*' for all"; return; }
+    if (!editRoleSidebarLinks.length) { rbacErr = "Select at least one sidebar link or '*' for all"; return; }
+    try {
+      const pages = editRolePages.includes("*") ? "*" : editRolePages;
+      const sidebar_links = editRoleSidebarLinks.includes("*") ? "*" : editRoleSidebarLinks;
+      await put(`/auth/rbac/roles/${encodeURIComponent(editingRole)}`, { pages, sidebar_links });
+      rbacMsg = `Updated role default for ${editingRole}`;
+      cancelEdit();
+      await loadRbac();
+    } catch (e) {
+      rbacErr = e.body?.detail || "Failed to save role";
+    }
+  }
+ 
+  onMount(() => {
+    loadPasskeys();
+    if (currentUser.role === "admin") loadRbac();
+  });
+</script>
+ 
+<div class="space-y-6">
+  <div>
+    <h1 class="text-2xl font-bold text-surface-900 dark:text-white tracking-tight">Settings</h1>
+    <p class="text-sm text-surface-400 mt-1">Account and security settings</p>
+  </div>
+ 
+  <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-6 shadow-sm max-w-md">
+    <h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200 mb-4">Change Password</h3>
+ 
+    {#if message}
+      <div class="text-sm text-emerald-600 bg-emerald-50 px-3 py-2 rounded-lg mb-4">{message}</div>
+    {/if}
+    {#if error}
+      <div class="text-sm text-rose-600 bg-rose-50 px-3 py-2 rounded-lg mb-4">{error}</div>
+    {/if}
+ 
+    <form onsubmit={(e) => { e.preventDefault(); changePassword(); }} class="space-y-3">
+      <div>
+        <label class="block text-xs font-medium text-surface-500 mb-1">Current Password</label>
+        <input type="password" bind:value={currentPassword} required class="w-full px-3 py-2 text-sm border border-surface-200 dark:border-surface-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-surface-700 dark:text-white" />
+      </div>
+      <div>
+        <label class="block text-xs font-medium text-surface-500 mb-1">New Password</label>
+        <input type="password" bind:value={newPassword} required class="w-full px-3 py-2 text-sm border border-surface-200 dark:border-surface-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-surface-700 dark:text-white" />
+      </div>
+      <div>
+        <label class="block text-xs font-medium text-surface-500 mb-1">Confirm New Password</label>
+        <input type="password" bind:value={confirmPassword} required class="w-full px-3 py-2 text-sm border border-surface-200 dark:border-surface-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-surface-700 dark:text-white" />
+      </div>
+      <button type="submit" disabled={saving} class="w-full px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 rounded-lg transition-colors disabled:opacity-50">
+        {saving ? "Saving..." : "Change Password"}
+      </button>
+    </form>
+  </div>
+ 
+  <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-6 shadow-sm max-w-md">
+    <h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200 mb-4">Passkeys</h3>
+ 
+    {#if passkeyMsg}
+      <div class="text-sm text-emerald-600 bg-emerald-50 px-3 py-2 rounded-lg mb-4">{passkeyMsg}</div>
+    {/if}
+    {#if passkeyErr}
+      <div class="text-sm text-rose-600 bg-rose-50 px-3 py-2 rounded-lg mb-4">{passkeyErr}</div>
+    {/if}
+ 
+    {#if passkeys.length > 0}
+      <div class="space-y-2 mb-4">
+        {#each passkeys as pk}
+          <div class="flex items-center justify-between px-3 py-2 bg-surface-50 dark:bg-surface-700 rounded-lg">
+            <div>
+              <span class="text-sm font-medium text-surface-700 dark:text-surface-200">{pk.name}</span>
+              {#if pk.created_at}
+                <span class="text-xs text-surface-400 ml-2">{new Date(pk.created_at).toLocaleDateString()}</span>
+              {/if}
+            </div>
+            <button onclick={() => deletePasskey(pk.id, pk.name)} disabled={passkeyLoading} class="text-xs text-rose-500 hover:text-rose-700 disabled:opacity-50">Remove</button>
+          </div>
+        {/each}
+      </div>
+    {:else}
+      <p class="text-sm text-surface-400 mb-4">No passkeys registered</p>
+    {/if}
+ 
+    <div class="flex gap-2">
+      <button onclick={registerPasskey} disabled={passkeyLoading} class="px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 rounded-lg transition-colors disabled:opacity-50">
+        Register new passkey
+      </button>
+      {#if passkeys.length > 1}
+        <button onclick={clearPasskeys} disabled={passkeyLoading} class="px-4 py-2 text-sm font-medium text-rose-600 bg-rose-50 hover:bg-rose-100 rounded-lg transition-colors disabled:opacity-50">
+          Remove all
+        </button>
+      {/if}
+    </div>
+  </div>
+ 
+  {#if currentUser.role === "admin" && rbacConfig}
+  <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-6 shadow-sm">
+    <div class="flex items-center justify-between mb-4">
+      <h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200">Access Control</h3>
+      <button onclick={loadRbac} disabled={rbacLoading} class="px-3 py-1.5 text-xs font-medium text-primary-600 bg-primary-50 hover:bg-primary-100 rounded-lg transition-colors disabled:opacity-50">
+        {rbacLoading ? "Loading..." : "Refresh"}
+      </button>
+    </div>
+ 
+    {#if rbacMsg}
+      <div class="text-sm text-emerald-600 bg-emerald-50 px-3 py-2 rounded-lg mb-4">{rbacMsg}</div>
+    {/if}
+    {#if rbacErr}
+      <div class="text-sm text-rose-600 bg-rose-50 px-3 py-2 rounded-lg mb-4">{rbacErr}</div>
+    {/if}
+ 
+    <!-- Role Defaults -->
+    <div class="mb-4">
+      <p class="text-xs font-medium text-surface-500 mb-2">Role Defaults</p>
+      <div class="space-y-2">
+        {#each Object.entries(rbacConfig.role_defaults || {}) as [role, cfg]}
+          {#if editingRole === role}
+            <div class="p-3 bg-surface-50 dark:bg-surface-700 rounded-lg space-y-2">
+              <p class="text-xs font-medium text-surface-700 dark:text-surface-200">{role}</p>
+              <p class="text-[10px] text-surface-500 mt-1">Pages (internal dashboard pages)</p>
+              <div class="flex flex-wrap gap-1.5">
+                <button onclick={() => toggleRolePage("*")} class="px-2 py-0.5 text-xs rounded-md transition-colors {editRolePages.includes('*') ? 'bg-primary-600 text-white' : 'bg-surface-200 dark:bg-surface-600 text-surface-500'}">* (all)</button>
+                {#each allPages as p}
+                  <button onclick={() => toggleRolePage(p)} class="px-2 py-0.5 text-xs rounded-md transition-colors {editRolePages.includes(p) ? 'bg-primary-600 text-white' : 'bg-surface-200 dark:bg-surface-600 text-surface-500'}">{p}</button>
+                {/each}
+              </div>
+              <p class="text-[10px] text-surface-500 mt-2">Sidebar Links (all services)</p>
+              <div class="flex flex-wrap gap-1.5">
+                <button onclick={() => toggleRoleSidebarLink("*")} class="px-2 py-0.5 text-xs rounded-md transition-colors {editRoleSidebarLinks.includes('*') ? 'bg-primary-600 text-white' : 'bg-surface-200 dark:bg-surface-600 text-surface-500'}">* (all)</button>
+                {#each allSidebarLinks as link}
+                  <button onclick={() => toggleRoleSidebarLink(link)} class="px-2 py-0.5 text-xs rounded-md transition-colors {editRoleSidebarLinks.includes(link) ? 'bg-primary-600 text-white' : 'bg-surface-200 dark:bg-surface-600 text-surface-500'}">{link}</button>
+                {/each}
+              </div>
+              <div class="flex gap-2">
+                <button onclick={saveRoleDefault} class="px-3 py-1 text-xs font-medium text-white bg-primary-600 hover:bg-primary-700 rounded-md transition-colors">Save</button>
+                <button onclick={cancelEdit} class="px-3 py-1 text-xs font-medium text-surface-600 hover:text-surface-800 dark:text-surface-400 dark:hover:text-surface-200">Cancel</button>
+              </div>
+            </div>
+          {:else}
+            <div class="flex items-center justify-between p-2 bg-surface-50 dark:bg-surface-700 rounded-lg">
+              <div class="flex flex-col gap-1 text-xs">
+                <div class="flex items-center gap-2">
+                  <span class="font-medium text-surface-700 dark:text-surface-300 w-16">{role}</span>
+                  <span class="text-surface-400">Pages: {cfg.pages === "*" ? "All" : (cfg.pages || []).join(", ")}</span>
+                </div>
+                <div class="flex items-center gap-2">
+                  <span class="w-16"></span>
+                  <span class="text-surface-400">Links: {cfg.sidebar_links === "*" ? "All" : (cfg.sidebar_links || ["*"]).join(", ")}</span>
+                </div>
+              </div>
+              <button onclick={() => startEditRole(role, cfg.pages, cfg.sidebar_links)} class="text-xs text-primary-500 hover:text-primary-700">Edit</button>
+            </div>
+          {/if}
+        {/each}
+      </div>
+    </div>
+ 
+    <!-- User Overrides -->
+    <div class="mb-4">
+      <div class="flex items-center justify-between mb-2">
+        <p class="text-xs font-medium text-surface-500">User Overrides</p>
+        {#if editingUser !== "__new__"}
+          <button onclick={startAdd} class="px-2 py-1 text-xs font-medium text-primary-600 bg-primary-50 hover:bg-primary-100 rounded-md transition-colors">Add Override</button>
+        {/if}
+      </div>
+ 
+      {#if Object.keys(rbacConfig.user_overrides || {}).length > 0}
+        <div class="space-y-2">
+          {#each Object.entries(rbacConfig.user_overrides) as [username, cfg]}
+            {#if editingUser === username}
+              <div class="p-3 bg-surface-50 dark:bg-surface-700 rounded-lg space-y-2">
+                <p class="text-xs font-medium text-surface-700 dark:text-surface-200">{username}</p>
+                <div class="flex flex-wrap gap-1.5">
+                  {#each allPages as p}
+                    <button onclick={() => togglePage(p)} class="px-2 py-0.5 text-xs rounded-md transition-colors {editPages.includes(p) ? 'bg-primary-600 text-white' : 'bg-surface-200 dark:bg-surface-600 text-surface-500'}">{p}</button>
+                  {/each}
+                </div>
+                <div class="flex gap-2">
+                  <button onclick={saveOverride} class="px-3 py-1 text-xs font-medium text-white bg-primary-600 hover:bg-primary-700 rounded-md">Save</button>
+                  <button onclick={cancelEdit} class="px-3 py-1 text-xs font-medium text-surface-500 bg-surface-200 hover:bg-surface-300 rounded-md">Cancel</button>
+                </div>
+              </div>
+            {:else}
+              <div class="flex items-center justify-between px-3 py-2 bg-surface-50 dark:bg-surface-700 rounded-lg">
+                <div>
+                  <span class="text-sm font-medium text-surface-700 dark:text-surface-200">{username}</span>
+                  <span class="text-xs text-surface-400 ml-2">{(cfg.pages || []).join(", ")}</span>
+                </div>
+                <div class="flex gap-1.5">
+                  <button onclick={() => startEdit(username, cfg.pages || [])} class="text-xs text-primary-500 hover:text-primary-700">Edit</button>
+                  <button onclick={() => deleteOverride(username)} class="text-xs text-rose-500 hover:text-rose-700">Delete</button>
+                </div>
+              </div>
+            {/if}
+          {/each}
+        </div>
+      {:else if editingUser !== "__new__"}
+        <p class="text-sm text-surface-400">No user overrides configured</p>
+      {/if}
+ 
+      {#if editingUser === "__new__"}
+        <div class="p-3 bg-surface-50 dark:bg-surface-700 rounded-lg space-y-2 mt-2">
+          <input type="text" bind:value={newUsername} placeholder="Username" class="w-full px-3 py-1.5 text-sm border border-surface-200 dark:border-surface-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-surface-800 dark:text-white" />
+          <div class="flex flex-wrap gap-1.5">
+            {#each allPages as p}
+              <button onclick={() => togglePage(p)} class="px-2 py-0.5 text-xs rounded-md transition-colors {editPages.includes(p) ? 'bg-primary-600 text-white' : 'bg-surface-200 dark:bg-surface-600 text-surface-500'}">{p}</button>
+            {/each}
+          </div>
+          <div class="flex gap-2">
+            <button onclick={saveOverride} class="px-3 py-1 text-xs font-medium text-white bg-primary-600 hover:bg-primary-700 rounded-md">Save</button>
+            <button onclick={cancelEdit} class="px-3 py-1 text-xs font-medium text-surface-500 bg-surface-200 hover:bg-surface-300 rounded-md">Cancel</button>
+          </div>
+        </div>
+      {/if}
+    </div>
+  </div>
+  {/if}
+ 
+  <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-6 shadow-sm">
+    <div class="flex items-center justify-between mb-4">
+      <h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200">Audit Log</h3>
+      <button onclick={loadAuditLog} disabled={auditLoading} class="px-3 py-1.5 text-xs font-medium text-primary-600 bg-primary-50 hover:bg-primary-100 rounded-lg transition-colors disabled:opacity-50">
+        {auditLoading ? "Loading..." : auditEntries.length ? "Refresh" : "Load"}
+      </button>
+    </div>
+    {#if auditEntries.length > 0}
+      <div class="flex gap-1.5 mb-3">
+        {#each ["all", "high", "medium", "low", "info"] as lvl}
+          <button onclick={() => auditFilter = lvl} class="px-2.5 py-1 text-xs rounded-md transition-colors {auditFilter === lvl ? 'bg-surface-800 text-white' : 'bg-surface-100 text-surface-500 hover:bg-surface-200'}">
+            {lvl === "all" ? "All" : levelLabels[lvl] || lvl}
+            {#if lvl !== "all"}
+              <span class="ml-1 opacity-60">{auditEntries.filter(e => e.level === lvl).length}</span>
+            {/if}
+          </button>
+        {/each}
+      </div>
+      <div class="max-h-80 overflow-auto rounded-lg bg-surface-950 p-3 space-y-0.5">
+        {#each filteredEntries as e}
+          <div class="text-xs font-mono flex gap-2 leading-5">
+            <span class="{levelColors[e.level]} w-8 shrink-0">{levelLabels[e.level]}</span>
+            <span class="text-surface-500 shrink-0">{e.ts.replace('T', ' ')}</span>
+            <span class="text-surface-400 shrink-0 w-10">{e.status}</span>
+            <span class="text-surface-300 shrink-0">{e.user}</span>
+            <span class="text-surface-500">{e.method} {e.path}</span>
+          </div>
+        {/each}
+      </div>
+    {:else if !auditLoading}
+      <p class="text-sm text-surface-400">Click Load to view recent audit log entries</p>
+    {/if}
+  </div>
+</div>
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/src/routes/Terminal.svelte.html b/dashboard/frontend/coverage/src/routes/Terminal.svelte.html new file mode 100644 index 0000000..c3a9e5f --- /dev/null +++ b/dashboard/frontend/coverage/src/routes/Terminal.svelte.html @@ -0,0 +1,2494 @@ + + + + + + Code coverage report for src/routes/Terminal.svelte + + + + + + + + + +
+
+

All files / src/routes Terminal.svelte

+
+ +
+ 0% + Statements + 0/545 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/545 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504 +505 +506 +507 +508 +509 +510 +511 +512 +513 +514 +515 +516 +517 +518 +519 +520 +521 +522 +523 +524 +525 +526 +527 +528 +529 +530 +531 +532 +533 +534 +535 +536 +537 +538 +539 +540 +541 +542 +543 +544 +545 +546 +547 +548 +549 +550 +551 +552 +553 +554 +555 +556 +557 +558 +559 +560 +561 +562 +563 +564 +565 +566 +567 +568 +569 +570 +571 +572 +573 +574 +575 +576 +577 +578 +579 +580 +581 +582 +583 +584 +585 +586 +587 +588 +589 +590 +591 +592 +593 +594 +595 +596 +597 +598 +599 +600 +601 +602 +603 +604 +605 +606 +607 +608 +609 +610 +611 +612 +613 +614 +615 +616 +617 +618 +619 +620 +621 +622 +623 +624 +625 +626 +627 +628 +629 +630 +631 +632 +633 +634 +635 +636 +637 +638 +639 +640 +641 +642 +643 +644 +645 +646 +647 +648 +649 +650 +651 +652 +653 +654 +655 +656 +657 +658 +659 +660 +661 +662 +663 +664 +665 +666 +667 +668 +669 +670 +671 +672 +673 +674 +675 +676 +677 +678 +679 +680 +681 +682 +683 +684 +685 +686 +687 +688 +689 +690 +691 +692 +693 +694 +695 +696 +697 +698 +699 +700 +701 +702 +703 +704 +705 +706 +707 +708 +709 +710 +711 +712 +713 +714 +715 +716 +717 +718 +719 +720 +721 +722 +723 +724 +725 +726 +727 +728 +729 +730 +731 +732 +733 +734 +735 +736 +737 +738 +739 +740 +741 +742 +743 +744 +745 +746 +747 +748 +749 +750 +751 +752 +753 +754 +755 +756 +757 +758 +759 +760 +761 +762 +763 +764 +765 +766 +767 +768 +769 +770 +771 +772 +773 +774 +775 +776 +777 +778 +779 +780 +781 +782 +783 +784 +785 +786 +787 +788 +789 +790 +791 +792 +793 +794 +795 +796 +797 +798 +799 +800 +801 +802 +803 +804  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
<script>
+  import { onMount, onDestroy } from "svelte";
+  import { VoiceSession } from "../lib/voice.js";
+  import { tryRefreshSession } from "../lib/api.js";
+ 
+  const PERSISTENCE_STATUS_PREFIX = "__DASHBOARD_PERSISTENCE__:";
+  const PERSISTENCE_STATUS_PREFIX_BYTES = new TextEncoder().encode(PERSISTENCE_STATUS_PREFIX);
+ 
+  const hosts = [
+    { id: "nas", label: "NAS" },
+    { id: "vps", label: "VPS" },
+    { id: "caddy-vps", label: "Caddy VPS" },
+    { id: "mac", label: "Mac" },
+    { id: "claude-dev", label: "Claude Dev", persistent: true },
+  ];
+ 
+  const DARK_THEME = {
+    background: "#0f172a", foreground: "#e2e8f0", cursor: "#6366f1",
+    cursorAccent: "#0f172a", selectionBackground: "#334155",
+    black: "#0f172a", red: "#f43f5e", green: "#10b981",
+    yellow: "#f59e0b", blue: "#3b82f6", magenta: "#a855f7",
+    cyan: "#06b6d4", white: "#e2e8f0",
+  };
+ 
+  const LIGHT_THEME = {
+    background: "#f8fafc", foreground: "#0f172a", cursor: "#4f46e5",
+    cursorAccent: "#f8fafc", selectionBackground: "#cbd5e1",
+    black: "#0f172a", red: "#e11d48", green: "#059669",
+    yellow: "#d97706", blue: "#2563eb", magenta: "#9333ea",
+    cyan: "#0891b2", white: "#334155",
+  };
+ 
+  let tabs = $state([]);
+  let activeTab = $state(null);
+  let showMenu = $state(false);
+  let voiceTick = $state(0);
+  let isDark = $state(false);
+  let isFullscreen = $state(false);
+  let showActionButtons = $state(false);
+  let tabCounter = 0;
+  const tabData = new Map();
+ 
+  function currentTheme() {
+    return isDark ? DARK_THEME : LIGHT_THEME;
+  }
+ 
+  function hostById(hostId) {
+    return hosts.find((h) => h.id === hostId);
+  }
+ 
+  function openFreshSession(tab) {
+    const url = new URL(window.location.href);
+    url.searchParams.set("page", "terminal");
+    url.searchParams.set("host", tab.hostId);
+    window.open(`${url.pathname}${url.search}${url.hash}`, "_blank", "noopener");
+  }
+ 
+  function persistenceLabel(status, reconnecting) {
+    if (status === "created") return "created new persistent session";
+    if (reconnecting) return "reattached to persistent session";
+    return "attached to persistent session";
+  }
+ 
+  function applyThemeToTerminals() {
+    const theme = currentTheme();
+    tabData.forEach((d) => {
+      d.term?.options && (d.term.options.theme = theme);
+    });
+  }
+ 
+  function syncTheme() {
+    isDark = document.documentElement.classList.contains("dark");
+    applyThemeToTerminals();
+  }
+ 
+  const themeObserver = new MutationObserver((mutations) => {
+    if (mutations.some((m) => m.attributeName === "class")) syncTheme();
+  });
+  onMount(() => {
+    syncTheme();
+    themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
+    const requestedHost = new URLSearchParams(window.location.search).get("host");
+    if (requestedHost && !tabs.length && hostById(requestedHost)) addTab(requestedHost);
+  });
+ 
+  function cleanName(name) {
+    return (name || "image")
+      .replace(/[^a-zA-Z0-9._-]/g, "-")
+      .replace(/-+/g, "-")
+      .replace(/^-|-$/g, "") || "image";
+  }
+ 
+  function extensionFor(type) {
+    if (type === "image/png") return "png";
+    if (type === "image/jpeg") return "jpg";
+    if (type === "image/webp") return "webp";
+    if (type === "image/gif") return "gif";
+    return "bin";
+  }
+ 
+  async function fileToBase64(file) {
+    const buf = await file.arrayBuffer();
+    let binary = "";
+    const bytes = new Uint8Array(buf);
+    const chunkSize = 0x8000;
+    for (let i = 0; i < bytes.length; i += chunkSize) {
+      binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
+    }
+    return btoa(binary);
+  }
+ 
+  async function sendDroppedImage(term, ws, file) {
+    if (!file?.type?.startsWith("image/")) return;
+    if (ws.readyState !== 1) return;
+ 
+    const ext = extensionFor(file.type);
+    const baseName = cleanName(file.name).replace(/\.[a-zA-Z0-9]+$/, "");
+    const stamp = Date.now();
+    const imagePath = `/tmp/claude-images/${baseName || "image"}-${stamp}.${ext}`;
+    const b64 = await fileToBase64(file);
+ 
+    const cmd = [
+      "python3 - <<'PY'",
+      "import base64, pathlib",
+      `p = pathlib.Path(${JSON.stringify(imagePath)})`,
+      "p.parent.mkdir(parents=True, exist_ok=True)",
+      `p.write_bytes(base64.b64decode(${JSON.stringify(b64)}))`,
+      `print(f'saved image -> {p}')`,
+      "PY",
+      "",
+    ].join("\n");
+ 
+    ws.send(new TextEncoder().encode(cmd));
+    term.write(`\r\n\x1b[90m[Uploaded image to ${imagePath}]\x1b[0m\r\n`);
+  }
+ 
+  function addTab(hostId) {
+    const id = ++tabCounter;
+    const host = hostById(hostId);
+    tabs = [...tabs, {
+      id,
+      hostId,
+      label: host.label,
+      connected: false,
+      error: "",
+      statusBanner: host.persistent ? "Connecting to persistent session..." : "",
+      persistent: !!host.persistent,
+    }];
+    activeTab = id;
+    showMenu = false;
+  }
+ 
+  function closeTab(id) {
+    const d = tabData.get(id);
+    if (d) {
+      d.closedManually = true;
+      if (d.reconnectTimer) clearTimeout(d.reconnectTimer);
+      d.voice?.destroy();
+      d.ro?.disconnect();
+      if (d.heartbeat) clearInterval(d.heartbeat);
+      d.term?.element?.removeEventListener("dragover", d.handleDragOver);
+      d.term?.element?.removeEventListener("drop", d.handleDrop);
+      d.term?.textarea?.removeEventListener("paste", d.handlePaste);
+      d.ws?.close();
+      d.term?.dispose();
+      tabData.delete(id);
+    }
+    tabs = tabs.filter((t) => t.id !== id);
+    if (activeTab === id) activeTab = tabs.length ? tabs[tabs.length - 1].id : null;
+  }
+ 
+  function updateTab(tabId, props) {
+    tabs = tabs.map((t) => (t.id === tabId ? { ...t, ...props } : t));
+  }
+ 
+  function initTerminal(el, tab) {
+    const tabId = tab.id;
+    (async () => {
+      const { Terminal } = await import("@xterm/xterm");
+      const { FitAddon } = await import("@xterm/addon-fit");
+      await import("@xterm/xterm/css/xterm.css");
+      const term = new Terminal({
+        cursorBlink: true, fontSize: 13,
+        fontFamily: "'JetBrains Mono', 'Fira Code', 'SF Mono', 'Menlo', monospace",
+        lineHeight: 1.4,
+        theme: currentTheme(),
+      });
+      const fit = new FitAddon();
+      term.loadAddon(fit);
+      term.open(el);
+      fit.fit();
+ 
+      const proto = location.protocol === "https:" ? "wss:" : "ws:";
+      let heartbeat = null;
+      let pongTimeout = null;
+      let reconnectTimer = null;
+      let closedManually = false;
+      let reconnecting = false;
+      let authRecoveryInFlight = false;
+      let reconnectAttempts = 0;
+      let consecutiveAuthFailures = 0;
+      const MAX_RECONNECT_DELAY = 30000;
+      const MAX_AUTH_FAILURES = 3;
+      const PING_INTERVAL = 30000; // 30s between pings
+      const PONG_TIMEOUT = 10000; // 10s to receive pong
+ 
+      function clearHeartbeat() {
+        if (heartbeat) {
+          clearInterval(heartbeat);
+          heartbeat = null;
+        }
+        if (pongTimeout) {
+          clearTimeout(pongTimeout);
+          pongTimeout = null;
+        }
+      }
+ 
+      function handleAuthExpired(reason = "Session expired — please log in again") {
+        clearHeartbeat();
+        if (reconnectTimer) {
+          clearTimeout(reconnectTimer);
+          reconnectTimer = null;
+        }
+        reconnecting = false;
+        updateTab(tabId, {
+          connected: false,
+          error: reason,
+          statusBanner: "",
+        });
+        term.write(`\r\n\x1b[90m[${reason}]\x1b[0m`);
+        const d = tabData.get(tabId);
+        if (d) {
+          d.ws = null;
+          d.heartbeat = heartbeat;
+          d.reconnectTimer = reconnectTimer;
+          d.closedManually = closedManually;
+          d.reconnecting = reconnecting;
+        }
+      }
+ 
+      function scheduleReconnect(reason) {
+        clearHeartbeat();
+        if (closedManually || reconnectTimer) return;
+        reconnecting = true;
+        reconnectAttempts++;
+        const delay = Math.min(1000 * Math.pow(2, reconnectAttempts - 1), MAX_RECONNECT_DELAY);
+        updateTab(tabId, {
+          connected: false,
+          error: `${reason} — reconnecting in ${Math.round(delay / 1000)}s (attempt ${reconnectAttempts})...`,
+          statusBanner: tab.persistent ? "Reconnecting to persistent session..." : "",
+        });
+        term.write(`\r\n\x1b[90m[${reason} — reconnecting in ${Math.round(delay / 1000)}s...]\x1b[0m`);
+        reconnectTimer = setTimeout(() => {
+          reconnectTimer = null;
+          if (!tabData.has(tabId) || closedManually) return;
+          connect(true);
+        }, delay);
+      }
+ 
+      function sendSize(ws) {
+        if (ws.readyState === 1) {
+          const buf = new Uint8Array(5);
+          buf[0] = 0x01;
+          new DataView(buf.buffer).setUint16(1, term.cols);
+          new DataView(buf.buffer).setUint16(3, term.rows);
+          ws.send(buf);
+        }
+      }
+ 
+      async function connect(forceRefresh = false) {
+        let d = tabData.get(tabId);
+ 
+        if (forceRefresh) {
+          const refreshed = await tryRefreshSession();
+          if (!refreshed) {
+            handleAuthExpired();
+            return null;
+          }
+        }
+ 
+        const ws = new WebSocket(
+          `${proto}//${location.host}/ws/terminal?host=${encodeURIComponent(tab.hostId)}`
+        );
+        ws.binaryType = "arraybuffer";
+ 
+        ws.onopen = () => {
+          reconnectAttempts = 0;
+          consecutiveAuthFailures = 0; // Reset auth failure counter on successful connection
+          updateTab(tabId, {
+            connected: true,
+            error: "",
+            statusBanner: tab.persistent ? "Connected. Waiting for persistent session status..." : "",
+          });
+          sendSize(ws);
+          clearHeartbeat();
+          heartbeat = setInterval(() => {
+            if (ws.readyState === 1) {
+              ws.send("__ping__");
+              // Set timeout to detect missing pong
+              pongTimeout = setTimeout(() => {
+                // Check if still the same WebSocket instance and still open
+                const currentTab = tabData.get(tabId);
+                if (currentTab?.ws === ws && ws.readyState === 1) {
+                  ws.close(1000, "keepalive ping timeout");
+                }
+              }, PONG_TIMEOUT);
+            }
+          }, PING_INTERVAL);
+          const d = tabData.get(tabId);
+          if (d) {
+            d.ws = ws;
+            d.heartbeat = heartbeat;
+            d.reconnectTimer = reconnectTimer;
+            d.closedManually = closedManually;
+            d.reconnecting = reconnecting;
+            d.voice?.attach(term, ws);
+          }
+        };
+        ws.onmessage = (e) => {
+          // Check for text pong response
+          if (typeof e.data === 'string' && e.data === '__pong__') {
+            // Clear the pong timeout - connection is alive
+            if (pongTimeout) {
+              clearTimeout(pongTimeout);
+              pongTimeout = null;
+            }
+            return;
+          }
+ 
+          const data = new Uint8Array(e.data);
+          const isPersistenceMessage = tab.persistent
+            && data.length >= PERSISTENCE_STATUS_PREFIX_BYTES.length
+            && PERSISTENCE_STATUS_PREFIX_BYTES.every((byte, index) => data[index] === byte);
+          if (isPersistenceMessage) {
+            const status = new TextDecoder().decode(data.slice(PERSISTENCE_STATUS_PREFIX_BYTES.length)).trim();
+            updateTab(tabId, { statusBanner: persistenceLabel(status, reconnecting) });
+            reconnecting = false;
+            const d = tabData.get(tabId);
+            if (d) d.reconnecting = reconnecting;
+            return;
+          }
+          term.write(data);
+        };
+        ws.onclose = async (e) => {
+          const reason = e.reason || `Connection closed (code ${e.code})`;
+          const tabState = tabData.get(tabId);
+          if (tabState) tabState.ws = null;
+ 
+          // Handle auth failures (code 1008)
+          if (e.code === 1008) {
+            consecutiveAuthFailures++;
+ 
+            // Stop after MAX_AUTH_FAILURES consecutive auth failures
+            if (consecutiveAuthFailures >= MAX_AUTH_FAILURES) {
+              handleAuthExpired(`Session expired after ${MAX_AUTH_FAILURES} attempts — please refresh page`);
+              return;
+            }
+ 
+            // Try to refresh session
+            if (!closedManually && !authRecoveryInFlight) {
+              authRecoveryInFlight = true;
+              try {
+                const refreshed = await tryRefreshSession();
+                if (refreshed && !closedManually) {
+                  reconnecting = true;
+                  updateTab(tabId, {
+                    connected: false,
+                    error: `Refreshing session (attempt ${consecutiveAuthFailures}/${MAX_AUTH_FAILURES})...`,
+                    statusBanner: tab.persistent ? "Reconnecting to persistent session..." : "",
+                  });
+                  term.write(`\r\n\x1b[90m[Refreshing session...]\x1b[0m`);
+                  void connect(false);
+                  return;
+                }
+              } catch (err) {
+                console.error("Session refresh failed:", err);
+              } finally {
+                authRecoveryInFlight = false;
+              }
+            }
+            handleAuthExpired("Session expired — please log in again");
+            return;
+          }
+ 
+          // Non-auth failures: continue with normal reconnection
+          if (!closedManually) scheduleReconnect(reason);
+          else {
+            clearHeartbeat();
+            updateTab(tabId, { connected: false, error: reason });
+            term.write(`\r\n\x1b[90m[${reason}]\x1b[0m`);
+          }
+        };
+        ws.onerror = () => {
+          const tabState = tabData.get(tabId);
+          if (tabState) tabState.ws = null;
+          if (!closedManually) scheduleReconnect("Connection failed");
+          else updateTab(tabId, { connected: false, error: "Connection failed" });
+        };
+ 
+        d = tabData.get(tabId);
+        if (d) {
+          d.ws = ws;
+          d.heartbeat = heartbeat;
+          d.reconnectTimer = reconnectTimer;
+          d.closedManually = closedManually;
+          d.reconnecting = reconnecting;
+        }
+        return ws;
+      }
+ 
+      let ws = null;
+      void connect().then((connectedWs) => {
+        ws = connectedWs;
+        if (connectedWs) voice.attach(term, connectedWs);
+      });
+      term.onData((data) => {
+        const currentWs = tabData.get(tabId)?.ws;
+        if (currentWs?.readyState === 1) currentWs.send(new TextEncoder().encode(data));
+      });
+ 
+      const handleDragOver = (e) => {
+        e.preventDefault();
+      };
+ 
+      const handleDrop = async (e) => {
+        e.preventDefault();
+        if (activeTab !== tabId) return;
+        const currentWs = tabData.get(tabId)?.ws;
+        const files = [...(e.dataTransfer?.files || [])].filter((f) => f.type?.startsWith("image/"));
+        for (const file of files) {
+          await sendDroppedImage(term, currentWs, file);
+        }
+      };
+ 
+      const handlePaste = async (e) => {
+        if (activeTab !== tabId) return;
+        const currentWs = tabData.get(tabId)?.ws;
+        const items = [...(e.clipboardData?.items || [])]
+          .filter((it) => it.kind === "file" && it.type?.startsWith("image/"));
+        for (const item of items) {
+          const file = item.getAsFile();
+          if (file) await sendDroppedImage(term, currentWs, file);
+        }
+      };
+ 
+      term.element?.addEventListener("dragover", handleDragOver);
+      term.element?.addEventListener("drop", handleDrop);
+      term.textarea?.addEventListener("paste", handlePaste);
+ 
+      const ro = new ResizeObserver(() => {
+        fit.fit();
+        const currentWs = tabData.get(tabId)?.ws;
+        if (currentWs) sendSize(currentWs);
+      });
+      ro.observe(el);
+      const voice = new VoiceSession(() => { voiceTick++; });
+      voice.attach(term, ws);
+      tabData.set(tabId, { ws, term, ro, el, voice, heartbeat, reconnectTimer, closedManually, reconnecting, handleDragOver, handleDrop, handlePaste });
+    })();
+  }
+ 
+  function activeVoice() { return voiceTick >= 0 && tabData.get(activeTab)?.voice; }
+ 
+  function toggleFullscreen() {
+    const elem = document.documentElement;
+    if (!document.fullscreenElement) {
+      elem.requestFullscreen().then(() => {
+        isFullscreen = true;
+        showActionButtons = true;
+      }).catch(err => {
+        console.error('Failed to enter fullscreen:', err);
+      });
+    } else {
+      document.exitFullscreen().then(() => {
+        isFullscreen = false;
+        showActionButtons = false;
+      }).catch(err => {
+        console.error('Failed to exit fullscreen:', err);
+      });
+    }
+  }
+ 
+  function sendKey(key) {
+    const currentWs = tabData.get(activeTab)?.ws;
+    if (currentWs?.readyState === 1) {
+      currentWs.send(new TextEncoder().encode(key));
+    }
+  }
+ 
+  onMount(() => {
+    syncTheme();
+    themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
+    const requestedHost = new URLSearchParams(window.location.search).get("host");
+    if (requestedHost && !tabs.length && hostById(requestedHost)) addTab(requestedHost);
+ 
+    // Listen for fullscreen changes
+    document.addEventListener('fullscreenchange', () => {
+      isFullscreen = !!document.fullscreenElement;
+      showActionButtons = !!document.fullscreenElement;
+    });
+  });
+ 
+  onDestroy(() => {
+    themeObserver.disconnect();
+    tabData.forEach((d) => {
+      d.closedManually = true;
+      if (d.reconnectTimer) clearTimeout(d.reconnectTimer);
+      d.voice?.destroy();
+      d.ro?.disconnect();
+      if (d.heartbeat) clearInterval(d.heartbeat);
+      d.term?.element?.removeEventListener("dragover", d.handleDragOver);
+      d.term?.element?.removeEventListener("drop", d.handleDrop);
+      d.term?.textarea?.removeEventListener("paste", d.handlePaste);
+      d.ws?.close();
+      d.term?.dispose();
+    });
+  });
+</script>
+<div class={activeVoice()?.voiceMode ? 'flex flex-col h-[calc(100vh-4rem)] overflow-hidden gap-1' : 'space-y-2'}>
+  {#if !activeVoice()?.voiceMode}
+    <h1 class="text-2xl font-bold text-surface-900 dark:text-surface-100 tracking-tight">Terminal</h1>
+  {/if}
+ 
+  <!-- iPhone Landscape Action Buttons (only in fullscreen) -->
+  {#if showActionButtons && activeTab}
+    <!-- Top Left Pocket -->
+    <div class="actions-top-left">
+      <button class="action-btn" onclick={() => sendKey('\x1b')} title="ESC">ESC</button>
+      <button class="action-btn" onclick={() => sendKey('\t')} title="TAB">TAB</button>
+    </div>
+ 
+    <!-- Bottom Left Pocket -->
+    <div class="actions-bottom-left">
+      <button class="action-btn" onclick={() => sendKey('\x01')} title="CTRL+A">^A</button>
+      <button class="action-btn" onclick={() => sendKey('\x03')} title="CTRL+C">^C</button>
+    </div>
+ 
+    <!-- Top Right Pocket -->
+    <div class="actions-top-right">
+      <button class="action-btn" onclick={() => sendKey('\x0c')} title="CTRL+L">^L</button>
+      <button class="action-btn" onclick={() => sendKey('\x1a')} title="CTRL+Z">^Z</button>
+    </div>
+ 
+    <!-- Bottom Right Pocket -->
+    <div class="actions-bottom-right">
+      <button class="action-btn" onclick={() => sendKey('\x04')} title="CTRL+D">^D</button>
+      <button class="action-btn" onclick={() => sendKey('\x12')} title="CTRL+R">^R</button>
+    </div>
+  {/if}
+ 
+  <div class="flex items-center gap-1 border-b border-surface-300 dark:border-surface-700 pb-1 relative">
+    {#each tabs as tab (tab.id)}
+      <button
+        class="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-t-lg transition-colors {activeTab === tab.id ? 'bg-surface-200 dark:bg-surface-800 text-surface-900 dark:text-surface-100' : 'text-surface-600 dark:text-surface-400 hover:text-surface-800 dark:hover:text-surface-200 hover:bg-surface-100 dark:hover:bg-surface-800/50'}"
+        onclick={() => activeTab = tab.id}
+      >
+        {#if tab.connected}
+          <span class="w-1.5 h-1.5 rounded-full bg-emerald-500"></span>
+        {:else if tab.error}
+          <span class="w-1.5 h-1.5 rounded-full bg-rose-500"></span>
+        {:else}
+          <span class="w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse"></span>
+        {/if}
+        {tab.label}
+        {#if tab.persistent}
+          <span class="rounded bg-indigo-100 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300">tmux</span>
+        {/if}
+        <span
+          class="ml-1 text-surface-500 hover:text-sky-500 cursor-pointer"
+          role="button"
+          tabindex="0"
+          title="Open fresh session in new browser tab"
+          aria-label={`Open fresh ${tab.label} session in new browser tab`}
+          onclick={(e) => { e.stopPropagation(); openFreshSession(tab); }}
+          onkeydown={(e) => e.key === 'Enter' && openFreshSession(tab)}
+        >↗</span>
+        <span
+          class="ml-1 text-surface-500 hover:text-rose-400 cursor-pointer"
+          role="button"
+          tabindex="0"
+          onclick={(e) => { e.stopPropagation(); closeTab(tab.id); }}
+          onkeydown={(e) => e.key === 'Enter' && closeTab(tab.id)}
+        >&times;</span>
+      </button>
+    {/each}
+    <div class="relative">
+      <button
+        class="px-2 py-1 text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-100 hover:bg-surface-200 dark:hover:bg-surface-800 rounded text-lg leading-none"
+        onclick={() => showMenu = !showMenu}
+      >+</button>
+      {#if showMenu}
+        <div class="absolute top-full left-0 mt-1 bg-surface-100 dark:bg-surface-800 border border-surface-300 dark:border-surface-600 rounded-lg shadow-xl z-10 py-1 min-w-[140px]">
+          {#each hosts as h}
+            <button
+              class="flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-sm text-surface-800 dark:text-surface-200 hover:bg-surface-200 dark:hover:bg-surface-700"
+              onclick={() => addTab(h.id)}
+            >
+              <span>{h.label}</span>
+              {#if h.persistent}
+                <span class="rounded bg-indigo-100 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300">persistent</span>
+              {/if}
+            </button>
+          {/each}
+        </div>
+      {/if}
+    </div>
+    {#if activeTab && tabs.find(t => t.id === activeTab)?.connected}
+      <div class="ml-auto flex items-center gap-1">
+        <button
+          class="px-2 py-1 rounded text-sm transition-colors text-surface-600 dark:text-surface-500 hover:text-surface-800 dark:hover:text-surface-300"
+          title={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
+          onclick={toggleFullscreen}
+        >
+          {#if isFullscreen}
+            <svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 3v3a2 2 0 01-2 2H3m18 0h-3a2 2 0 01-2-2V3m0 18v-3a2 2 0 012-2h3M3 16h3a2 2 0 012 2v3"/></svg>
+          {:else}
+            <svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3"/></svg>
+          {/if}
+        </button>
+        {#if activeVoice()}
+          {@const v = activeVoice()}
+          <button
+            class="px-2 py-1 rounded text-sm transition-colors {v.voiceMode ? 'text-rose-400' : v.sttAvailable ? 'text-surface-600 dark:text-surface-500 hover:text-surface-800 dark:hover:text-surface-300' : 'text-surface-300 dark:text-surface-700 cursor-not-allowed'}"
+            title={!v.sttAvailable ? 'STT not supported' : v.voiceMode ? 'Voice mode on' : 'Enter voice mode'}
+            disabled={!v.sttAvailable}
+            onclick={() => { if (!v.voiceMode) v.enterVoiceMode(); else v.exitVoiceMode(); voiceTick++; }}
+          >
+            <svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"/><path d="M19 10v2a7 7 0 01-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
+          </button>
+        {/if}
+      </div>
+    {/if}
+  </div>
+ 
+  <p class="text-xs text-surface-600 dark:text-surface-500">
+    Tip: drag & drop or paste images into the active terminal tab to upload them to <code class="text-surface-700 dark:text-surface-400">/tmp/claude-images</code>.
+  </p>
+ 
+  {#if activeTab && tabs.find(t => t.id === activeTab)?.statusBanner}
+    <div class="rounded-lg border border-indigo-200 bg-indigo-50 px-3 py-2 text-sm text-indigo-800 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-200">
+      {tabs.find(t => t.id === activeTab)?.statusBanner}
+    </div>
+  {/if}
+ 
+  {#if !tabs.length}
+    <div class="flex items-center justify-center h-[calc(100vh-12rem)] text-surface-600 dark:text-surface-500 text-sm">
+      Click + to open a terminal
+    </div>
+  {/if}
+ 
+  {#each tabs as tab (tab.id)}
+    <div class="{activeTab === tab.id ? '' : 'hidden'} {activeVoice()?.voiceMode ? 'flex-1 min-h-0' : ''}">
+      <div
+        use:initTerminal={tab}
+        class="h-full rounded-xl overflow-hidden shadow-lg border border-surface-300 dark:border-surface-700 bg-surface-50 dark:bg-[#0f172a] transition-all duration-300"
+        style="{activeVoice()?.voiceMode ? '' : 'height: calc(100vh - 10rem);'}"
+      ></div>
+    </div>
+  {/each}
+ 
+  {#if activeVoice()?.voiceMode}
+    {@const v = activeVoice()}
+    <div class="mt-1 shrink-0 rounded-xl bg-surface-100/95 dark:bg-surface-800/95 border border-surface-300 dark:border-surface-700 shadow-xl overflow-hidden animate-slide-up">
+      <!-- Header -->
+      <div class="flex items-center justify-between px-3 py-1.5 border-b border-surface-300/50 dark:border-surface-700/50">
+        <span class="text-xs font-medium text-surface-700 dark:text-surface-300">Voice Mode</span>
+        <div class="flex items-center gap-2">
+          <select
+            class="bg-surface-200 dark:bg-surface-700 text-surface-800 dark:text-surface-300 text-xs rounded px-1 py-0.5 outline-none max-w-[120px]"
+            onchange={(e) => { v.setVoice(+e.target.value); voiceTick++; }}
+          >
+            {#each v.voices as voice, i}
+              <option value={i} selected={i === v.voiceIdx}>{voice.name}</option>
+            {/each}
+          </select>
+          <button
+            class="px-1.5 py-0.5 rounded text-xs font-medium text-surface-600 dark:text-surface-400 hover:text-surface-800 dark:hover:text-surface-200 hover:bg-surface-200 dark:hover:bg-surface-700"
+            onclick={() => { v.cycleLang(); voiceTick++; }}
+          >{v.langLabel}</button>
+          <button
+            class="px-1.5 py-0.5 rounded text-xs font-medium text-surface-600 dark:text-surface-400 hover:text-surface-800 dark:hover:text-surface-200 hover:bg-surface-200 dark:hover:bg-surface-700"
+            onclick={() => { v.cycleTalkMode(); voiceTick++; }}
+          >{v.modeLabel}</button>
+          <button
+            class="px-1.5 py-0.5 rounded text-xs text-surface-500 hover:text-rose-400"
+            onclick={() => { v.exitVoiceMode(); voiceTick++; }}
+          >&times;</button>
+        </div>
+      </div>
+      <!-- Transcript + Mic -->
+      <div class="flex items-center gap-3 px-3 py-2">
+        {#if v.talkMode === "hold"}
+          <button
+            aria-label="Hold to talk"
+            class="w-10 h-10 shrink-0 rounded-full flex items-center justify-center transition-colors {v.listening ? 'bg-rose-500/30 text-rose-400' : 'bg-surface-200 dark:bg-surface-700 text-surface-700 dark:text-surface-300 hover:bg-surface-300 dark:hover:bg-surface-600'}"
+            onpointerdown={() => { v.startHold(); voiceTick++; }}
+            onpointerup={() => { v.releaseHold(); voiceTick++; }}
+            onpointerleave={() => { if (v.listening) { v.releaseHold(); voiceTick++; } }}
+          >
+            <svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"/><path d="M19 10v2a7 7 0 01-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
+          </button>
+        {:else if v.talkMode === "tap"}
+          <button
+            aria-label="Tap to talk"
+            class="w-10 h-10 shrink-0 rounded-full flex items-center justify-center transition-colors {v.listening ? 'bg-rose-500/30 text-rose-400' : 'bg-surface-200 dark:bg-surface-700 text-surface-700 dark:text-surface-300 hover:bg-surface-300 dark:hover:bg-surface-600'}"
+            onclick={() => { v.tapMic(); voiceTick++; }}
+          >
+            <svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"/><path d="M19 10v2a7 7 0 01-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
+          </button>
+        {:else}
+          <div class="w-10 h-10 shrink-0 rounded-full flex items-center justify-center {v.listening ? 'bg-emerald-500/20 text-emerald-400 animate-pulse' : 'bg-surface-200 dark:bg-surface-700 text-surface-500'}">
+            <svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"/><path d="M19 10v2a7 7 0 01-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
+          </div>
+        {/if}
+        <div class="flex-1 min-w-0">
+          {#if v.transcript}
+            <p class="text-sm text-surface-600 dark:text-surface-400 italic truncate">"{v.transcript}"</p>
+          {:else}
+            <p class="text-xs text-surface-500 dark:text-surface-600">{v.speaking ? 'Speaking...' : v.listening ? 'Listening...' : 'Ready'}</p>
+          {/if}
+        </div>
+      </div>
+    </div>
+  {/if}
+</div>
+ 
+<style>
+  @keyframes slide-up {
+    from { transform: translateY(100%); opacity: 0; }
+    to { transform: translateY(0); opacity: 1; }
+  }
+  .animate-slide-up { animation: slide-up 0.25s ease-out; }
+ 
+  /* iPhone 17 Pro Landscape Action Button Zones */
+  .actions-top-left,
+  .actions-bottom-left,
+  .actions-top-right,
+  .actions-bottom-right {
+    position: fixed;
+    display: flex;
+    flex-direction: column;
+    gap: 5px;
+    z-index: 9999;
+  }
+ 
+  .actions-top-left {
+    top: 5px;
+    left: 5px;
+    width: 52px;
+    height: 125px;
+  }
+ 
+  .actions-bottom-left {
+    bottom: 26px;
+    left: 5px;
+    width: 52px;
+    height: 110px;
+  }
+ 
+  .actions-top-right {
+    top: 5px;
+    right: 5px;
+    width: 52px;
+    height: 125px;
+  }
+ 
+  .actions-bottom-right {
+    bottom: 26px;
+    right: 5px;
+    width: 52px;
+    height: 110px;
+  }
+ 
+  .action-btn {
+    flex: 1;
+    background: rgba(99, 102, 241, 0.9);
+    color: white;
+    border: none;
+    border-radius: 8px;
+    font-size: 11px;
+    font-weight: 600;
+    cursor: pointer;
+    transition: all 0.2s;
+    backdrop-filter: blur(10px);
+    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+ 
+  .action-btn:active {
+    background: rgba(79, 70, 229, 1);
+    transform: scale(0.95);
+  }
+ 
+  /* Terminal container respects safe areas */
+  .terminal-safe-container {
+    padding-left: env(safe-area-inset-left);
+    padding-right: env(safe-area-inset-right);
+    padding-bottom: env(safe-area-inset-bottom);
+    box-sizing: border-box;
+  }
+</style>
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/src/routes/Transmission.svelte.html b/dashboard/frontend/coverage/src/routes/Transmission.svelte.html new file mode 100644 index 0000000..be1401e --- /dev/null +++ b/dashboard/frontend/coverage/src/routes/Transmission.svelte.html @@ -0,0 +1,931 @@ + + + + + + Code coverage report for src/routes/Transmission.svelte + + + + + + + + + +
+
+

All files / src/routes Transmission.svelte

+
+ +
+ 0% + Statements + 0/145 +
+ + +
+ 0% + Branches + 0/1 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 0% + Lines + 0/145 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
<script>
+  import { onMount, onDestroy } from "svelte";
+  import { get, post } from "../lib/api.js";
+ 
+  let status = $state(null);
+  let torrents = $state([]);
+  let trackers = $state([]);
+  let stats = $state(null);
+  let loading = $state(true);
+  let selectedTab = $state("torrents");
+ 
+  async function load() {
+    try {
+      const [statusData, torrentsData, trackersData, statsData] = await Promise.all([
+        get("/transmission/status").catch(() => null),
+        get("/transmission/torrents").catch(() => ({ torrents: [] })),
+        get("/transmission/trackers").catch(() => ({ trackers: [] })),
+        get("/transmission/stats").catch(() => null),
+      ]);
+ 
+      status = statusData;
+      torrents = torrentsData?.torrents || [];
+      trackers = trackersData?.trackers || [];
+      stats = statsData;
+      loading = false;
+    } catch (e) {
+      console.error("Failed to load transmission data:", e);
+      loading = false;
+    }
+  }
+ 
+  async function reannounce(torrentId) {
+    try {
+      await post(`/transmission/reannounce/${torrentId}`);
+      await load();
+    } catch (e) {
+      alert("Failed to reannounce: " + e.message);
+    }
+  }
+ 
+  async function startTorrent(torrentId) {
+    try {
+      await post(`/transmission/start/${torrentId}`);
+      await load();
+    } catch (e) {
+      alert("Failed to start: " + e.message);
+    }
+  }
+ 
+  async function stopTorrent(torrentId) {
+    try {
+      await post(`/transmission/stop/${torrentId}`);
+      await load();
+    } catch (e) {
+      alert("Failed to stop: " + e.message);
+    }
+  }
+ 
+  function fmtBytes(b) {
+    if (!b) return "0 B";
+    const u = ["B", "KB", "MB", "GB", "TB"];
+    const i = Math.floor(Math.log(b) / Math.log(1024));
+    return (b / Math.pow(1024, i)).toFixed(1) + " " + u[i];
+  }
+ 
+  function fmtSpeed(bytesPerSec) {
+    return fmtBytes(bytesPerSec) + "/s";
+  }
+ 
+  function statusText(status) {
+    const statusMap = {
+      0: "Stopped",
+      1: "Check waiting",
+      2: "Checking",
+      3: "Download waiting",
+      4: "Downloading",
+      5: "Seed waiting",
+      6: "Seeding"
+    };
+    return statusMap[status] || "Unknown";
+  }
+ 
+  let interval;
+  onMount(() => { load(); interval = setInterval(load, 10000); });
+  onDestroy(() => clearInterval(interval));
+</script>
+ 
+<div class="space-y-6">
+  <div>
+    <h1 class="text-2xl font-bold text-surface-900 dark:text-white tracking-tight">Transmission</h1>
+    <p class="text-sm text-surface-400 mt-1">BitTorrent client status and management</p>
+  </div>
+ 
+  {#if loading}
+    <div class="h-[400px] rounded-xl bg-surface-100 dark:bg-surface-800 animate-pulse"></div>
+  {:else}
+    <!-- Status Cards -->
+    <div class="grid grid-cols-1 md:grid-cols-4 gap-4">
+      <!-- Daemon Status -->
+      <div class="rounded-xl bg-white dark:bg-surface-800 p-6 border border-surface-200 dark:border-surface-700">
+        <div class="flex items-center justify-between">
+          <div>
+            <p class="text-sm text-surface-500 dark:text-surface-400">Daemon</p>
+            <p class="text-2xl font-bold mt-1 {status?.running ? 'text-emerald-500' : 'text-rose-500'}">
+              {status?.running ? 'Running' : 'Stopped'}
+            </p>
+          </div>
+          <div class="w-12 h-12 rounded-full {status?.running ? 'bg-emerald-100 dark:bg-emerald-900/30' : 'bg-rose-100 dark:bg-rose-900/30'} flex items-center justify-center">
+            <svg class="w-6 h-6 {status?.running ? 'text-emerald-500' : 'text-rose-500'}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
+            </svg>
+          </div>
+        </div>
+      </div>
+ 
+      <!-- Active Torrents -->
+      <div class="rounded-xl bg-white dark:bg-surface-800 p-6 border border-surface-200 dark:border-surface-700">
+        <div class="flex items-center justify-between">
+          <div>
+            <p class="text-sm text-surface-500 dark:text-surface-400">Active Torrents</p>
+            <p class="text-2xl font-bold mt-1 text-surface-900 dark:text-white">
+              {torrents.filter(t => t.status === 4 || t.status === 6).length}
+            </p>
+          </div>
+          <div class="w-12 h-12 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
+            <svg class="w-6 h-6 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10"></path>
+            </svg>
+          </div>
+        </div>
+      </div>
+ 
+      <!-- Download Speed -->
+      <div class="rounded-xl bg-white dark:bg-surface-800 p-6 border border-surface-200 dark:border-surface-700">
+        <div class="flex items-center justify-between">
+          <div>
+            <p class="text-sm text-surface-500 dark:text-surface-400">Download</p>
+            <p class="text-2xl font-bold mt-1 text-surface-900 dark:text-white">
+              {fmtSpeed(torrents.reduce((sum, t) => sum + (t.rateDownload || 0), 0))}
+            </p>
+          </div>
+          <div class="w-12 h-12 rounded-full bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
+            <svg class="w-6 h-6 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3"></path>
+            </svg>
+          </div>
+        </div>
+      </div>
+ 
+      <!-- Upload Speed -->
+      <div class="rounded-xl bg-white dark:bg-surface-800 p-6 border border-surface-200 dark:border-surface-700">
+        <div class="flex items-center justify-between">
+          <div>
+            <p class="text-sm text-surface-500 dark:text-surface-400">Upload</p>
+            <p class="text-2xl font-bold mt-1 text-surface-900 dark:text-white">
+              {fmtSpeed(torrents.reduce((sum, t) => sum + (t.rateUpload || 0), 0))}
+            </p>
+          </div>
+          <div class="w-12 h-12 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
+            <svg class="w-6 h-6 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18"></path>
+            </svg>
+          </div>
+        </div>
+      </div>
+    </div>
+ 
+    <!-- Tabs -->
+    <div class="border-b border-surface-200 dark:border-surface-700">
+      <nav class="-mb-px flex space-x-8">
+        <button
+          onclick={() => selectedTab = "torrents"}
+          class="py-4 px-1 border-b-2 font-medium text-sm {selectedTab === 'torrents' ? 'border-blue-500 text-blue-600 dark:text-blue-400' : 'border-transparent text-surface-500 hover:text-surface-700 hover:border-surface-300 dark:text-surface-400 dark:hover:text-surface-300'}"
+        >
+          Torrents ({torrents.length})
+        </button>
+        <button
+          onclick={() => selectedTab = "trackers"}
+          class="py-4 px-1 border-b-2 font-medium text-sm {selectedTab === 'trackers' ? 'border-blue-500 text-blue-600 dark:text-blue-400' : 'border-transparent text-surface-500 hover:text-surface-700 hover:border-surface-300 dark:text-surface-400 dark:hover:text-surface-300'}"
+        >
+          Trackers ({trackers.length})
+        </button>
+      </nav>
+    </div>
+ 
+    <!-- Content -->
+    {#if selectedTab === "torrents"}
+      <div class="rounded-xl bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 overflow-hidden">
+        <div class="overflow-x-auto">
+          <table class="w-full">
+            <thead class="bg-surface-50 dark:bg-surface-900/50">
+              <tr>
+                <th class="px-6 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase tracking-wider">Name</th>
+                <th class="px-6 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase tracking-wider">Status</th>
+                <th class="px-6 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase tracking-wider">Progress</th>
+                <th class="px-6 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase tracking-wider">Down</th>
+                <th class="px-6 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase tracking-wider">Up</th>
+                <th class="px-6 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase tracking-wider">Ratio</th>
+                <th class="px-6 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase tracking-wider">Tracker</th>
+                <th class="px-6 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase tracking-wider">Actions</th>
+              </tr>
+            </thead>
+            <tbody class="divide-y divide-surface-200 dark:divide-surface-700">
+              {#each torrents as torrent}
+                <tr class="hover:bg-surface-50 dark:hover:bg-surface-900/30">
+                  <td class="px-6 py-4 text-sm text-surface-900 dark:text-white max-w-md truncate">{torrent.name}</td>
+                  <td class="px-6 py-4 text-sm">
+                    <span class="px-2 py-1 rounded-full text-xs font-medium {torrent.status === 4 || torrent.status === 6 ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' : 'bg-surface-100 text-surface-700 dark:bg-surface-700 dark:text-surface-300'}">
+                      {statusText(torrent.status)}
+                    </span>
+                  </td>
+                  <td class="px-6 py-4 text-sm text-surface-500 dark:text-surface-400">{(torrent.percentDone * 100).toFixed(1)}%</td>
+                  <td class="px-6 py-4 text-sm text-surface-500 dark:text-surface-400">{fmtSpeed(torrent.rateDownload)}</td>
+                  <td class="px-6 py-4 text-sm text-surface-500 dark:text-surface-400">{fmtSpeed(torrent.rateUpload)}</td>
+                  <td class="px-6 py-4 text-sm text-surface-500 dark:text-surface-400">{torrent.uploadRatio?.toFixed(2) || '0.00'}</td>
+                  <td class="px-6 py-4 text-sm">
+                    {#if torrent.hasTrackerErrors}
+                      <span class="text-rose-500 flex items-center gap-1">
+                        <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
+                          <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"></path>
+                        </svg>
+                        Error
+                      </span>
+                    {:else}
+                      <span class="text-emerald-500">OK</span>
+                    {/if}
+                  </td>
+                  <td class="px-6 py-4 text-sm">
+                    <button
+                      onclick={() => reannounce(torrent.id)}
+                      class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
+                      title="Reannounce to tracker"
+                    >
+                      Reannounce
+                    </button>
+                  </td>
+                </tr>
+              {/each}
+            </tbody>
+          </table>
+        </div>
+      </div>
+    {:else if selectedTab === "trackers"}
+      <div class="rounded-xl bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 overflow-hidden">
+        <div class="overflow-x-auto">
+          <table class="w-full">
+            <thead class="bg-surface-50 dark:bg-surface-900/50">
+              <tr>
+                <th class="px-6 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase tracking-wider">Tracker</th>
+                <th class="px-6 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase tracking-wider">Total Torrents</th>
+                <th class="px-6 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase tracking-wider">Errors</th>
+                <th class="px-6 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase tracking-wider">Status</th>
+                <th class="px-6 py-3 text-left text-xs font-medium text-surface-500 dark:text-surface-400 uppercase tracking-wider">Last Error</th>
+              </tr>
+            </thead>
+            <tbody class="divide-y divide-surface-200 dark:divide-surface-700">
+              {#each trackers as tracker}
+                <tr class="hover:bg-surface-50 dark:hover:bg-surface-900/30">
+                  <td class="px-6 py-4 text-sm font-medium text-surface-900 dark:text-white">{tracker.host}</td>
+                  <td class="px-6 py-4 text-sm text-surface-500 dark:text-surface-400">{tracker.total}</td>
+                  <td class="px-6 py-4 text-sm text-surface-500 dark:text-surface-400">{tracker.errors}</td>
+                  <td class="px-6 py-4 text-sm">
+                    {#if tracker.errors > 0}
+                      <span class="px-2 py-1 rounded-full text-xs font-medium bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400">
+                        Error
+                      </span>
+                    {:else}
+                      <span class="px-2 py-1 rounded-full text-xs font-medium bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400">
+                        OK
+                      </span>
+                    {/if}
+                  </td>
+                  <td class="px-6 py-4 text-sm text-surface-500 dark:text-surface-400">{tracker.lastError || '-'}</td>
+                </tr>
+              {/each}
+            </tbody>
+          </table>
+        </div>
+      </div>
+    {/if}
+  {/if}
+</div>
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/dashboard/frontend/coverage/src/routes/index.html b/dashboard/frontend/coverage/src/routes/index.html new file mode 100644 index 0000000..eb1ef62 --- /dev/null +++ b/dashboard/frontend/coverage/src/routes/index.html @@ -0,0 +1,341 @@ + + + + + + Code coverage report for src/routes + + + + + + + + + +
+
+

All files src/routes

+
+ +
+ 2.01% + Statements + 48/2380 +
+ + +
+ 12% + Branches + 3/25 +
+ + +
+ 9.52% + Functions + 2/21 +
+ + +
+ 2.01% + Lines + 48/2380 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
CcConnect.svelte +
+
0%0/870%0/10%0/10%0/87
ChatSummary.svelte +
+
0%0/910%0/10%0/10%0/91
Conversations.svelte +
+
0%0/2010%0/10%0/10%0/201
Dashboard.svelte +
+
0%0/870%0/10%0/10%0/87
Docker.svelte +
+
0%0/940%0/10%0/10%0/94
Files.svelte +
+
0%0/1050%0/10%0/10%0/105
Gitea.svelte +
+
0%0/740%0/10%0/10%0/74
InfoEngine.svelte +
+
0%0/920%0/10%0/10%0/92
LiteLLM.svelte +
+
0%0/580%0/10%0/10%0/58
Login.svelte +
+
41.02%48/11730%3/1033.33%2/641.02%48/117
OPC.svelte +
+
0%0/1290%0/10%0/10%0/129
OpenClaw.svelte +
+
0%0/220%0/10%0/10%0/22
Security.svelte +
+
0%0/1710%0/10%0/10%0/171
Settings.svelte +
+
0%0/3620%0/10%0/10%0/362
Terminal.svelte +
+
0%0/5450%0/10%0/10%0/545
Transmission.svelte +
+
0%0/1450%0/10%0/10%0/145
+
+
+
+ + + + + + + + \ No newline at end of file