From 2d7fcf57f139386086594e03de1a8337d8262353 Mon Sep 17 00:00:00 2001 From: ZaynJarvis Date: Thu, 21 May 2026 15:17:50 +0800 Subject: [PATCH] feat(docker)!: drop legacy console (keep BFF + Caddy), ship web-studio in pip, fix favicons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OpenViking docker image still launched the legacy `openviking/console` standalone service on port 8020. Now that web-studio is bundled into the OV server itself at /studio (see #2156), that process is redundant and the port is just a confusing artefact. This change retires the old console (python package + 8020 + console-frontend favicons) but **keeps the in-compose Caddy as a stable single-ingress on port 1934**, just simplified to one upstream now that there's no 8020. The server-side BFF at `openviking/server/routers/console.py` (under `/api/v1/console/*`) is also kept — web-studio uses the same endpoints. **The OAuth authorize page (`openviking/server/oauth/router.py`) is deliberately untouched in this PR** — the console-link button and Quick authorize same-origin panel will be re-pointed at web-studio in a focused follow-up. BREAKING CHANGES: - Port 8020 is gone from the docker image and docker-compose.yml; Caddy at 1934 now forwards everything to 1933 (web-studio lives at /studio there). Anything bookmarked at `http://host:8020/...` must migrate to `http://host:1933/studio/`. - `python -m openviking.console.bootstrap` no longer exists; the python package `openviking.console` has been removed. Pip packaging: - web-studio dist is now shipped inside the wheel under `openviking/web_studio/dist/` (mirroring the old `openviking/console/static/` layout). The dockerfile copies `--from=web-studio-builder /web-studio/dist` into the source tree before `uv sync`, so the wheel produced by the default docker build always carries the SPA. Building the wheel without running `npm run build` first leaves the directory empty, which gracefully degrades /studio to a 404 without breaking server startup. - Favicon assets (`favicon.ico` / `favicon-32.png` / `apple-touch-icon.png`, ~11 KB total) are duplicated into `openviking/server/static/` and shipped via package-data so `/favicon.*` and `/mcp/favicon.*` routes are always registered, regardless of whether the web-studio dist is bundled. - `pyproject.toml` and `setup.py` `package-data` drop `console/static/**` and add `server/static/**` + `web_studio/dist/**`. - New favicons (the 16/32/180 set in both `openviking/server/static/` and `web-studio/public/`) are downscaled from the canonical `web-studio/public/openviking-icon.png`, so the small-icon family matches the SPA's high-res rel="icon" target — the studio tab icon now stays consistent whether the browser uses the HTML link tag or falls back to auto-fetching `/favicon.ico`. Server: - `openviking/server/app.py` now reads `/studio` from `Path(__file__).parent.parent / 'web_studio' / 'dist'` by default; `OPENVIKING_WEB_STUDIO_DIR` still wins for dev mode pointing at a repo-local build. Favicon routes are unconditionally registered and load from `openviking/server/static/`. - `openviking/observability/usage_audit/projection.py` drops the legacy `/console/*` skip prefix (the BFF prefix `/api/v1/console/*` remains). Docker: - `web-studio-builder` stage moved earlier (Stage 2) so its dist can flow into `py-builder` before `uv sync` runs. - Runtime stage no longer separately copies the dist or sets `OPENVIKING_WEB_STUDIO_DIR`; the in-package path is the default. - Entrypoint renamed `openviking-console-entrypoint.sh` -> `openviking-entrypoint.sh` and stripped of the `python -m openviking.console.bootstrap` launch. - `EXPOSE 1933 8020` -> `EXPOSE 1933`. - `docker-compose.yml` drops the openviking service's 8020 port mapping; the caddy service stays but no longer needs port 8020 exposed. - `Caddyfile` simplified to a single `:1934 { reverse_proxy openviking:1933 }` — the legacy `/console/*` route to :8020 is gone. Docs: - en/zh quickstart updated to drop the 8020 mapping and explain that the API server now also serves `/studio`. - Other guides (`12-public-access.md`, `11-oauth.md`, `05-observability.md`, `04-setup-for-agent.md`, `03-deployment.md`) are intentionally left for a focused follow-up PR alongside the OAuth quick-authorize reintroduction. Tests: - Deleted `tests/misc/test_console_{proxy,static_assets}.py` (covered the removed console package). `tests/observability/test_console_router.py` stays — it covers the BFF, which remains. --- Caddyfile | 25 +- Dockerfile | 58 +- docker-compose.yml | 10 +- ...entrypoint.sh => openviking-entrypoint.sh} | 67 +- docs/en/getting-started/02-quickstart.md | 4 +- docs/zh/getting-started/02-quickstart.md | 4 +- openviking/console/README.md | 59 - openviking/console/__init__.py | 10 - openviking/console/app.py | 514 ---- openviking/console/bootstrap.py | 75 - openviking/console/config.py | 84 - openviking/console/static/app.js | 2663 ----------------- .../console/static/apple-touch-icon.png | Bin 26166 -> 0 bytes openviking/console/static/console_settings.js | 107 - openviking/console/static/favicon-32.png | Bin 1823 -> 0 bytes openviking/console/static/index.html | 398 --- openviking/console/static/styles.css | 1489 --------- .../observability/usage_audit/README.md | 5 +- .../observability/usage_audit/projection.py | 6 +- openviking/server/app.py | 48 +- openviking/server/static/apple-touch-icon.png | Bin 0 -> 25357 bytes openviking/server/static/favicon-32.png | Bin 0 -> 1801 bytes .../{console => server}/static/favicon.ico | Bin openviking/web_studio/__init__.py | 11 + pyproject.toml | 3 +- setup.py | 16 +- tests/misc/test_console_proxy.py | 57 - tests/misc/test_console_static_assets.py | 15 - web-studio/public/apple-touch-icon.png | Bin 26166 -> 25357 bytes web-studio/public/favicon-32.png | Bin 1823 -> 1801 bytes 30 files changed, 101 insertions(+), 5627 deletions(-) rename docker/{openviking-console-entrypoint.sh => openviking-entrypoint.sh} (56%) delete mode 100644 openviking/console/README.md delete mode 100644 openviking/console/__init__.py delete mode 100644 openviking/console/app.py delete mode 100644 openviking/console/bootstrap.py delete mode 100644 openviking/console/config.py delete mode 100644 openviking/console/static/app.js delete mode 100644 openviking/console/static/apple-touch-icon.png delete mode 100644 openviking/console/static/console_settings.js delete mode 100644 openviking/console/static/favicon-32.png delete mode 100644 openviking/console/static/index.html delete mode 100644 openviking/console/static/styles.css create mode 100644 openviking/server/static/apple-touch-icon.png create mode 100644 openviking/server/static/favicon-32.png rename openviking/{console => server}/static/favicon.ico (100%) create mode 100644 openviking/web_studio/__init__.py delete mode 100644 tests/misc/test_console_proxy.py delete mode 100644 tests/misc/test_console_static_assets.py diff --git a/Caddyfile b/Caddyfile index 4d431e72e..50cbc24e2 100644 --- a/Caddyfile +++ b/Caddyfile @@ -1,20 +1,17 @@ # OpenViking aggregated reverse proxy # -# Port 1934 merges the API server (1933) and Console (8020) under one origin. -# This block always serves plain HTTP — suitable for local dev, internal -# networks, and as an upstream behind your own TLS-terminating proxy. +# Port 1934 fronts the OV server (1933) under one stable origin. This block +# always serves plain HTTP — suitable for local dev, internal networks, and +# as an upstream behind your own TLS-terminating proxy. +# +# Web Studio (the in-server SPA) is served by OV itself at `/studio`, so a +# single upstream is all that's needed here. # # To add public HTTPS, append a domain block below (Caddy auto-provisions # Let's Encrypt certs): # # {$OPENVIKING_PUBLIC_BASE_URL} { -# @console path /console /console/* -# handle @console { -# reverse_proxy openviking:8020 -# } -# handle { -# reverse_proxy openviking:1933 -# } +# reverse_proxy openviking:1933 # # Optional: pin ACME email # # tls {$OV_ACME_EMAIL} # } @@ -23,11 +20,5 @@ # OPENVIKING_PUBLIC_BASE_URL=https://your-domain.com in .env. :1934 { - @console path /console /console/* - handle @console { - reverse_proxy openviking:8020 - } - handle { - reverse_proxy openviking:1933 - } + reverse_proxy openviking:1933 } diff --git a/Dockerfile b/Dockerfile index d8cfeed27..de6eb55c7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,26 @@ # ragfs-python's default S3-enabled dependency set currently requires rustc >= 1.91.1. FROM rust:1.91.1-trixie AS rust-toolchain -# Stage 2: build Python environment with uv (builds Rust CLI + C++ extension from source) +# Stage 2: build web-studio static bundle (Vite SPA). Runs before py-builder so +# the dist can be copied into openviking/web_studio/dist/ and included in the +# python wheel via package-data — no separate runtime COPY required. +# +# WEB_STUDIO_BASE_PATH is passed to `vite build --base=...` and is also +# consumed at runtime by getRouterBasePath() through import.meta.env.BASE_URL, +# so the SPA's asset URLs and TanStack Router basepath stay in sync. +FROM node:20-bookworm-slim AS web-studio-builder +WORKDIR /web-studio +ARG TARGETPLATFORM +ARG WEB_STUDIO_BASE_PATH=/studio/ + +COPY web-studio/package.json web-studio/package-lock.json ./ +RUN --mount=type=cache,target=/root/.npm,id=npm-${TARGETPLATFORM} \ + npm ci + +COPY web-studio/ ./ +RUN npm run build -- --base="${WEB_STUDIO_BASE_PATH}" + +# Stage 3: build Python environment with uv (builds Rust CLI + C++ extension from source) FROM ghcr.io/astral-sh/uv:python3.13-trixie-slim AS py-builder # Reuse Rust toolchain from stage 1 so setup.py can compile ov CLI in-place. @@ -49,6 +68,11 @@ COPY openviking_cli/ openviking_cli/ COPY src/ src/ COPY third_party/ third_party/ +# Bundle the prebuilt web-studio dist into the openviking python package so the +# wheel produced by `uv sync` ships /studio assets out of the box (parallel to +# the legacy openviking/console/static layout, just for the new SPA). +COPY --from=web-studio-builder /web-studio/dist /app/openviking/web_studio/dist + # Install project and dependencies (triggers setup.py artifact builds + build_extension). # Default to auto-refreshing uv.lock inside the ephemeral build context when it is # stale, so Docker builds stay unblocked after dependency changes. Set @@ -82,26 +106,6 @@ RUN --mount=type=cache,target=/root/.cache/uv,id=uv-${TARGETPLATFORM} \ ;; \ esac -# Stage 3: build web-studio static bundle (Vite SPA). -# Produces /web-studio/dist which the runtime stage mounts at /studio in the -# OpenViking server. Lockfile-first dependency install keeps the layer cached -# until package.json / lockfile actually change. -# -# WEB_STUDIO_BASE_PATH is passed to `vite build --base=...` and is also -# consumed at runtime by getRouterBasePath() through import.meta.env.BASE_URL, -# so the SPA's asset URLs and TanStack Router basepath stay in sync. -FROM node:20-bookworm-slim AS web-studio-builder -WORKDIR /web-studio -ARG TARGETPLATFORM -ARG WEB_STUDIO_BASE_PATH=/studio/ - -COPY web-studio/package.json web-studio/package-lock.json ./ -RUN --mount=type=cache,target=/root/.npm,id=npm-${TARGETPLATFORM} \ - npm ci - -COPY web-studio/ ./ -RUN npm run build -- --base="${WEB_STUDIO_BASE_PATH}" - # Stage 4: runtime FROM python:3.13-slim-trixie @@ -114,18 +118,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ WORKDIR /app COPY --from=py-builder /app/.venv /app/.venv -COPY --from=web-studio-builder /web-studio/dist /app/web-studio/dist -COPY docker/openviking-console-entrypoint.sh /usr/local/bin/openviking-console-entrypoint +COPY docker/openviking-entrypoint.sh /usr/local/bin/openviking-entrypoint COPY docker/pending_health_server.py /usr/local/bin/openviking-pending-health RUN mkdir -p /app/.openviking \ - && chmod +x /usr/local/bin/openviking-console-entrypoint /usr/local/bin/openviking-pending-health + && chmod +x /usr/local/bin/openviking-entrypoint /usr/local/bin/openviking-pending-health ENV HOME="/app" \ PATH="/app/.venv/bin:$PATH" \ OPENVIKING_CONFIG_FILE="/app/.openviking/ov.conf" \ - OPENVIKING_CLI_CONFIG_FILE="/app/.openviking/ovcli.conf" \ - OPENVIKING_WEB_STUDIO_DIR="/app/web-studio/dist" + OPENVIKING_CLI_CONFIG_FILE="/app/.openviking/ovcli.conf" -EXPOSE 1933 8020 +EXPOSE 1933 HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ CMD curl -fsS http://127.0.0.1:1933/health || exit 1 @@ -138,4 +140,4 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ # JSON, or `docker exec` in and run `openviking-server init`. # Override command to run CLI, e.g.: # docker run --rm -v ~/.openviking:/app/.openviking openviking --help -ENTRYPOINT ["openviking-console-entrypoint"] +ENTRYPOINT ["openviking-entrypoint"] diff --git a/docker-compose.yml b/docker-compose.yml index 33b6822ff..c9a32e3eb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,12 +16,10 @@ services: openviking: image: ghcr.io/volcengine/openviking:latest container_name: openviking - # Direct access to individual services (optional — caddy:1934 is the - # recommended single entry point). Comment these out once Caddy is your - # only ingress. + # Direct access (optional — caddy:1934 is the recommended single entry + # point). Comment this out once Caddy is your only ingress. ports: - "1933:1933" - - "8020:8020" volumes: - ~/.openviking:/app/.openviking environment: @@ -34,8 +32,8 @@ services: start_period: 30s restart: unless-stopped - # Aggregated reverse proxy — merges API (1933) + Console (8020) on port - # 1934 under one origin. Always enabled, plain HTTP by default. + # Aggregated reverse proxy — fronts OV (1933) on port 1934 under one stable + # origin. Always enabled, plain HTTP by default. # # For public HTTPS: edit the Caddyfile to add a domain block, set # OPENVIKING_PUBLIC_BASE_URL in .env, uncomment the 80/443 port lines diff --git a/docker/openviking-console-entrypoint.sh b/docker/openviking-entrypoint.sh similarity index 56% rename from docker/openviking-console-entrypoint.sh rename to docker/openviking-entrypoint.sh index b844601a9..eb48a9fcc 100644 --- a/docker/openviking-console-entrypoint.sh +++ b/docker/openviking-entrypoint.sh @@ -3,16 +3,11 @@ set -eu SERVER_URL="http://127.0.0.1:1933" SERVER_HEALTH_URL="${SERVER_URL}/health" -SERVER_READY_URL="${SERVER_URL}/ready" -CONSOLE_PORT="${OPENVIKING_CONSOLE_PORT:-8020}" -CONSOLE_HOST="${OPENVIKING_CONSOLE_HOST:-0.0.0.0}" WITH_BOT="${OPENVIKING_WITH_BOT:-1}" HEALTH_MAX_ATTEMPTS="${OPENVIKING_HEALTH_MAX_ATTEMPTS:-120}" -READY_MAX_ATTEMPTS="${OPENVIKING_READY_MAX_ATTEMPTS:-300}" CONFIG_FILE="${OPENVIKING_CONFIG_FILE:-/app/.openviking/ov.conf}" PENDING_HEALTH_SCRIPT="/usr/local/bin/openviking-pending-health" SERVER_PID="" -CONSOLE_PID="" PENDING_PID="" stop_pending_health() { @@ -30,11 +25,11 @@ ensure_config() { mkdir -p "$(dirname "${CONFIG_FILE}")" if [ -n "${OPENVIKING_CONF_CONTENT:-}" ]; then printf '%s' "${OPENVIKING_CONF_CONTENT}" > "${CONFIG_FILE}" - echo "[openviking-console-entrypoint] wrote ${CONFIG_FILE} from OPENVIKING_CONF_CONTENT" + echo "[openviking-entrypoint] wrote ${CONFIG_FILE} from OPENVIKING_CONF_CONTENT" return fi cat >&2 </dev/null; then - echo "[openviking-console-entrypoint] pending health server exited unexpectedly" >&2 + echo "[openviking-entrypoint] pending health server exited unexpectedly" >&2 PENDING_PID="" exit 1 fi @@ -61,7 +56,7 @@ EOF stop_pending_health trap - INT TERM - echo "[openviking-console-entrypoint] detected ${CONFIG_FILE}, starting OpenViking" + echo "[openviking-entrypoint] detected ${CONFIG_FILE}, starting OpenViking" } normalize_with_bot() { @@ -73,7 +68,7 @@ normalize_with_bot() { WITH_BOT="0" ;; *) - echo "[openviking-console-entrypoint] invalid OPENVIKING_WITH_BOT=${1}" >&2 + echo "[openviking-entrypoint] invalid OPENVIKING_WITH_BOT=${1}" >&2 exit 2 ;; esac @@ -102,9 +97,6 @@ forward_signal() { if [ -n "${SERVER_PID}" ] && kill -0 "${SERVER_PID}" 2>/dev/null; then kill "${SERVER_PID}" 2>/dev/null || true fi - if [ -n "${CONSOLE_PID}" ] && kill -0 "${CONSOLE_PID}" 2>/dev/null; then - kill "${CONSOLE_PID}" 2>/dev/null || true - fi } trap 'forward_signal' INT TERM @@ -122,58 +114,19 @@ attempt=0 until curl -fsS "${SERVER_HEALTH_URL}" >/dev/null 2>&1; do attempt=$((attempt + 1)) if ! kill -0 "${SERVER_PID}" 2>/dev/null; then - echo "[openviking-console-entrypoint] openviking-server exited before becoming healthy" >&2 + echo "[openviking-entrypoint] openviking-server exited before becoming healthy" >&2 wait "${SERVER_PID}" || true exit 1 fi if [ "${attempt}" -ge "${HEALTH_MAX_ATTEMPTS}" ]; then - echo "[openviking-console-entrypoint] timed out waiting for ${SERVER_HEALTH_URL}" >&2 + echo "[openviking-entrypoint] timed out waiting for ${SERVER_HEALTH_URL}" >&2 forward_signal wait "${SERVER_PID}" || true exit 1 fi sleep 1 done +echo "[openviking-entrypoint] openviking-server is healthy" -# Wait for server to become fully ready before starting console -ready_attempt=0 -until curl -fsS "${SERVER_READY_URL}" 2>/dev/null | grep -q '"status":"ready"'; do - ready_attempt=$((ready_attempt + 1)) - if ! kill -0 "${SERVER_PID}" 2>/dev/null; then - echo "[openviking-console-entrypoint] openviking-server exited before becoming ready" >&2 - wait "${SERVER_PID}" || true - exit 1 - fi - if [ "${ready_attempt}" -ge "${READY_MAX_ATTEMPTS}" ]; then - echo "[openviking-console-entrypoint] timed out waiting for ${SERVER_READY_URL}" >&2 - forward_signal - wait "${SERVER_PID}" || true - exit 1 - fi - sleep 2 -done -echo "[openviking-console-entrypoint] openviking-server is ready" - -python -m openviking.console.bootstrap \ - --host "${CONSOLE_HOST}" \ - --port "${CONSOLE_PORT}" \ - --openviking-url "${SERVER_URL}" & -CONSOLE_PID=$! - -while kill -0 "${SERVER_PID}" 2>/dev/null && kill -0 "${CONSOLE_PID}" 2>/dev/null; do - sleep 1 -done - -if ! kill -0 "${SERVER_PID}" 2>/dev/null; then - wait "${SERVER_PID}" || SERVER_STATUS=$? - SERVER_STATUS=${SERVER_STATUS:-1} - forward_signal - wait "${CONSOLE_PID}" || true - exit "${SERVER_STATUS}" -fi - -wait "${CONSOLE_PID}" || CONSOLE_STATUS=$? -CONSOLE_STATUS=${CONSOLE_STATUS:-0} -forward_signal -wait "${SERVER_PID}" || true -exit "${CONSOLE_STATUS}" +wait "${SERVER_PID}" || SERVER_STATUS=$? +exit "${SERVER_STATUS:-0}" diff --git a/docs/en/getting-started/02-quickstart.md b/docs/en/getting-started/02-quickstart.md index 9f42ecf62..02d9d0653 100644 --- a/docs/en/getting-started/02-quickstart.md +++ b/docs/en/getting-started/02-quickstart.md @@ -40,7 +40,6 @@ If you prefer to run OpenViking as a standalone service, Docker is recommended. container_name: openviking ports: - "1933:1933" - - "8020:8020" volumes: - ~/.openviking:/app/.openviking restart: unless-stopped @@ -50,7 +49,7 @@ If you prefer to run OpenViking as a standalone service, Docker is recommended. docker-compose up -d ``` - By default, the container starts the OpenViking API server on `1933`, the Console UI on `8020`, and the bundled `vikingbot` gateway. If you need to disable `vikingbot`, add either `command: ["--without-bot"]` or `environment: ["OPENVIKING_WITH_BOT=0"]`. + By default, the container starts the OpenViking API server on `1933` (which also serves the Web Studio UI at `/studio`) and the bundled `vikingbot` gateway. If you need to disable `vikingbot`, add either `command: ["--without-bot"]` or `environment: ["OPENVIKING_WITH_BOT=0"]`. On platforms that don't allow bind mounts, set `OPENVIKING_CONF_CONTENT` to the full config JSON to bootstrap on first start, or `docker exec` in and run `openviking-server init` after the container is up. See [Deployment Guide](../guides/03-deployment.md#when-docker--v-is-not-available) for details. @@ -65,7 +64,6 @@ If you prefer to run OpenViking as a standalone service, Docker is recommended. > openviking: > image: ghcr.io/volcengine/openviking:latest > ports: -> - "8020:8020" > - "1933:1934" # Map host 1933 to container 1934 > volumes: > - ~/.openviking:/app/.openviking diff --git a/docs/zh/getting-started/02-quickstart.md b/docs/zh/getting-started/02-quickstart.md index ebb2ce8c7..5b57914d0 100644 --- a/docs/zh/getting-started/02-quickstart.md +++ b/docs/zh/getting-started/02-quickstart.md @@ -40,7 +40,6 @@ pip install openviking --upgrade --force-reinstall container_name: openviking ports: - "1933:1933" - - "8020:8020" volumes: - ~/.openviking:/app/.openviking restart: unless-stopped @@ -50,7 +49,7 @@ pip install openviking --upgrade --force-reinstall docker-compose up -d ``` - 默认情况下,容器会同时启动 OpenViking API 服务(`1933`)、Console 界面(`8020`)以及内置的 `vikingbot` gateway。如果你需要关闭 `vikingbot`,可以在 Compose 里增加 `command: ["--without-bot"]`,或者设置 `environment: ["OPENVIKING_WITH_BOT=0"]`。 + 默认情况下,容器会启动 OpenViking API 服务(`1933`,同时在 `/studio` 提供 Web Studio 前端)以及内置的 `vikingbot` gateway。如果你需要关闭 `vikingbot`,可以在 Compose 里增加 `command: ["--without-bot"]`,或者设置 `environment: ["OPENVIKING_WITH_BOT=0"]`。 如果运行平台不支持 bind mount,可以通过 `OPENVIKING_CONF_CONTENT` 环境变量传入完整的配置 JSON,或在容器启动后 `docker exec` 进去执行 `openviking-server init`。详见 [部署指南](../guides/03-deployment.md#无法使用-docker--v-时)。 @@ -65,7 +64,6 @@ pip install openviking --upgrade --force-reinstall > openviking: > image: ghcr.io/volcengine/openviking:latest > ports: -> - "8020:8020" > - "1933:1934" # 将宿主机 1933 映射到容器 1934 > volumes: > - ~/.openviking:/app/.openviking diff --git a/openviking/console/README.md b/openviking/console/README.md deleted file mode 100644 index 0dd8be6de..000000000 --- a/openviking/console/README.md +++ /dev/null @@ -1,59 +0,0 @@ -# OpenViking Console - -This is a standalone console service. -It is not wired into release packaging or CLI commands. - -## What it provides - -- File system browsing (`ls/read/stat`) -- Find query -- Add resource (`/api/v1/resources`) -- Tenant/account management UI -- System/observer status panels - -## Quick start - -1. Start OpenViking server (default: `http://127.0.0.1:1933`) -2. Start the console service: - -```bash -python -m openviking.console.bootstrap \ - --host 127.0.0.1 \ - --port 8020 \ - --openviking-url http://127.0.0.1:1933 -``` - -3. Open: - -```text -http://127.0.0.1:8020/ -``` - -4. In **Settings**, configure headers for your upstream auth mode. -`api_key` is the default server mode, so in that mode you normally paste `X-API-Key` and click **Save** (or press Enter). If the upstream server runs in `trusted` mode, you can omit `X-API-Key` for ordinary requests only when that server is localhost-only and has no `root_api_key`; otherwise you still need `X-API-Key`, and you should also set `X-OpenViking-Account` and `X-OpenViking-User` (and optionally `X-OpenViking-Agent`). -`X-API-Key` is stored locally in the browser and restored into the current tab. - -When the upstream server runs in `trusted` mode, ordinary access does not require user registration first. If you try account or user management actions against Admin API endpoints in `trusted` mode, the server now returns an explicit error explaining that `trusted` mode resolves requests as `USER` and that account/user management requires `api_key` mode with `root_api_key`. - -## Startup parameters - -- `--openviking-url` (default `http://127.0.0.1:1933`) -- `--host` (default `127.0.0.1`) -- `--port` (default `8020`) -- `--write-enabled` (default `false`) -- `--request-timeout-sec` (default `30`) -- `--cors-origins` (default `*`, comma-separated) - -Without `--write-enabled`, write operations are blocked by backend guardrails. -If you need **Add Resource** or **multi-tenant management** (create/delete account, add/delete user, role/key changes), -start with `--write-enabled`. - -Example: - -```bash -python -m openviking.console.bootstrap \ - --host 127.0.0.1 \ - --port 8020 \ - --openviking-url http://127.0.0.1:1933 \ - --write-enabled -``` diff --git a/openviking/console/__init__.py b/openviking/console/__init__.py deleted file mode 100644 index 3d71e1049..000000000 --- a/openviking/console/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. -# SPDX-License-Identifier: AGPL-3.0 - -"""OpenViking Console (standalone web dashboard). - -This package contains the FastAPI app and static frontend assets. -""" - -from .app import create_console_app # noqa: F401 -from .config import ConsoleConfig, load_console_config # noqa: F401 diff --git a/openviking/console/app.py b/openviking/console/app.py deleted file mode 100644 index 8883a063f..000000000 --- a/openviking/console/app.py +++ /dev/null @@ -1,514 +0,0 @@ -# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. -# SPDX-License-Identifier: AGPL-3.0 -"""FastAPI app for the standalone OpenViking console service.""" - -from __future__ import annotations - -import json -import re -from contextlib import asynccontextmanager -from pathlib import Path -from typing import Optional - -import httpx -from fastapi import APIRouter, FastAPI, Request -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse, JSONResponse, Response - -from .config import ( - ConsoleConfig, - as_runtime_capabilities, - load_console_config, -) - -PROXY_PREFIX = "/console/api/v1" -_CONSOLE_NO_STORE_HEADERS = {"Cache-Control": "no-store"} -_SAFE_PATH_SEGMENT = re.compile(r"^[\w.@+-]+$") - -_ALLOWED_FORWARD_HEADERS = { - "accept", - "x-api-key", - "authorization", - "x-openviking-account", - "x-openviking-user", - "x-openviking-agent", - "content-type", -} - -_ALLOWED_FORWARD_RESPONSE_HEADERS = { - # Content negotiation / caching / downloads - "content-type", - "content-disposition", - "cache-control", - "etag", - "last-modified", - # Observability - "x-request-id", -} - - -def _is_json_content_type(content_type: str) -> bool: - value = (content_type or "").lower() - return "application/json" in value or "+json" in value - - -def _should_default_telemetry(upstream_path: str) -> bool: - if upstream_path in {"/api/v1/search/find", "/api/v1/resources"}: - return True - return upstream_path.startswith("/api/v1/sessions/") and upstream_path.endswith("/commit") - - -def _with_default_telemetry(request: Request, upstream_path: str, body: bytes) -> bytes: - if request.method.upper() != "POST": - return body - if not _should_default_telemetry(upstream_path): - return body - if not _is_json_content_type(request.headers.get("content-type", "")): - return body - - try: - payload = json.loads(body.decode("utf-8")) if body else {} - except (json.JSONDecodeError, UnicodeDecodeError): - return body - if not isinstance(payload, dict): - return body - - payload.setdefault("telemetry", True) - return json.dumps(payload).encode("utf-8") - - -def _error_response(status_code: int, code: str, message: str, details: Optional[dict] = None): - return JSONResponse( - status_code=status_code, - content={ - "status": "error", - "error": { - "code": code, - "message": message, - "details": details or {}, - }, - }, - ) - - -def _copy_forward_headers(request: Request) -> dict[str, str]: - headers: dict[str, str] = {} - for key, value in request.headers.items(): - if key.lower() in _ALLOWED_FORWARD_HEADERS: - headers[key] = value - return headers - - -def _copy_forward_response_headers(upstream_response: httpx.Response) -> dict[str, str]: - headers: dict[str, str] = {} - for key, value in upstream_response.headers.items(): - if key.lower() in _ALLOWED_FORWARD_RESPONSE_HEADERS: - headers[key] = value - return headers - - -async def _forward_request(request: Request, upstream_path: str) -> Response: - """Forward the incoming request to OpenViking upstream.""" - client: httpx.AsyncClient = request.app.state.upstream_client - body = await request.body() - body = _with_default_telemetry(request, upstream_path, body) - try: - upstream_response = await client.request( - method=request.method, - url=upstream_path, - params=request.query_params, - content=body, - headers=_copy_forward_headers(request), - ) - except httpx.RequestError as exc: - return _error_response( - status_code=502, - code="UPSTREAM_UNAVAILABLE", - message=f"Failed to reach OpenViking upstream: {exc}", - ) - - content_type = upstream_response.headers.get("content-type", "application/json") - return Response( - content=upstream_response.content, - status_code=upstream_response.status_code, - media_type=content_type, - headers=_copy_forward_response_headers(upstream_response), - ) - - -def _ensure_write_enabled(request: Request) -> Optional[JSONResponse]: - config: ConsoleConfig = request.app.state.console_config - if config.write_enabled: - return None - return _error_response( - status_code=403, - code="WRITE_DISABLED", - message=( - "Console write mode is disabled. Start service with --write-enabled " - "and restart the service to allow write operations." - ), - ) - - -def _validate_path_param(value: str, name: str) -> Optional[JSONResponse]: - if not value or value in {".", ".."} or not _SAFE_PATH_SEGMENT.match(value): - return _error_response( - status_code=400, - code="INVALID_PARAMETER", - message=f"Invalid {name}", - ) - return None - - -def _validate_fs_path(path_str: str) -> Optional[JSONResponse]: - """Validate file system path to prevent directory traversal attacks.""" - if not path_str: - # Empty path is allowed (means current directory) - return None - - # Reject absolute paths - if path_str.startswith("/") or path_str.startswith("\\"): - return _error_response( - status_code=400, - code="INVALID_PATH", - message="Absolute paths are not allowed", - ) - - # Check for Windows drive letters (C:, D:, etc.) - if len(path_str) >= 2 and path_str[1] == ":": - return _error_response( - status_code=400, - code="INVALID_PATH", - message="Absolute paths are not allowed", - ) - - # Check for parent directory traversal - if ".." in path_str: - return _error_response( - status_code=400, - code="INVALID_PATH", - message="Path traversal sequences (..) are not allowed", - ) - - return None - - -def _create_proxy_router() -> APIRouter: - router = APIRouter(prefix=PROXY_PREFIX, tags=["console"]) - - @router.get("/runtime/capabilities") - async def runtime_capabilities(request: Request): - config: ConsoleConfig = request.app.state.console_config - return {"status": "ok", "result": as_runtime_capabilities(config)} - - # ---- Read routes ---- - - @router.get("/ov/fs/ls") - async def fs_ls(request: Request): - path = request.query_params.get("path", "") - invalid = _validate_fs_path(path) - if invalid: - return invalid - return await _forward_request(request, "/api/v1/fs/ls") - - @router.get("/ov/fs/tree") - async def fs_tree(request: Request): - path = request.query_params.get("path", "") - invalid = _validate_fs_path(path) - if invalid: - return invalid - return await _forward_request(request, "/api/v1/fs/tree") - - @router.get("/ov/fs/stat") - async def fs_stat(request: Request): - return await _forward_request(request, "/api/v1/fs/stat") - - @router.post("/ov/search/find") - async def search_find(request: Request): - return await _forward_request(request, "/api/v1/search/find") - - @router.get("/ov/content/read") - async def content_read(request: Request): - return await _forward_request(request, "/api/v1/content/read") - - @router.get("/ov/admin/accounts") - async def admin_accounts(request: Request): - return await _forward_request(request, "/api/v1/admin/accounts") - - @router.get("/ov/admin/accounts/{account_id}/users") - async def admin_users(request: Request, account_id: str): - invalid = _validate_path_param(account_id, "account_id") - if invalid: - return invalid - return await _forward_request(request, f"/api/v1/admin/accounts/{account_id}/users") - - @router.get("/ov/admin/accounts/{account_id}/agents") - async def admin_agents(request: Request, account_id: str): - invalid = _validate_path_param(account_id, "account_id") - if invalid: - return invalid - return await _forward_request(request, f"/api/v1/admin/accounts/{account_id}/agents") - - @router.get("/ov/system/status") - async def system_status(request: Request): - return await _forward_request(request, "/api/v1/system/status") - - @router.get("/ov/console/dashboard/summary") - async def console_dashboard_summary(request: Request): - return await _forward_request(request, "/api/v1/console/dashboard/summary") - - @router.get("/ov/console/tokens") - async def console_tokens(request: Request): - return await _forward_request(request, "/api/v1/console/tokens") - - @router.get("/ov/console/context-commits") - async def console_context_commits(request: Request): - return await _forward_request(request, "/api/v1/console/context-commits") - - @router.get("/ov/console/audit") - async def console_audit(request: Request): - return await _forward_request(request, "/api/v1/console/audit") - - @router.get("/ov/observer/{component}") - async def observer_component(request: Request, component: str): - invalid = _validate_path_param(component, "component") - if invalid: - return invalid - return await _forward_request(request, f"/api/v1/observer/{component}") - - @router.post("/ov/auth/otp") - async def issue_otp(request: Request): - # OTP issuance binds to the caller's existing API key identity, so it - # uses the same auth flow as any other read endpoint. Not gated by - # write_enabled — minting an authentication artifact for yourself is - # not a data mutation. - return await _forward_request(request, "/api/v1/auth/otp") - - @router.post("/ov/auth/oauth-verify") - async def oauth_verify(request: Request): - # Confirm a pending OAuth authorization (device-flow style). The - # caller is the console-authenticated user; the API key in their - # session storage is what binds the issuing identity. - return await _forward_request(request, "/api/v1/auth/oauth-verify") - - # ---- Write routes ---- - - @router.post("/ov/fs/mkdir") - async def fs_mkdir(request: Request): - blocked = _ensure_write_enabled(request) - if blocked: - return blocked - return await _forward_request(request, "/api/v1/fs/mkdir") - - @router.post("/ov/resources") - async def add_resource(request: Request): - blocked = _ensure_write_enabled(request) - if blocked: - return blocked - return await _forward_request(request, "/api/v1/resources") - - @router.post("/ov/resources/temp_upload") - async def add_resource_temp_upload(request: Request): - blocked = _ensure_write_enabled(request) - if blocked: - return blocked - return await _forward_request(request, "/api/v1/resources/temp_upload") - - @router.post("/ov/fs/mv") - async def fs_mv(request: Request): - blocked = _ensure_write_enabled(request) - if blocked: - return blocked - return await _forward_request(request, "/api/v1/fs/mv") - - @router.delete("/ov/fs") - async def fs_rm(request: Request): - blocked = _ensure_write_enabled(request) - if blocked: - return blocked - return await _forward_request(request, "/api/v1/fs") - - @router.post("/ov/admin/accounts") - async def create_account(request: Request): - blocked = _ensure_write_enabled(request) - if blocked: - return blocked - return await _forward_request(request, "/api/v1/admin/accounts") - - @router.delete("/ov/admin/accounts/{account_id}") - async def delete_account(request: Request, account_id: str): - blocked = _ensure_write_enabled(request) - if blocked: - return blocked - invalid = _validate_path_param(account_id, "account_id") - if invalid: - return invalid - return await _forward_request(request, f"/api/v1/admin/accounts/{account_id}") - - @router.post("/ov/admin/accounts/{account_id}/users") - async def create_user(request: Request, account_id: str): - blocked = _ensure_write_enabled(request) - if blocked: - return blocked - invalid = _validate_path_param(account_id, "account_id") - if invalid: - return invalid - return await _forward_request(request, f"/api/v1/admin/accounts/{account_id}/users") - - @router.delete("/ov/admin/accounts/{account_id}/users/{user_id}") - async def delete_user(request: Request, account_id: str, user_id: str): - blocked = _ensure_write_enabled(request) - if blocked: - return blocked - invalid = _validate_path_param(account_id, "account_id") - if invalid: - return invalid - invalid = _validate_path_param(user_id, "user_id") - if invalid: - return invalid - return await _forward_request( - request, f"/api/v1/admin/accounts/{account_id}/users/{user_id}" - ) - - @router.put("/ov/admin/accounts/{account_id}/users/{user_id}/role") - async def set_user_role(request: Request, account_id: str, user_id: str): - blocked = _ensure_write_enabled(request) - if blocked: - return blocked - invalid = _validate_path_param(account_id, "account_id") - if invalid: - return invalid - invalid = _validate_path_param(user_id, "user_id") - if invalid: - return invalid - return await _forward_request( - request, - f"/api/v1/admin/accounts/{account_id}/users/{user_id}/role", - ) - - @router.post("/ov/admin/accounts/{account_id}/users/{user_id}/key") - async def regenerate_key(request: Request, account_id: str, user_id: str): - blocked = _ensure_write_enabled(request) - if blocked: - return blocked - invalid = _validate_path_param(account_id, "account_id") - if invalid: - return invalid - invalid = _validate_path_param(user_id, "user_id") - if invalid: - return invalid - return await _forward_request( - request, - f"/api/v1/admin/accounts/{account_id}/users/{user_id}/key", - ) - - @router.post("/ov/sessions") - async def create_session(request: Request): - blocked = _ensure_write_enabled(request) - if blocked: - return blocked - return await _forward_request(request, "/api/v1/sessions") - - @router.post("/ov/sessions/{session_id}/messages") - async def add_session_message(request: Request, session_id: str): - blocked = _ensure_write_enabled(request) - if blocked: - return blocked - invalid = _validate_path_param(session_id, "session_id") - if invalid: - return invalid - return await _forward_request(request, f"/api/v1/sessions/{session_id}/messages") - - @router.post("/ov/sessions/{session_id}/commit") - async def commit_session(request: Request, session_id: str): - blocked = _ensure_write_enabled(request) - if blocked: - return blocked - invalid = _validate_path_param(session_id, "session_id") - if invalid: - return invalid - return await _forward_request(request, f"/api/v1/sessions/{session_id}/commit") - - return router - - -def create_console_app( - config: Optional[ConsoleConfig] = None, - upstream_transport: Optional[httpx.AsyncBaseTransport] = None, -) -> FastAPI: - """Create console app instance.""" - if config is None: - config = load_console_config() - - static_dir = Path(__file__).resolve().parent / "static" - index_file = static_dir / "index.html" - - @asynccontextmanager - async def lifespan(app: FastAPI): - try: - yield - finally: - client: httpx.AsyncClient = app.state.upstream_client - if not client.is_closed: - await client.aclose() - - app = FastAPI( - title="OpenViking Console", - description="Standalone console for OpenViking HTTP APIs", - version="0.1.0", - lifespan=lifespan, - ) - - app.state.console_config = config - app.state.upstream_client = httpx.AsyncClient( - base_url=config.normalized_base_url(), - timeout=config.request_timeout_sec, - transport=upstream_transport, - ) - - app.add_middleware( - CORSMiddleware, - allow_origins=config.cors_origins, - allow_methods=["*"], - allow_headers=["*"], - # Avoid invalid/unsafe combination: allow_credentials + wildcard origin. - allow_credentials=("*" not in config.cors_origins), - ) - - app.include_router(_create_proxy_router()) - - def _console_file_response(path: Path) -> FileResponse: - return FileResponse(path, headers=_CONSOLE_NO_STORE_HEADERS) - - @app.get("/health", include_in_schema=False) - async def healthz(): - return {"status": "ok", "service": "openviking-console"} - - @app.get("/", include_in_schema=False) - async def index_root(): - return _console_file_response(index_file) - - @app.get("/console", include_in_schema=False) - async def index_console(): - return _console_file_response(index_file) - - @app.get("/console/{path:path}", include_in_schema=False) - async def console_assets(path: str): - if path.startswith("api/"): - return _error_response(status_code=404, code="NOT_FOUND", message="Not found") - - # Prevent directory traversal (e.g. /console/%2e%2e/...) - static_root = static_dir.resolve() - try: - requested_file = (static_dir / path).resolve() - except OSError: - return _error_response(status_code=404, code="NOT_FOUND", message="Not found") - - if not requested_file.is_relative_to(static_root): - return _error_response(status_code=404, code="NOT_FOUND", message="Not found") - - if requested_file.exists() and requested_file.is_file(): - return _console_file_response(requested_file) - return _console_file_response(index_file) - - return app diff --git a/openviking/console/bootstrap.py b/openviking/console/bootstrap.py deleted file mode 100644 index 1ad56a188..000000000 --- a/openviking/console/bootstrap.py +++ /dev/null @@ -1,75 +0,0 @@ -# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. -# SPDX-License-Identifier: AGPL-3.0 -"""Bootstrap entrypoint for OpenViking console service.""" - -from __future__ import annotations - -import argparse -import sys -from pathlib import Path - -import uvicorn - -if __package__ in {None, ""}: - # Allow running as a script from a source checkout: - # python openviking/console/bootstrap.py - sys.path.insert(0, str(Path(__file__).resolve().parents[2])) - -from openviking.console.app import create_console_app -from openviking.console.config import load_console_config - - -def _build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - description="OpenViking Console", - formatter_class=argparse.ArgumentDefaultsHelpFormatter, - ) - parser.add_argument("--host", type=str, default="127.0.0.1", help="Host to bind to") - parser.add_argument("--port", type=int, default=8020, help="Port to bind to") - parser.add_argument( - "--openviking-url", - type=str, - default="http://127.0.0.1:1933", - help="Base URL for OpenViking HTTP service", - ) - parser.add_argument( - "--write-enabled", - action="store_true", - help="Enable write operations in console proxy", - ) - parser.add_argument( - "--request-timeout-sec", - type=float, - default=3600.0, - help="Upstream request timeout in seconds", - ) - parser.add_argument( - "--cors-origins", - type=str, - default="*", - help="Comma-separated CORS origins", - ) - return parser - - -def main() -> None: - """Run console service.""" - parser = _build_parser() - args = parser.parse_args() - - config = load_console_config( - host=args.host, - port=args.port, - openviking_base_url=args.openviking_url, - write_enabled=args.write_enabled, - request_timeout_sec=args.request_timeout_sec, - cors_origins=args.cors_origins, - ) - - app = create_console_app(config=config) - print(f"OpenViking Console is running on {config.host}:{config.port}") - uvicorn.run(app, host=config.host, port=config.port) - - -if __name__ == "__main__": - main() diff --git a/openviking/console/config.py b/openviking/console/config.py deleted file mode 100644 index 0b26c82eb..000000000 --- a/openviking/console/config.py +++ /dev/null @@ -1,84 +0,0 @@ -# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. -# SPDX-License-Identifier: AGPL-3.0 -"""Configuration for the standalone OpenViking console service.""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Iterable, List - - -def _parse_cors_origins(raw_value: str | None) -> List[str]: - if not raw_value: - return ["*"] - return [item.strip() for item in raw_value.split(",") if item.strip()] - - -@dataclass(slots=True) -class ConsoleConfig: - """Runtime settings for console BFF + static frontend.""" - - host: str = "127.0.0.1" - port: int = 8020 - openviking_base_url: str = "http://127.0.0.1:1933" - write_enabled: bool = False - request_timeout_sec: float = 30.0 - cors_origins: List[str] = field(default_factory=lambda: ["*"]) - - def normalized_base_url(self) -> str: - """Return upstream base URL without trailing slash.""" - return self.openviking_base_url.rstrip("/") - - -def load_console_config( - *, - host: str = "127.0.0.1", - port: int = 8020, - openviking_base_url: str = "http://127.0.0.1:1933", - write_enabled: bool = False, - request_timeout_sec: float = 30.0, - cors_origins: str | List[str] | None = None, -) -> ConsoleConfig: - """Load console config from startup parameters.""" - resolved_cors_origins = ( - _parse_cors_origins(cors_origins) - if isinstance(cors_origins, str) or cors_origins is None - else list(cors_origins) - ) - return ConsoleConfig( - host=host, - port=port, - openviking_base_url=openviking_base_url, - write_enabled=write_enabled, - request_timeout_sec=request_timeout_sec, - cors_origins=resolved_cors_origins, - ) - - -def as_runtime_capabilities(config: ConsoleConfig) -> dict: - """Expose runtime behavior switches for UI gating.""" - allowed_modules: Iterable[str] = [ - "fs.read", - "search.find", - "admin.read", - "monitor.read", - ] - if config.write_enabled: - allowed_modules = [*allowed_modules, "fs.write", "admin.write", "resources.write"] - - return { - "write_enabled": config.write_enabled, - "allowed_modules": list(allowed_modules), - "dangerous_actions": [ - "fs.mkdir", - "fs.mv", - "fs.rm", - "admin.create_account", - "admin.delete_account", - "admin.create_user", - "admin.delete_user", - "admin.set_role", - "admin.regenerate_key", - "resources.add_resource", - ], - } diff --git a/openviking/console/static/app.js b/openviking/console/static/app.js deleted file mode 100644 index 26fd86ea4..000000000 --- a/openviking/console/static/app.js +++ /dev/null @@ -1,2663 +0,0 @@ -import { - buildRequestHeaders, - resolveRuntimeConsoleSettings, - saveConsoleSettings, -} from "./console_settings.js"; - -const API_BASE = "/console/api/v1"; -const SESSION_KEY = "ov_console_api_key"; -const THEME_MODE_KEY = "ov_console_theme_mode"; -const NAV_COLLAPSED_KEY = "ov_console_nav_collapsed"; -const RESULT_COLLAPSED_KEY = "ov_console_result_collapsed_v2"; - -const state = { - activePanel: "filesystem", - writeEnabled: false, - fsCurrentUri: "viking://", - fsHistory: [], - fsSortField: "uri", - fsSortDirection: "asc", - fsViewMode: "list", - fsTreeData: {}, - fsTreeExpanded: new Set(), - findRows: [], - findSortField: "", - findSortDirection: "asc", - addResourceMode: "path", - tenantAccounts: [], - tenantFilteredAccounts: [], - tenantUsers: [], - tenantSelectedAccountId: "", - tenantAccountsLoaded: false, - tenantAccountSortField: "account_id", - tenantAccountSortDirection: "asc", - tenantUserSortField: "user_id", - tenantUserSortDirection: "asc", - tenantConfirmRequest: null, - themeMode: "dark", - navCollapsed: false, - resultCollapsed: false, - connectionSettings: { - apiKey: "", - accountId: "", - userId: "", - agentId: "", - }, -}; - -const elements = { - workspace: document.querySelector(".workspace"), - shell: document.querySelector(".shell"), - content: document.querySelector(".content"), - panelStack: document.querySelector(".panel-stack"), - sidebar: document.querySelector(".sidebar"), - resultCard: document.querySelector(".result-card"), - sidebarResizer: document.getElementById("sidebarResizer"), - outputResizer: document.getElementById("outputResizer"), - apiKeyInput: document.getElementById("apiKeyInput"), - accountInput: document.getElementById("accountInput"), - userInput: document.getElementById("userInput"), - agentIdInput: document.getElementById("agentIdInput"), - saveKeyBtn: document.getElementById("saveKeyBtn"), - clearKeyBtn: document.getElementById("clearKeyBtn"), - connectionHint: document.getElementById("connectionHint"), - getOtpBtn: document.getElementById("getOtpBtn"), - otpBox: document.getElementById("otpBox"), - otpValue: document.getElementById("otpValue"), - otpCopyBtn: document.getElementById("otpCopyBtn"), - otpExpiry: document.getElementById("otpExpiry"), - oauthVerifyInput: document.getElementById("oauthVerifyInput"), - oauthVerifyBtn: document.getElementById("oauthVerifyBtn"), - oauthDenyBtn: document.getElementById("oauthDenyBtn"), - oauthVerifyHint: document.getElementById("oauthVerifyHint"), - writeBadge: document.getElementById("writeBadge"), - output: document.getElementById("output"), - tabs: document.querySelectorAll(".tab"), - panels: document.querySelectorAll(".panel"), - fsBackBtn: document.getElementById("fsBackBtn"), - fsUpBtn: document.getElementById("fsUpBtn"), - fsRefreshBtn: document.getElementById("fsRefreshBtn"), - fsModeListBtn: document.getElementById("fsModeListBtn"), - fsModeTreeBtn: document.getElementById("fsModeTreeBtn"), - fsGoBtn: document.getElementById("fsGoBtn"), - fsCurrentUri: document.getElementById("fsCurrentUri"), - fsEntries: document.getElementById("fsEntries"), - fsSortHeaders: document.querySelectorAll(".fs-sort-btn"), - fsTable: document.querySelector(".fs-table"), - fsTableWrap: document.querySelector(".fs-table-wrap"), - fsTree: document.getElementById("fsTree"), - findQuery: document.getElementById("findQuery"), - findTarget: document.getElementById("findTarget"), - findLimit: document.getElementById("findLimit"), - findBtn: document.getElementById("findBtn"), - findResultsHead: document.getElementById("findResultsHead"), - findResultsBody: document.getElementById("findResultsBody"), - addResourcePath: document.getElementById("addResourcePath"), - addResourceFile: document.getElementById("addResourceFile"), - addResourceModePathBtn: document.getElementById("addResourceModePathBtn"), - addResourceModeUploadBtn: document.getElementById("addResourceModeUploadBtn"), - addResourcePathPane: document.getElementById("addResourcePathPane"), - addResourceUploadPane: document.getElementById("addResourceUploadPane"), - addResourceTarget: document.getElementById("addResourceTarget"), - addResourceWait: document.getElementById("addResourceWait"), - addResourceStrict: document.getElementById("addResourceStrict"), - addResourceUploadMedia: document.getElementById("addResourceUploadMedia"), - addResourceTimeout: document.getElementById("addResourceTimeout"), - addResourceIgnoreDirs: document.getElementById("addResourceIgnoreDirs"), - addResourceInclude: document.getElementById("addResourceInclude"), - addResourceExclude: document.getElementById("addResourceExclude"), - addResourceReason: document.getElementById("addResourceReason"), - addResourceInstruction: document.getElementById("addResourceInstruction"), - addResourceSubmitBtn: document.getElementById("addResourceSubmitBtn"), - addMemoryInput: document.getElementById("addMemoryInput"), - addMemoryBtn: document.getElementById("addMemoryBtn"), - tenantAccountSearch: document.getElementById("tenantAccountSearch"), - tenantRefreshAccountsBtn: document.getElementById("tenantRefreshAccountsBtn"), - tenantCreateAccountBtn: document.getElementById("tenantCreateAccountBtn"), - tenantCreateAccountId: document.getElementById("tenantCreateAccountId"), - tenantCreateAdminUserId: document.getElementById("tenantCreateAdminUserId"), - tenantAccountsBody: document.getElementById("tenantAccountsBody"), - tenantCurrentAccount: document.getElementById("tenantCurrentAccount"), - tenantAddUserBtn: document.getElementById("tenantAddUserBtn"), - tenantAddUserId: document.getElementById("tenantAddUserId"), - tenantAddUserRole: document.getElementById("tenantAddUserRole"), - tenantUsersBody: document.getElementById("tenantUsersBody"), - tenantAccountSortBtns: document.querySelectorAll("[data-tenant-account-sort]"), - tenantUserSortBtns: document.querySelectorAll("[data-tenant-user-sort]"), - tenantConfirmModal: document.getElementById("tenantConfirmModal"), - tenantConfirmTitle: document.getElementById("tenantConfirmTitle"), - tenantConfirmMessage: document.getElementById("tenantConfirmMessage"), - tenantConfirmLabel: document.getElementById("tenantConfirmLabel"), - tenantConfirmInput: document.getElementById("tenantConfirmInput"), - tenantConfirmError: document.getElementById("tenantConfirmError"), - tenantConfirmActionBtn: document.getElementById("tenantConfirmActionBtn"), - tenantConfirmCancelBtn: document.getElementById("tenantConfirmCancelBtn"), - systemBtn: document.getElementById("systemBtn"), - observerBtn: document.getElementById("observerBtn"), - monitorResults: document.getElementById("monitorResults"), - navToggleBtn: document.getElementById("navToggleBtn"), - resultToggleBtn: document.getElementById("resultToggleBtn"), - clearOutputBtn: document.getElementById("clearOutputBtn"), - themeButtons: document.querySelectorAll("[data-theme-mode]"), -}; - -const layoutLimits = { - minSidebar: 200, - maxSidebar: 560, - minPanel: 180, - minResult: 56, -}; - -function readLocalStorage(key) { - try { - return window.localStorage.getItem(key); - } catch (_error) { - return null; - } -} - -function writeLocalStorage(key, value) { - try { - window.localStorage.setItem(key, value); - } catch (_error) { - // Ignore storage failures in private mode or restricted browsers. - } -} - -function prefersDarkTheme() { - return window.matchMedia("(prefers-color-scheme: dark)").matches; -} - -function resolveThemeMode(mode) { - if (mode === "light") { - return "light"; - } - if (mode === "system") { - return prefersDarkTheme() ? "dark" : "light"; - } - return "dark"; -} - -function updateThemeButtons() { - for (const button of elements.themeButtons) { - const selected = button.dataset.themeMode === state.themeMode; - button.classList.toggle("active", selected); - button.setAttribute("aria-pressed", selected ? "true" : "false"); - } -} - -function applyThemeMode(mode, { persist = true } = {}) { - const normalized = mode === "light" || mode === "system" ? mode : "dark"; - state.themeMode = normalized; - const resolved = resolveThemeMode(normalized); - document.documentElement.setAttribute("data-theme", resolved); - updateThemeButtons(); - if (persist) { - writeLocalStorage(THEME_MODE_KEY, normalized); - } -} - -function applyShellStateClasses() { - if (!elements.shell) { - return; - } - elements.shell.classList.toggle("shell--nav-collapsed", state.navCollapsed); - elements.shell.classList.toggle("shell--result-collapsed", state.resultCollapsed); -} - -function setNavCollapsed(collapsed, { persist = true } = {}) { - state.navCollapsed = Boolean(collapsed); - applyShellStateClasses(); - if (persist) { - writeLocalStorage(NAV_COLLAPSED_KEY, state.navCollapsed ? "1" : "0"); - } -} - -function setResultCollapsed(collapsed, { persist = true } = {}) { - state.resultCollapsed = Boolean(collapsed); - applyShellStateClasses(); - if (elements.resultToggleBtn) { - elements.resultToggleBtn.textContent = state.resultCollapsed ? "Show Result" : "Hide Result"; - } - if (persist) { - writeLocalStorage(RESULT_COLLAPSED_KEY, state.resultCollapsed ? "1" : "0"); - } -} - -function syncResultEmptyState() { - const isEmpty = !elements.output.textContent.trim(); - elements.shell.classList.toggle("shell--result-empty", isEmpty); - elements.resultCard.classList.toggle("result-card--empty", isEmpty); - elements.output.dataset.empty = isEmpty ? "true" : "false"; -} - -function setOutput(value) { - const content = typeof value === "string" ? value : JSON.stringify(value, null, 2); - elements.output.textContent = content; - syncResultEmptyState(); -} - -function setActivePanel(panel) { - state.activePanel = panel; - for (const tab of elements.tabs) { - tab.classList.toggle("active", tab.dataset.panel === panel); - } - for (const panelNode of elements.panels) { - panelNode.classList.toggle("active", panelNode.id === `panel-${panel}`); - } - - if (window.matchMedia("(max-width: 900px)").matches) { - setNavCollapsed(true); - } - - // If a confirmation dialog was left open, never carry it across panel switches. - if (elements.tenantConfirmModal && !elements.tenantConfirmModal.hidden) { - closeTenantConfirmModal(); - } - - if (panel === "tenants") { - ensureTenantsLoaded().catch((error) => { - setOutput(error.message); - }); - } -} - -function getApiKey() { - return window.sessionStorage.getItem(SESSION_KEY) || state.connectionSettings.apiKey || ""; -} - -function updateConnectionHint() { - const key = getApiKey(); - const identities = [ - state.connectionSettings.accountId ? `account=${state.connectionSettings.accountId}` : "", - state.connectionSettings.userId ? `user=${state.connectionSettings.userId}` : "", - state.connectionSettings.agentId ? `agent=${state.connectionSettings.agentId}` : "", - ].filter(Boolean); - - const apiKeySummary = key ? `API key loaded in session (${key.length} chars).` : "No API key in session."; - const identitySummary = identities.length - ? ` Identity headers: ${identities.join(", ")}.` - : " No account/user/agent id configured."; - elements.connectionHint.textContent = `${apiKeySummary}${identitySummary}`; -} - -function truncateText(value, maxLength = 4000) { - const text = String(value || ""); - if (text.length <= maxLength) { - return text; - } - return `${text.slice(0, maxLength)}\n... (truncated, ${text.length} chars total)`; -} - -function isJsonLikeContentType(contentType) { - const value = (contentType || "").toLowerCase(); - return value.includes("application/json") || value.includes("+json"); -} - -async function callConsole(path, options = {}) { - const baseHeaders = { - ...(options.headers || {}), - }; - - if (!(options.body instanceof FormData)) { - baseHeaders["Content-Type"] = baseHeaders["Content-Type"] || "application/json"; - } - const headers = buildRequestHeaders(baseHeaders, { - apiKey: getApiKey(), - ...state.connectionSettings, - }); - - let response; - try { - response = await fetch(`${API_BASE}${path}`, { - ...options, - headers, - }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`NETWORK_ERROR: ${message}`); - } - - const contentType = response.headers.get("content-type") || ""; - const status = response.status; - - let payload = null; - let rawText = ""; - - if (status === 204 || status === 205) { - payload = { status: "ok", result: null }; - } else if (isJsonLikeContentType(contentType)) { - const clone = response.clone(); - try { - payload = await response.json(); - } catch (_error) { - rawText = await clone.text().catch(() => ""); - payload = response.ok - ? { status: "ok", result: rawText } - : { - status: "error", - error: { - code: "BAD_RESPONSE", - message: "Invalid JSON response from console", - detail: truncateText(rawText, 2000), - }, - }; - } - } else { - rawText = await response.text().catch(() => ""); - payload = response.ok - ? { status: "ok", result: rawText } - : { - status: "error", - error: { - code: "HTTP_ERROR", - message: rawText ? truncateText(rawText, 2000) : `Request failed with status ${status}`, - }, - }; - } - - if (!response.ok) { - const code = payload?.error?.code || "ERROR"; - const message = - payload?.error?.message || `Request failed with status ${response.status} ${response.statusText}`; - const missingApiKey = - code === "UNAUTHENTICATED" && String(message).toLowerCase().includes("missing api key"); - const hint = missingApiKey ? " Please go to Settings and set X-API-Key." : ""; - throw new Error(`${code}: ${message}${hint}`); - } - - return payload; -} - -function normalizeDirUri(uri) { - const value = (uri || "").trim(); - if (!value) { - return "viking://"; - } - if (value === "viking://") { - return value; - } - return value.endsWith("/") ? value : `${value}/`; -} - -function parentUri(uri) { - const normalized = normalizeDirUri(uri); - if (normalized === "viking://") { - return normalized; - } - - const scheme = "viking://"; - if (!normalized.startsWith(scheme)) { - return scheme; - } - - const withoutTrailingSlash = normalized.slice(0, -1); - const body = withoutTrailingSlash.slice(scheme.length); - if (!body.includes("/")) { - return scheme; - } - - const prefix = body.slice(0, body.lastIndexOf("/") + 1); - return `${scheme}${prefix}`; -} - -function joinUri(baseUri, child) { - const raw = String(child || "").trim(); - if (!raw) { - return normalizeDirUri(baseUri); - } - if (raw.startsWith("viking://")) { - return raw; - } - - const normalizedBase = normalizeDirUri(baseUri); - const cleanedChild = raw.replace(/^\//, ""); - return `${normalizedBase}${cleanedChild}`; -} - -function pickFirstNonEmpty(candidates) { - for (const candidate of candidates) { - if (candidate !== undefined && candidate !== null && String(candidate).trim() !== "") { - return candidate; - } - } - return null; -} - -function normalizeFsEntries(result, currentUri) { - const toEntry = (item) => { - if (typeof item === "string") { - const rawName = item.trim(); - const isDir = rawName.endsWith("/"); - const resolvedUri = joinUri(currentUri, rawName); - return { - uri: isDir ? normalizeDirUri(resolvedUri) : resolvedUri, - size: null, - isDir, - modTime: null, - abstract: "", - }; - } - - if (item && typeof item === "object") { - const baseLabel = - item.name || item.path || item.relative_path || item.uri || item.id || JSON.stringify(item); - const isDir = - Boolean(item.is_dir) || - Boolean(item.isDir) || - item.type === "dir" || - item.type === "directory" || - item.kind === "dir" || - String(baseLabel).endsWith("/"); - const rawUri = item.uri || item.path || item.relative_path || baseLabel; - const resolvedUri = joinUri(currentUri, rawUri); - const size = pickFirstNonEmpty([ - item.size, - item.size_bytes, - item.content_length, - item.contentLength, - item.bytes, - ]); - const modTime = pickFirstNonEmpty([ - item.modTime, - item.mod_time, - item.mtime, - item.modified_at, - item.modifiedAt, - item.updated_at, - item.updatedAt, - item.last_modified, - item.lastModified, - item.timestamp, - item.time, - ]); - const abstract = pickFirstNonEmpty([ - item.abstract, - item.summary, - item.description, - item.desc, - ]); - - return { - uri: isDir ? normalizeDirUri(resolvedUri) : resolvedUri, - size, - isDir, - modTime, - abstract: abstract === null ? "" : String(abstract), - }; - } - - return { - uri: joinUri(currentUri, String(item)), - size: null, - isDir: false, - modTime: null, - abstract: "", - }; - }; - - if (Array.isArray(result)) { - return result.map(toEntry); - } - - if (result && typeof result === "object") { - const candidates = [result.entries, result.items, result.children, result.results]; - for (const candidate of candidates) { - if (Array.isArray(candidate)) { - return candidate.map(toEntry); - } - } - } - - if (typeof result === "string") { - return result - .split("\n") - .map((line) => line.trim()) - .filter(Boolean) - .map(toEntry); - } - - return []; -} - -function normalizeSortString(value) { - if (value === null || value === undefined) { - return ""; - } - return String(value).toLowerCase(); -} - -function toSortableNumber(value) { - if (typeof value === "number" && Number.isFinite(value)) { - return value; - } - if (typeof value === "string") { - const parsed = Number.parseFloat(value); - if (Number.isFinite(parsed)) { - return parsed; - } - } - return null; -} - -function toSortableTime(value) { - if (!value) { - return null; - } - - const date = new Date(value); - if (!Number.isNaN(date.getTime())) { - return date.getTime(); - } - return toSortableNumber(value); -} - -function compareNullable(left, right, compareFn) { - const leftMissing = left === null || left === undefined || left === ""; - const rightMissing = right === null || right === undefined || right === ""; - if (leftMissing && rightMissing) { - return 0; - } - if (leftMissing) { - return 1; - } - if (rightMissing) { - return -1; - } - return compareFn(left, right); -} - -function compareFsEntries(left, right, field) { - switch (field) { - case "size": - return compareNullable(left.size, right.size, (a, b) => { - const leftNum = toSortableNumber(a); - const rightNum = toSortableNumber(b); - if (leftNum !== null && rightNum !== null) { - return leftNum - rightNum; - } - return normalizeSortString(a).localeCompare(normalizeSortString(b)); - }); - case "isDir": - return Number(left.isDir) - Number(right.isDir); - case "modTime": - return compareNullable(left.modTime, right.modTime, (a, b) => { - const leftTime = toSortableTime(a); - const rightTime = toSortableTime(b); - if (leftTime !== null && rightTime !== null) { - return leftTime - rightTime; - } - return normalizeSortString(a).localeCompare(normalizeSortString(b)); - }); - case "abstract": - return compareNullable(left.abstract, right.abstract, (a, b) => - normalizeSortString(a).localeCompare(normalizeSortString(b)) - ); - case "uri": - default: - return normalizeSortString(left.uri).localeCompare(normalizeSortString(right.uri)); - } -} - -function sortFilesystemEntries(entries) { - const sorted = [...entries].sort((left, right) => - compareFsEntries(left, right, state.fsSortField) - ); - if (state.fsSortDirection === "desc") { - sorted.reverse(); - } - return sorted; -} - -function updateFilesystemSortHeaders() { - for (const button of elements.fsSortHeaders) { - const field = button.dataset.fsSort || ""; - const isActive = field === state.fsSortField; - button.classList.toggle("active", isActive); - button.setAttribute( - "aria-sort", - isActive ? (state.fsSortDirection === "asc" ? "ascending" : "descending") : "none" - ); - const suffix = !isActive ? "" : state.fsSortDirection === "asc" ? " ↑" : " ↓"; - button.textContent = `${field}${suffix}`; - } -} - -function bindFilesystemSort() { - for (const button of elements.fsSortHeaders) { - button.addEventListener("click", async () => { - const field = button.dataset.fsSort; - if (!field) { - return; - } - - if (state.fsSortField === field) { - state.fsSortDirection = state.fsSortDirection === "asc" ? "desc" : "asc"; - } else { - state.fsSortField = field; - state.fsSortDirection = "asc"; - } - - updateFilesystemSortHeaders(); - - try { - await loadFilesystem(state.fsCurrentUri); - } catch (error) { - setOutput(error.message); - } - }); - } -} - -function initFsColumnResize() { - if (!elements.fsTable) { - return; - } - - const headers = elements.fsTable.querySelectorAll("thead th"); - for (const header of headers) { - if (header.dataset.resizable === "false") { - continue; - } - if (header.querySelector(".fs-col-resizer")) { - continue; - } - - const handle = document.createElement("div"); - handle.className = "fs-col-resizer"; - handle.setAttribute("role", "separator"); - handle.setAttribute("aria-orientation", "vertical"); - handle.setAttribute("aria-label", "Resize column"); - header.appendChild(handle); - - handle.addEventListener("pointerdown", (event) => { - event.preventDefault(); - event.stopPropagation(); - document.body.classList.add("dragging-fs-column"); - - const startX = event.clientX; - const startWidth = header.getBoundingClientRect().width; - const minWidth = Number.parseFloat(header.dataset.minWidth || "90"); - - handle.setPointerCapture(event.pointerId); - - const onMove = (moveEvent) => { - const nextWidth = clamp(startWidth + (moveEvent.clientX - startX), minWidth, 1200); - header.style.width = `${nextWidth}px`; - header.style.minWidth = `${nextWidth}px`; - }; - - const onUp = () => { - handle.removeEventListener("pointermove", onMove); - handle.removeEventListener("pointerup", onUp); - handle.removeEventListener("pointercancel", onUp); - document.body.classList.remove("dragging-fs-column"); - handle.releasePointerCapture(event.pointerId); - }; - - handle.addEventListener("pointermove", onMove); - handle.addEventListener("pointerup", onUp); - handle.addEventListener("pointercancel", onUp); - }); - } -} - -function normalizeReadContent(result) { - if (typeof result === "string") { - return result; - } - if (Array.isArray(result)) { - return result.map((item) => String(item)).join("\n"); - } - if (result && typeof result === "object") { - const content = pickFirstNonEmpty([ - result.content, - result.text, - result.body, - result.value, - result.data, - ]); - if (content !== null) { - return typeof content === "string" ? content : JSON.stringify(content, null, 2); - } - } - return JSON.stringify(result, null, 2); -} - -async function readFilesystemFile(entry) { - const uri = String(entry?.uri || "").replace(/\/$/, ""); - if (!uri) { - throw new Error("Invalid file uri."); - } - - setOutput(`Reading ${uri} ...`); - const payload = await callConsole( - `/ov/content/read?uri=${encodeURIComponent(uri)}&offset=0&limit=-1`, - { method: "GET" } - ); - const content = normalizeReadContent(payload.result); - setOutput(content && content.trim() ? content : "(empty file)"); -} - -async function statFilesystemResource(entry) { - let uri = String(entry?.uri || "").trim(); - if (!uri) { - throw new Error("Invalid resource uri."); - } - if (uri !== "viking://") { - uri = uri.replace(/\/$/, ""); - } - - const payload = await callConsole(`/ov/fs/stat?uri=${encodeURIComponent(uri)}`, { method: "GET" }); - setOutput(payload); -} - -function renderFilesystemEntries(target, rows, onOpen, onOpenContent) { - target.innerHTML = ""; - - if (!rows.length) { - const tr = document.createElement("tr"); - const td = document.createElement("td"); - td.colSpan = 6; - td.className = "fs-empty"; - td.textContent = "No data"; - tr.appendChild(td); - target.appendChild(tr); - return; - } - - for (const row of rows) { - const tr = document.createElement("tr"); - - const actionCell = document.createElement("td"); - actionCell.className = "fs-col-action"; - const openBtn = document.createElement("button"); - openBtn.type = "button"; - openBtn.className = "fs-open-btn"; - openBtn.title = "Show stat info"; - openBtn.setAttribute("aria-label", `Show stat info for ${row.uri}`); - openBtn.textContent = "ⓘ"; - openBtn.addEventListener("click", async (event) => { - event.preventDefault(); - event.stopPropagation(); - try { - await onOpenContent(row); - } catch (error) { - setOutput(error.message); - } - }); - actionCell.appendChild(openBtn); - tr.appendChild(actionCell); - - const uriCell = document.createElement("td"); - uriCell.className = "fs-col-uri"; - const uriBtn = document.createElement("button"); - uriBtn.type = "button"; - uriBtn.className = "fs-uri-btn"; - uriBtn.textContent = row.uri || "-"; - uriBtn.addEventListener("click", () => onOpen(row)); - uriCell.appendChild(uriBtn); - tr.appendChild(uriCell); - - const sizeCell = document.createElement("td"); - sizeCell.className = "fs-col-size"; - sizeCell.textContent = row.size === null || row.size === undefined || row.size === "" ? "-" : String(row.size); - tr.appendChild(sizeCell); - - const dirCell = document.createElement("td"); - dirCell.className = "fs-col-dir"; - dirCell.textContent = row.isDir ? "true" : "false"; - tr.appendChild(dirCell); - - const modTimeCell = document.createElement("td"); - modTimeCell.className = "fs-col-mod-time"; - modTimeCell.textContent = - row.modTime === null || row.modTime === undefined || row.modTime === "" - ? "-" - : String(row.modTime); - tr.appendChild(modTimeCell); - - const abstractCell = document.createElement("td"); - abstractCell.className = "fs-col-abstract"; - abstractCell.textContent = row.abstract || "-"; - tr.appendChild(abstractCell); - - target.appendChild(tr); - } -} - -function isRecord(value) { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - -function extractDeepestObjectArray(value) { - const best = { depth: -1, rows: null }; - - const visit = (current, depth) => { - if (Array.isArray(current)) { - if (current.length > 0 && current.every((item) => isRecord(item))) { - if (depth > best.depth) { - best.depth = depth; - best.rows = current; - } - } - - for (const item of current) { - visit(item, depth + 1); - } - return; - } - - if (!isRecord(current)) { - return; - } - - for (const nested of Object.values(current)) { - visit(nested, depth + 1); - } - }; - - visit(value, 0); - return best.rows; -} - -function normalizeFindRows(result) { - if (Array.isArray(result)) { - return result.map((item) => (isRecord(item) ? item : { value: item })); - } - - if (isRecord(result)) { - const typedBucketKeys = ["memories", "resources", "skills"]; - const hasTypedBuckets = typedBucketKeys.some((key) => Array.isArray(result[key])); - if (hasTypedBuckets) { - const typedRows = []; - for (const key of typedBucketKeys) { - const rows = Array.isArray(result[key]) ? result[key] : []; - for (const row of rows) { - const normalized = isRecord(row) ? row : { value: row }; - typedRows.push({ - ...normalized, - context_type: - normalized.context_type || (key === "memories" ? "memory" : key.slice(0, -1)), - }); - } - } - return typedRows; - } - - const topLevelArrays = [ - result.results, - result.items, - result.matches, - result.hits, - result.rows, - result.entries, - result.data, - ]; - for (const rows of topLevelArrays) { - if (Array.isArray(rows)) { - return rows.map((item) => (isRecord(item) ? item : { value: item })); - } - } - - const deepestRows = extractDeepestObjectArray(result); - if (deepestRows) { - return deepestRows; - } - - return [result]; - } - - if (result === null || result === undefined) { - return []; - } - - return [{ value: result }]; -} - -function collectFindColumns(rows) { - const columns = []; - const seen = new Set(); - - for (const row of rows) { - if (!isRecord(row)) { - continue; - } - - for (const key of Object.keys(row)) { - if (!seen.has(key)) { - seen.add(key); - columns.push(key); - } - } - } - - return columns; -} - -function formatFindCellValue(value) { - if (value === null || value === undefined || value === "") { - return "-"; - } - if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { - return String(value); - } - return JSON.stringify(value); -} - -function renderFindCellContent(td, column, value) { - const expandableColumns = new Set(["abstract", "overview"]); - const formattedValue = formatFindCellValue(value); - if (!expandableColumns.has(column) || formattedValue === "-") { - td.textContent = formattedValue; - return; - } - - td.classList.add("find-cell-expandable"); - td.classList.add("find-col-abstract"); - - const wrapper = document.createElement("div"); - wrapper.className = "find-cell-content"; - - const text = document.createElement("span"); - text.className = "find-cell-text"; - text.textContent = formattedValue; - - const toggle = document.createElement("button"); - toggle.type = "button"; - toggle.className = "find-cell-expand-btn"; - toggle.textContent = "Expand"; - - let expanded = false; - toggle.addEventListener("click", () => { - expanded = !expanded; - text.classList.toggle("expanded", expanded); - wrapper.classList.toggle("expanded", expanded); - toggle.textContent = expanded ? "Collapse" : "Expand"; - toggle.setAttribute("aria-expanded", expanded ? "true" : "false"); - }); - toggle.setAttribute("aria-expanded", "false"); - - wrapper.appendChild(text); - wrapper.appendChild(toggle); - td.appendChild(wrapper); -} - -function toFindComparable(value) { - if (value === null || value === undefined || value === "") { - return { missing: true, type: "missing", value: "" }; - } - - if (typeof value === "number" && Number.isFinite(value)) { - return { missing: false, type: "number", value }; - } - - if (typeof value === "boolean") { - return { missing: false, type: "number", value: Number(value) }; - } - - if (typeof value === "string") { - const trimmed = value.trim(); - const asNumber = Number.parseFloat(trimmed); - if (trimmed !== "" && Number.isFinite(asNumber)) { - return { missing: false, type: "number", value: asNumber }; - } - - const asDate = new Date(trimmed); - if (!Number.isNaN(asDate.getTime())) { - return { missing: false, type: "date", value: asDate.getTime() }; - } - - return { missing: false, type: "string", value: trimmed.toLowerCase() }; - } - - return { missing: false, type: "string", value: JSON.stringify(value).toLowerCase() }; -} - -function compareFindValues(left, right) { - const leftValue = toFindComparable(left); - const rightValue = toFindComparable(right); - - if (leftValue.missing && rightValue.missing) { - return 0; - } - if (leftValue.missing) { - return 1; - } - if (rightValue.missing) { - return -1; - } - - if (leftValue.type === rightValue.type && (leftValue.type === "number" || leftValue.type === "date")) { - return leftValue.value - rightValue.value; - } - - return String(leftValue.value).localeCompare(String(rightValue.value)); -} - -function sortFindRows(rows, column, direction) { - const sorted = [...rows].sort((left, right) => { - const leftCell = isRecord(left) ? left[column] : undefined; - const rightCell = isRecord(right) ? right[column] : undefined; - return compareFindValues(leftCell, rightCell); - }); - - if (direction === "desc") { - sorted.reverse(); - } - return sorted; -} - -function renderFindTable(rows) { - state.findRows = rows; - elements.findResultsHead.innerHTML = ""; - elements.findResultsBody.innerHTML = ""; - - const columns = collectFindColumns(rows); - if (!columns.length) { - columns.push("value"); - } - - if (!state.findSortField || !columns.includes(state.findSortField)) { - state.findSortField = columns[0]; - state.findSortDirection = "asc"; - } - - const headerRow = document.createElement("tr"); - for (const column of columns) { - const th = document.createElement("th"); - th.scope = "col"; - - const sortBtn = document.createElement("button"); - sortBtn.type = "button"; - sortBtn.className = "find-sort-btn"; - sortBtn.dataset.findSort = column; - - const isActive = state.findSortField === column; - const sortLabel = isActive ? (state.findSortDirection === "asc" ? " ↑" : " ↓") : ""; - sortBtn.textContent = `${column}${sortLabel}`; - sortBtn.setAttribute( - "aria-sort", - isActive ? (state.findSortDirection === "asc" ? "ascending" : "descending") : "none" - ); - - sortBtn.addEventListener("click", () => { - if (state.findSortField === column) { - state.findSortDirection = state.findSortDirection === "asc" ? "desc" : "asc"; - } else { - state.findSortField = column; - state.findSortDirection = "asc"; - } - renderFindTable(state.findRows); - }); - - th.appendChild(sortBtn); - headerRow.appendChild(th); - } - elements.findResultsHead.appendChild(headerRow); - - if (!rows.length) { - const emptyRow = document.createElement("tr"); - const emptyCell = document.createElement("td"); - emptyCell.colSpan = columns.length; - emptyCell.className = "find-empty"; - emptyCell.textContent = "No data"; - emptyRow.appendChild(emptyCell); - elements.findResultsBody.appendChild(emptyRow); - return; - } - - const sortedRows = sortFindRows(rows, state.findSortField, state.findSortDirection); - for (const row of sortedRows) { - const tr = document.createElement("tr"); - for (const column of columns) { - const td = document.createElement("td"); - const cellValue = isRecord(row) ? row[column] : undefined; - renderFindCellContent(td, column, cellValue); - tr.appendChild(td); - } - elements.findResultsBody.appendChild(tr); - } -} - -function renderList(target, rows, onClick) { - target.innerHTML = ""; - if (!rows.length) { - const empty = document.createElement("li"); - empty.innerHTML = '
No data
'; - target.appendChild(empty); - return; - } - - for (const row of rows) { - const li = document.createElement("li"); - if (onClick) { - const button = document.createElement("button"); - button.type = "button"; - button.textContent = row.label; - button.addEventListener("click", () => onClick(row)); - li.appendChild(button); - } else { - const div = document.createElement("div"); - div.className = "row-item"; - div.textContent = row.label; - li.appendChild(div); - } - target.appendChild(li); - } -} - -function clamp(value, min, max) { - return Math.max(min, Math.min(max, value)); -} - -function syncWriteControls() { - const writeButtons = document.querySelectorAll("[data-tenant-write]"); - for (const button of writeButtons) { - button.disabled = !state.writeEnabled; - } -} - -function initResizablePanes() { - const rootStyle = document.documentElement.style; - - if (elements.sidebarResizer && elements.sidebar) { - elements.sidebarResizer.addEventListener("pointerdown", (event) => { - if (window.matchMedia("(max-width: 900px)").matches) { - return; - } - event.preventDefault(); - document.body.classList.add("dragging-sidebar"); - elements.sidebarResizer.setPointerCapture(event.pointerId); - const startX = event.clientX; - const startWidth = elements.sidebar.getBoundingClientRect().width; - - const onMove = (moveEvent) => { - const nextWidth = clamp( - startWidth + (moveEvent.clientX - startX), - layoutLimits.minSidebar, - layoutLimits.maxSidebar - ); - rootStyle.setProperty("--sidebar-width", `${nextWidth}px`); - }; - - const onUp = () => { - elements.sidebarResizer.removeEventListener("pointermove", onMove); - elements.sidebarResizer.removeEventListener("pointerup", onUp); - elements.sidebarResizer.removeEventListener("pointercancel", onUp); - document.body.classList.remove("dragging-sidebar"); - elements.sidebarResizer.releasePointerCapture(event.pointerId); - }; - - elements.sidebarResizer.addEventListener("pointermove", onMove); - elements.sidebarResizer.addEventListener("pointerup", onUp); - elements.sidebarResizer.addEventListener("pointercancel", onUp); - }); - } - - if (elements.outputResizer && elements.resultCard) { - elements.outputResizer.addEventListener("pointerdown", (event) => { - if (window.matchMedia("(max-width: 900px)").matches) { - return; - } - event.preventDefault(); - document.body.classList.add("dragging-output"); - elements.outputResizer.setPointerCapture(event.pointerId); - const startY = event.clientY; - const startHeight = - elements.panelStack?.getBoundingClientRect().height || layoutLimits.minPanel; - - const onMove = (moveEvent) => { - const contentHeight = elements.content?.getBoundingClientRect().height || window.innerHeight; - const resizerHeight = elements.outputResizer.getBoundingClientRect().height || 8; - const rowGap = Number.parseFloat( - window.getComputedStyle(elements.content || document.body).rowGap || "0" - ); - const totalGap = Number.isFinite(rowGap) ? rowGap * 2 : 0; - const availableHeight = Math.max( - layoutLimits.minPanel + layoutLimits.minResult, - contentHeight - resizerHeight - totalGap - ); - const maxPanel = Math.max(layoutLimits.minPanel, availableHeight - layoutLimits.minResult); - const nextPanelHeight = clamp( - startHeight + (moveEvent.clientY - startY), - layoutLimits.minPanel, - maxPanel - ); - rootStyle.setProperty("--panel-height", `${nextPanelHeight}px`); - }; - - const onUp = () => { - elements.outputResizer.removeEventListener("pointermove", onMove); - elements.outputResizer.removeEventListener("pointerup", onUp); - elements.outputResizer.removeEventListener("pointercancel", onUp); - document.body.classList.remove("dragging-output"); - elements.outputResizer.releasePointerCapture(event.pointerId); - }; - - elements.outputResizer.addEventListener("pointermove", onMove); - elements.outputResizer.addEventListener("pointerup", onUp); - elements.outputResizer.addEventListener("pointercancel", onUp); - }); - } -} - -function buildFsTreeItem(entry, depth) { - const uriStr = entry.uri || ""; - const trimmed = uriStr.replace(/\/$/, ""); - const lastSlash = trimmed.lastIndexOf("/"); - const displayName = lastSlash >= 0 ? trimmed.slice(lastSlash + 1) || trimmed : trimmed; - - const item = document.createElement("div"); - item.className = `fs-tree-item${entry.isDir ? " fs-tree-item--dir" : ""}`; - item.style.paddingLeft = `${10 + depth * 16}px`; - - // ⓘ button — leftmost, matches list view action column - const infoBtn = document.createElement("button"); - infoBtn.type = "button"; - infoBtn.className = "fs-tree-info-btn"; - infoBtn.textContent = "ⓘ"; - infoBtn.title = "Show stat info"; - infoBtn.setAttribute("aria-label", `Show stat info for ${uriStr}`); - infoBtn.addEventListener("click", async (event) => { - event.stopPropagation(); - try { - await statFilesystemResource(entry); - } catch (error) { - setOutput(error.message); - } - }); - item.appendChild(infoBtn); - - // collapse/expand arrow (dirs only; files get a fixed-width placeholder) - const toggle = document.createElement("span"); - toggle.className = "fs-tree-toggle"; - toggle.setAttribute("aria-hidden", "true"); - toggle.textContent = entry.isDir ? (state.fsTreeExpanded.has(entry.uri) ? "▼" : "▶") : ""; - item.appendChild(toggle); - - const name = document.createElement("span"); - name.className = "fs-tree-name"; - name.textContent = displayName; - name.title = uriStr; - item.appendChild(name); - - item.addEventListener("click", async () => { - if (entry.isDir) { - if (state.fsTreeExpanded.has(entry.uri)) { - state.fsTreeExpanded.delete(entry.uri); - await renderFsTree(); - } else if (state.fsTreeData[entry.uri]) { - state.fsTreeExpanded.add(entry.uri); - await renderFsTree(); - } else { - try { - const payload = await callConsole( - `/ov/fs/ls?uri=${encodeURIComponent(entry.uri)}&show_all_hidden=true`, - { method: "GET" } - ); - const children = normalizeFsEntries(payload.result, entry.uri); - children.sort((a, b) => { - if (a.isDir !== b.isDir) { - return a.isDir ? -1 : 1; - } - return (a.uri || "").localeCompare(b.uri || ""); - }); - state.fsTreeData[entry.uri] = children; - state.fsTreeExpanded.add(entry.uri); - await renderFsTree(); - } catch (error) { - setOutput(error.message); - } - } - } else { - try { - await readFilesystemFile(entry); - } catch (error) { - setOutput(error.message); - } - } - }); - - return item; -} - -async function renderFsTreeLevel(container, uri, depth) { - const entries = state.fsTreeData[uri] || []; - for (const entry of entries) { - const item = buildFsTreeItem(entry, depth); - container.appendChild(item); - if (entry.isDir && state.fsTreeExpanded.has(entry.uri)) { - const childContainer = document.createElement("div"); - childContainer.className = "fs-tree-children"; - container.appendChild(childContainer); - await renderFsTreeLevel(childContainer, entry.uri, depth + 1); - } - } -} - -async function renderFsTree() { - elements.fsTree.innerHTML = ""; - await renderFsTreeLevel(elements.fsTree, state.fsCurrentUri, 0); -} - -function setFsViewMode(mode) { - state.fsViewMode = mode; - elements.fsModeListBtn.classList.toggle("active", mode === "list"); - elements.fsModeTreeBtn.classList.toggle("active", mode === "tree"); - elements.fsModeListBtn.setAttribute("aria-pressed", String(mode === "list")); - elements.fsModeTreeBtn.setAttribute("aria-pressed", String(mode === "tree")); - elements.fsTableWrap.hidden = mode === "tree"; - elements.fsTree.hidden = mode === "list"; -} - -async function loadFilesystem(uri, { pushHistory = false } = {}) { - const targetUri = normalizeDirUri(uri); - const payload = await callConsole( - `/ov/fs/ls?uri=${encodeURIComponent(targetUri)}&show_all_hidden=true`, - { method: "GET" } - ); - - if (pushHistory && state.fsCurrentUri !== targetUri) { - state.fsHistory.push(state.fsCurrentUri); - } - - state.fsCurrentUri = targetUri; - elements.fsCurrentUri.value = targetUri; - - const rawEntries = normalizeFsEntries(payload.result, targetUri); - - if (state.fsViewMode === "list") { - const entries = sortFilesystemEntries(rawEntries); - renderFilesystemEntries( - elements.fsEntries, - entries, - async (entry) => { - if (entry.isDir) { - try { - await loadFilesystem(entry.uri, { pushHistory: true }); - } catch (error) { - setOutput(error.message); - } - return; - } - try { - await readFilesystemFile(entry); - } catch (error) { - setOutput(error.message); - } - }, - async (entry) => { - await statFilesystemResource(entry); - } - ); - } else { - rawEntries.sort((a, b) => { - if (a.isDir !== b.isDir) { - return a.isDir ? -1 : 1; - } - return (a.uri || "").localeCompare(b.uri || ""); - }); - state.fsTreeData[targetUri] = rawEntries; - await renderFsTree(); - } - -} - -async function refreshCapabilities() { - try { - const payload = await callConsole("/runtime/capabilities", { method: "GET" }); - state.writeEnabled = Boolean(payload.result?.write_enabled); - elements.writeBadge.textContent = state.writeEnabled ? "Write Enabled" : "Readonly"; - elements.writeBadge.classList.toggle("write", state.writeEnabled); - elements.addResourceSubmitBtn.disabled = !state.writeEnabled; - syncWriteControls(); - renderAccountsTable(); - renderUsersTable(); - } catch (error) { - setOutput(`Failed to load capabilities: ${error.message}`); - } -} - -function bindShellControls() { - const preferDark = window.matchMedia("(prefers-color-scheme: dark)"); - - if (elements.navToggleBtn) { - elements.navToggleBtn.addEventListener("click", () => { - setNavCollapsed(!state.navCollapsed); - }); - } - - if (elements.resultToggleBtn) { - elements.resultToggleBtn.addEventListener("click", () => { - setResultCollapsed(!state.resultCollapsed); - }); - } - - if (elements.clearOutputBtn) { - elements.clearOutputBtn.addEventListener("click", () => { - setOutput(""); - }); - } - - for (const button of elements.themeButtons) { - button.addEventListener("click", () => { - applyThemeMode(button.dataset.themeMode || "dark"); - }); - } - - if (elements.content) { - elements.content.addEventListener("click", () => { - if (window.matchMedia("(max-width: 900px)").matches && !state.navCollapsed) { - setNavCollapsed(true); - } - }); - } - - const onThemeChange = () => { - if (state.themeMode === "system") { - applyThemeMode("system", { persist: false }); - } - }; - if (typeof preferDark.addEventListener === "function") { - preferDark.addEventListener("change", onThemeChange); - } else if (typeof preferDark.addListener === "function") { - preferDark.addListener(onThemeChange); - } -} - -function initShellState() { - const storedTheme = readLocalStorage(THEME_MODE_KEY); - const themeMode = storedTheme === "light" || storedTheme === "system" ? storedTheme : "dark"; - applyThemeMode(themeMode, { persist: false }); - - const storedNav = readLocalStorage(NAV_COLLAPSED_KEY); - const defaultNavCollapsed = - storedNav === "1" || (storedNav === null && window.matchMedia("(max-width: 900px)").matches); - setNavCollapsed(defaultNavCollapsed, { persist: false }); - - const storedResult = readLocalStorage(RESULT_COLLAPSED_KEY); - setResultCollapsed(storedResult === null ? false : storedResult === "1", { persist: false }); -} - -function bindTabs() { - for (const tab of elements.tabs) { - tab.addEventListener("click", () => { - const panel = tab.dataset.panel; - if (!panel) { - return; - } - setActivePanel(panel); - }); - } -} - -function bindConnection() { - const saveConnection = () => { - const nextApiKey = elements.apiKeyInput.value.trim() || state.connectionSettings.apiKey; - state.connectionSettings = saveConsoleSettings(window.localStorage, { - apiKey: nextApiKey, - accountId: elements.accountInput.value, - userId: elements.userInput.value, - agentId: elements.agentIdInput.value, - }); - - elements.accountInput.value = state.connectionSettings.accountId; - elements.userInput.value = state.connectionSettings.userId; - elements.agentIdInput.value = state.connectionSettings.agentId; - - if (state.connectionSettings.apiKey) { - window.sessionStorage.setItem(SESSION_KEY, state.connectionSettings.apiKey); - elements.apiKeyInput.value = ""; - } else { - window.sessionStorage.removeItem(SESSION_KEY); - } - - updateConnectionHint(); - setOutput(nextApiKey ? "Connection settings saved." : "Identity settings saved. API key unchanged."); - return true; - }; - - elements.saveKeyBtn.addEventListener("click", () => { - saveConnection(); - }); - - elements.apiKeyInput.addEventListener("keydown", (event) => { - if (event.key !== "Enter") { - return; - } - event.preventDefault(); - saveConnection(); - }); - - elements.clearKeyBtn.addEventListener("click", () => { - window.sessionStorage.removeItem(SESSION_KEY); - state.connectionSettings = saveConsoleSettings(window.localStorage, {}); - elements.accountInput.value = ""; - elements.userInput.value = ""; - elements.agentIdInput.value = ""; - elements.apiKeyInput.value = ""; - updateConnectionHint(); - setOutput("Connection settings cleared from browser storage."); - }); - - if (elements.getOtpBtn) { - elements.getOtpBtn.addEventListener("click", async () => { - if (!getApiKey()) { - setOutput("Save an API key first — OTP issuance requires authentication."); - return; - } - elements.getOtpBtn.disabled = true; - const originalLabel = elements.getOtpBtn.textContent; - elements.getOtpBtn.textContent = "Generating…"; - try { - const payload = await callConsole("/ov/auth/otp", { - method: "POST", - body: JSON.stringify({}), - }); - const result = payload && payload.result ? payload.result : payload; - const otp = result && (result.otp || result.code); - if (!otp) { - throw new Error("Server did not return an OTP"); - } - elements.otpValue.textContent = otp; - const ttl = result.ttl_seconds || 300; - const expiresAt = result.expires_at - ? new Date(result.expires_at * 1000).toLocaleTimeString() - : null; - elements.otpExpiry.textContent = expiresAt - ? `Expires at ${expiresAt} (${ttl}s). One-time use.` - : `Expires in ${ttl}s. One-time use.`; - elements.otpBox.hidden = false; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setOutput(`Failed to generate OTP: ${message}`); - } finally { - elements.getOtpBtn.disabled = false; - elements.getOtpBtn.textContent = originalLabel; - } - }); - } - - if (elements.otpCopyBtn) { - elements.otpCopyBtn.addEventListener("click", async () => { - const otp = elements.otpValue.textContent.trim(); - if (!otp) { - return; - } - try { - await navigator.clipboard.writeText(otp); - const originalLabel = elements.otpCopyBtn.textContent; - elements.otpCopyBtn.textContent = "Copied"; - setTimeout(() => { - elements.otpCopyBtn.textContent = originalLabel; - }, 1500); - } catch (_error) { - setOutput("Could not copy to clipboard — long-press the code to copy manually."); - } - }); - } - - // OAuth verification (device-flow style: user reads code from the MCP - // client's authorize page and pastes it here). - async function submitOAuthVerify(decision) { - if (!getApiKey()) { - elements.oauthVerifyHint.textContent = - "Save an API key first — verification requires authentication."; - return; - } - const code = (elements.oauthVerifyInput.value || "").trim().toUpperCase(); - if (!code) { - elements.oauthVerifyHint.textContent = "Enter the 6-character code shown on the authorize page."; - return; - } - elements.oauthVerifyBtn.disabled = true; - elements.oauthDenyBtn.disabled = true; - elements.oauthVerifyHint.textContent = decision === "deny" ? "Denying…" : "Authorizing…"; - try { - const payload = await callConsole("/ov/auth/oauth-verify", { - method: "POST", - body: JSON.stringify({ code, decision }), - }); - const result = payload && payload.result ? payload.result : payload; - const status = result && result.status; - if (status === "approved") { - const name = (result && result.client_name) || "the client"; - elements.oauthVerifyHint.textContent = - `Authorized ${name}. Switch back to the client tab — it will continue automatically.`; - elements.oauthVerifyInput.value = ""; - } else if (status === "denied") { - elements.oauthVerifyHint.textContent = "Denied. The client will see an error."; - elements.oauthVerifyInput.value = ""; - } else { - elements.oauthVerifyHint.textContent = - "Server returned an unexpected response — check the result panel."; - setOutput(payload); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - elements.oauthVerifyHint.textContent = `Failed: ${message}`; - } finally { - elements.oauthVerifyBtn.disabled = false; - elements.oauthDenyBtn.disabled = false; - } - } - - if (elements.oauthVerifyBtn) { - elements.oauthVerifyBtn.addEventListener("click", () => submitOAuthVerify("approve")); - } - if (elements.oauthDenyBtn) { - elements.oauthDenyBtn.addEventListener("click", () => submitOAuthVerify("deny")); - } - if (elements.oauthVerifyInput) { - elements.oauthVerifyInput.addEventListener("keydown", (event) => { - if (event.key !== "Enter") { - return; - } - event.preventDefault(); - submitOAuthVerify("approve"); - }); - } -} - -function bindFilesystem() { - bindFilesystemSort(); - updateFilesystemSortHeaders(); - - elements.fsGoBtn.addEventListener("click", async () => { - try { - await loadFilesystem(elements.fsCurrentUri.value, { pushHistory: true }); - } catch (error) { - setOutput(error.message); - } - }); - - elements.fsRefreshBtn.addEventListener("click", async () => { - try { - await loadFilesystem(state.fsCurrentUri); - } catch (error) { - setOutput(error.message); - } - }); - - elements.fsBackBtn.addEventListener("click", async () => { - if (!state.fsHistory.length) { - setOutput("No previous directory."); - return; - } - - const previous = state.fsHistory.pop(); - try { - await loadFilesystem(previous); - } catch (error) { - setOutput(error.message); - } - }); - - elements.fsUpBtn.addEventListener("click", async () => { - const parent = parentUri(state.fsCurrentUri); - if (parent === state.fsCurrentUri) { - setOutput("Already at viking:// root."); - return; - } - - state.fsHistory.push(state.fsCurrentUri); - try { - await loadFilesystem(parent); - } catch (error) { - setOutput(error.message); - } - }); - - elements.fsModeListBtn.addEventListener("click", () => { - setFsViewMode("list"); - loadFilesystem(state.fsCurrentUri).catch((e) => setOutput(e.message)); - }); - - elements.fsModeTreeBtn.addEventListener("click", async () => { - if (state.fsViewMode === "tree") { - // Already in tree mode: toggle all collapse ↔ expand (first level) - if (state.fsTreeExpanded.size > 0) { - state.fsTreeExpanded.clear(); - await renderFsTree(); - } else { - const firstLevel = state.fsTreeData[state.fsCurrentUri] || []; - await Promise.all( - firstLevel - .filter((e) => e.isDir && !state.fsTreeData[e.uri]) - .map(async (e) => { - try { - const payload = await callConsole( - `/ov/fs/ls?uri=${encodeURIComponent(e.uri)}&show_all_hidden=true`, - { method: "GET" } - ); - const children = normalizeFsEntries(payload.result, e.uri); - children.sort((a, b) => { - if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; - return (a.uri || "").localeCompare(b.uri || ""); - }); - state.fsTreeData[e.uri] = children; - } catch (_) {} - }) - ); - for (const entry of firstLevel) { - if (entry.isDir) state.fsTreeExpanded.add(entry.uri); - } - await renderFsTree(); - } - return; - } - setFsViewMode("tree"); - state.fsTreeData = {}; - state.fsTreeExpanded = new Set(); - loadFilesystem(state.fsCurrentUri).catch((e) => setOutput(e.message)); - }); -} - -function bindFind() { - elements.findBtn.addEventListener("click", async () => { - const query = elements.findQuery.value.trim(); - const rawLimit = elements.findLimit.value.trim(); - const parsedLimit = Number.parseInt(rawLimit, 10); - if (!query) { - setOutput("Query cannot be empty."); - return; - } - - try { - const requestBody = { - query, - target_uri: elements.findTarget.value.trim(), - }; - if (Number.isInteger(parsedLimit) && parsedLimit > 0) { - requestBody.limit = parsedLimit; - } - - const payload = await callConsole("/ov/search/find", { - method: "POST", - body: JSON.stringify(requestBody), - }); - - const rows = normalizeFindRows(payload.result); - renderFindTable(rows); - setOutput(payload); - } catch (error) { - setOutput(error.message); - } - }); -} - -function buildAddResourcePayload() { - const payload = { - to: elements.addResourceTarget.value.trim(), - reason: elements.addResourceReason.value.trim(), - instruction: elements.addResourceInstruction.value.trim(), - wait: elements.addResourceWait.checked, - strict: elements.addResourceStrict.checked, - directly_upload_media: elements.addResourceUploadMedia.checked, - }; - - const timeoutRaw = elements.addResourceTimeout.value.trim(); - if (timeoutRaw) { - const timeout = Number.parseFloat(timeoutRaw); - if (Number.isFinite(timeout) && timeout > 0) { - payload.timeout = timeout; - } - } - - const ignoreDirs = elements.addResourceIgnoreDirs.value.trim(); - if (ignoreDirs) { - payload.ignore_dirs = ignoreDirs; - } - - const include = elements.addResourceInclude.value.trim(); - if (include) { - payload.include = include; - } - - const exclude = elements.addResourceExclude.value.trim(); - if (exclude) { - payload.exclude = exclude; - } - - return payload; -} - -function renderAddResourceMode() { - const isPathMode = state.addResourceMode === "path"; - elements.addResourceModePathBtn.classList.toggle("active", isPathMode); - elements.addResourceModeUploadBtn.classList.toggle("active", !isPathMode); - elements.addResourceModePathBtn.setAttribute("aria-selected", String(isPathMode)); - elements.addResourceModeUploadBtn.setAttribute("aria-selected", String(!isPathMode)); - elements.addResourcePathPane.hidden = !isPathMode; - elements.addResourceUploadPane.hidden = isPathMode; -} - -function bindAddResource() { - elements.addResourceModePathBtn.addEventListener("click", () => { - state.addResourceMode = "path"; - renderAddResourceMode(); - }); - - elements.addResourceModeUploadBtn.addEventListener("click", () => { - state.addResourceMode = "upload"; - renderAddResourceMode(); - }); - - elements.addResourceSubmitBtn.addEventListener("click", async () => { - if (!state.writeEnabled) { - setOutput("Write mode is disabled on the server."); - return; - } - - try { - if (state.addResourceMode === "path") { - const path = elements.addResourcePath.value.trim(); - if (!path) { - setOutput("Path cannot be empty."); - return; - } - - const payload = await callConsole("/ov/resources", { - method: "POST", - body: JSON.stringify({ - ...buildAddResourcePayload(), - path, - }), - }); - setOutput(payload); - return; - } - - const file = elements.addResourceFile.files?.[0]; - if (!file) { - setOutput("Please select a file first."); - return; - } - - const formData = new FormData(); - formData.append("file", file); - formData.append("telemetry", "true"); - - setOutput(`Uploading ${file.name} ...`); - const uploadPayload = await callConsole("/ov/resources/temp_upload", { - method: "POST", - body: formData, - }); - const tempFileId = uploadPayload.result?.temp_file_id; - if (!tempFileId) { - throw new Error("Temp upload did not return temp_file_id."); - } - - const addPayload = await callConsole("/ov/resources", { - method: "POST", - body: JSON.stringify({ - ...buildAddResourcePayload(), - temp_file_id: tempFileId, - }), - }); - - setOutput({ - status: "ok", - result: { - upload: uploadPayload.result, - add_resource: addPayload.result, - }, - telemetry: { - upload: uploadPayload.telemetry, - add_resource: addPayload.telemetry, - }, - }); - } catch (error) { - setOutput(error.message); - } - }); -} - -function normalizeArrayResult(result, candidateKeys = []) { - if (Array.isArray(result)) { - return result; - } - if (isRecord(result)) { - for (const key of candidateKeys) { - if (Array.isArray(result[key])) { - return result[key]; - } - } - } - return []; -} - -function normalizeTenantAccount(item) { - if (typeof item === "string") { - const accountId = item.trim(); - return accountId - ? { - accountId, - userCount: null, - raw: item, - } - : null; - } - - if (!isRecord(item)) { - return null; - } - - const accountIdValue = pickFirstNonEmpty([ - item.account_id, - item.accountId, - item.id, - item.name, - item.uri, - ]); - if (accountIdValue === null) { - return null; - } - - return { - accountId: String(accountIdValue), - userCount: pickFirstNonEmpty([item.user_count, item.userCount, item.users, item.member_count]), - raw: item, - }; -} - -function normalizeTenantUser(item) { - if (typeof item === "string") { - const userId = item.trim(); - return userId ? { userId, role: "", raw: item } : null; - } - - if (!isRecord(item)) { - return null; - } - - const userIdValue = pickFirstNonEmpty([item.user_id, item.userId, item.id, item.name]); - if (userIdValue === null) { - return null; - } - - let role = pickFirstNonEmpty([item.role, item.user_role, item.userRole, item.permission, item.permissions]); - if (role === null && typeof item.is_admin === "boolean") { - role = item.is_admin ? "admin" : "member"; - } - - return { - userId: String(userIdValue), - role: role === null ? "" : String(role), - raw: item, - }; -} - -function updateTenantCurrentAccountLabel() { - elements.tenantCurrentAccount.textContent = state.tenantSelectedAccountId - ? `Account: ${state.tenantSelectedAccountId}` - : "No account selected"; -} - -function compareTenantRows(left, right, field) { - const leftValue = isRecord(left) ? left[field] : undefined; - const rightValue = isRecord(right) ? right[field] : undefined; - return compareFindValues(leftValue, rightValue); -} - -function sortTenantRows(rows, field, direction) { - const sorted = [...rows].sort((left, right) => compareTenantRows(left, right, field)); - if (direction === "desc") { - sorted.reverse(); - } - return sorted; -} - -function applyTenantAccountFilter() { - const keyword = elements.tenantAccountSearch.value.trim().toLowerCase(); - state.tenantFilteredAccounts = state.tenantAccounts.filter((account) => - account.accountId.toLowerCase().includes(keyword) - ); -} - -function updateTenantSortButtons(buttons, activeField, direction) { - for (const button of buttons) { - const field = button.dataset.tenantAccountSort || button.dataset.tenantUserSort || ""; - const isActive = field === activeField; - const suffix = !isActive ? "" : direction === "asc" ? " ↑" : " ↓"; - button.textContent = `${field}${suffix}`; - button.setAttribute("aria-sort", isActive ? (direction === "asc" ? "ascending" : "descending") : "none"); - } -} - -function renderAccountsTable() { - if (!elements.tenantAccountsBody) { - return; - } - - elements.tenantAccountsBody.innerHTML = ""; - applyTenantAccountFilter(); - const rows = sortTenantRows( - state.tenantFilteredAccounts, - state.tenantAccountSortField, - state.tenantAccountSortDirection - ); - updateTenantSortButtons( - elements.tenantAccountSortBtns, - state.tenantAccountSortField, - state.tenantAccountSortDirection - ); - - if (!rows.length) { - const tr = document.createElement("tr"); - const td = document.createElement("td"); - td.colSpan = 3; - td.className = "tenant-empty"; - td.textContent = "No accounts"; - tr.appendChild(td); - elements.tenantAccountsBody.appendChild(tr); - return; - } - - for (const account of rows) { - const tr = document.createElement("tr"); - tr.classList.toggle("tenant-row-selected", account.accountId === state.tenantSelectedAccountId); - - const accountCell = document.createElement("td"); - const accountBtn = document.createElement("button"); - accountBtn.type = "button"; - accountBtn.className = "tenant-account-btn"; - accountBtn.textContent = account.accountId; - accountBtn.addEventListener("click", async () => { - state.tenantSelectedAccountId = account.accountId; - updateTenantCurrentAccountLabel(); - renderAccountsTable(); - try { - await loadTenantUsers(account.accountId); - } catch (error) { - setOutput(error.message); - } - }); - accountCell.appendChild(accountBtn); - tr.appendChild(accountCell); - - const countCell = document.createElement("td"); - countCell.textContent = - account.userCount === null || account.userCount === undefined || account.userCount === "" - ? "-" - : String(account.userCount); - tr.appendChild(countCell); - - const actionCell = document.createElement("td"); - const actions = document.createElement("div"); - actions.className = "tenant-actions"; - - const deleteBtn = document.createElement("button"); - deleteBtn.type = "button"; - deleteBtn.className = "danger"; - deleteBtn.textContent = "Delete"; - deleteBtn.disabled = !state.writeEnabled; - deleteBtn.addEventListener("click", (event) => { - event.preventDefault(); - event.stopPropagation(); - void executeTenantAction( - { - title: "Delete account", - message: `Delete account "${account.accountId}" and its tenant users?`, - confirmLabel: `Type ${account.accountId} to confirm`, - confirmToken: account.accountId, - actionLabel: "Delete account", - run: async () => - callConsole(`/ov/admin/accounts/${encodeURIComponent(account.accountId)}`, { - method: "DELETE", - }), - afterSuccess: async () => { - await loadTenantAccounts({ showOutput: false }); - }, - }, - { confirm: true } - ); - }); - actions.appendChild(deleteBtn); - - actionCell.appendChild(actions); - tr.appendChild(actionCell); - elements.tenantAccountsBody.appendChild(tr); - } -} - -function tenantRoleOptions(role) { - const defaults = ["user", "admin"]; - if (role && !defaults.includes(role)) { - defaults.unshift(role); - } - return defaults; -} - -function renderUsersTable() { - if (!elements.tenantUsersBody) { - return; - } - - elements.tenantUsersBody.innerHTML = ""; - updateTenantSortButtons(elements.tenantUserSortBtns, state.tenantUserSortField, state.tenantUserSortDirection); - - if (!state.tenantSelectedAccountId) { - const tr = document.createElement("tr"); - const td = document.createElement("td"); - td.colSpan = 3; - td.className = "tenant-empty"; - td.textContent = "Select an account to view users"; - tr.appendChild(td); - elements.tenantUsersBody.appendChild(tr); - return; - } - - const rows = sortTenantRows(state.tenantUsers, state.tenantUserSortField, state.tenantUserSortDirection); - if (!rows.length) { - const tr = document.createElement("tr"); - const td = document.createElement("td"); - td.colSpan = 3; - td.className = "tenant-empty"; - td.textContent = "No users"; - tr.appendChild(td); - elements.tenantUsersBody.appendChild(tr); - return; - } - - for (const user of rows) { - const tr = document.createElement("tr"); - - const userIdCell = document.createElement("td"); - userIdCell.textContent = user.userId; - tr.appendChild(userIdCell); - - const roleCell = document.createElement("td"); - roleCell.textContent = user.role || "-"; - tr.appendChild(roleCell); - - const actionCell = document.createElement("td"); - const actions = document.createElement("div"); - actions.className = "tenant-actions"; - - const roleSelect = document.createElement("select"); - roleSelect.className = "tenant-role-select"; - for (const optionValue of tenantRoleOptions(user.role)) { - const option = document.createElement("option"); - option.value = optionValue; - option.textContent = optionValue; - option.selected = optionValue === (user.role || "member"); - roleSelect.appendChild(option); - } - actions.appendChild(roleSelect); - - const roleBtn = document.createElement("button"); - roleBtn.type = "button"; - roleBtn.textContent = "Update Role"; - roleBtn.disabled = !state.writeEnabled; - roleBtn.addEventListener("click", () => { - void executeTenantAction({ - title: "Update user role", - message: `Set role for "${user.userId}" under "${state.tenantSelectedAccountId}" to "${roleSelect.value}".`, - confirmLabel: `Type ${state.tenantSelectedAccountId}/${user.userId} to confirm`, - confirmToken: `${state.tenantSelectedAccountId}/${user.userId}`, - actionLabel: "Save role", - run: async () => - callConsole( - `/ov/admin/accounts/${encodeURIComponent(state.tenantSelectedAccountId)}/users/${encodeURIComponent( - user.userId - )}/role`, - { - method: "PUT", - body: JSON.stringify({ role: roleSelect.value }), - } - ), - afterSuccess: async () => { - await loadTenantUsers(state.tenantSelectedAccountId, { showOutput: false }); - }, - }); - }); - actions.appendChild(roleBtn); - - const keyBtn = document.createElement("button"); - keyBtn.type = "button"; - keyBtn.textContent = "Reset API Key"; - keyBtn.disabled = !state.writeEnabled; - keyBtn.addEventListener("click", () => { - void executeTenantAction({ - title: "Reset API key", - message: `Generate a new API key for "${user.userId}" under "${state.tenantSelectedAccountId}".`, - confirmLabel: `Type ${state.tenantSelectedAccountId}/${user.userId} to confirm`, - confirmToken: `${state.tenantSelectedAccountId}/${user.userId}`, - actionLabel: "Reset key", - run: async () => - callConsole( - `/ov/admin/accounts/${encodeURIComponent(state.tenantSelectedAccountId)}/users/${encodeURIComponent( - user.userId - )}/key`, - { method: "POST", body: JSON.stringify({}) } - ), - }); - }); - actions.appendChild(keyBtn); - - const deleteBtn = document.createElement("button"); - deleteBtn.type = "button"; - deleteBtn.className = "danger"; - deleteBtn.textContent = "Remove"; - deleteBtn.disabled = !state.writeEnabled; - deleteBtn.addEventListener("click", () => { - void executeTenantAction( - { - title: "Remove user", - message: `Remove "${user.userId}" from account "${state.tenantSelectedAccountId}".`, - confirmLabel: `Type ${state.tenantSelectedAccountId}/${user.userId} to confirm`, - confirmToken: `${state.tenantSelectedAccountId}/${user.userId}`, - actionLabel: "Remove user", - run: async () => - callConsole( - `/ov/admin/accounts/${encodeURIComponent(state.tenantSelectedAccountId)}/users/${encodeURIComponent( - user.userId - )}`, - { method: "DELETE" } - ), - afterSuccess: async () => { - await loadTenantUsers(state.tenantSelectedAccountId, { showOutput: false }); - }, - }, - { confirm: true } - ); - }); - actions.appendChild(deleteBtn); - - actionCell.appendChild(actions); - tr.appendChild(actionCell); - elements.tenantUsersBody.appendChild(tr); - } -} - -async function loadTenantUsers(accountId, { showOutput = true } = {}) { - if (!accountId) { - state.tenantUsers = []; - updateTenantCurrentAccountLabel(); - renderUsersTable(); - return null; - } - - const payload = await callConsole(`/ov/admin/accounts/${encodeURIComponent(accountId)}/users`, { - method: "GET", - }); - const normalizedUsers = normalizeArrayResult(payload.result, ["users", "items", "results"]) - .map(normalizeTenantUser) - .filter(Boolean); - state.tenantSelectedAccountId = accountId; - state.tenantUsers = normalizedUsers; - updateTenantCurrentAccountLabel(); - renderUsersTable(); - if (showOutput) { - setOutput(payload); - } - return payload; -} - -async function loadTenantAccounts({ showOutput = true } = {}) { - const payload = await callConsole("/ov/admin/accounts", { method: "GET" }); - const normalizedAccounts = normalizeArrayResult(payload.result, ["accounts", "items", "results"]) - .map(normalizeTenantAccount) - .filter(Boolean); - state.tenantAccounts = normalizedAccounts; - state.tenantAccountsLoaded = true; - - const hasSelected = state.tenantSelectedAccountId - ? normalizedAccounts.some((account) => account.accountId === state.tenantSelectedAccountId) - : false; - if (!hasSelected) { - state.tenantSelectedAccountId = normalizedAccounts[0]?.accountId || ""; - } - - renderAccountsTable(); - if (state.tenantSelectedAccountId) { - await loadTenantUsers(state.tenantSelectedAccountId, { showOutput: false }); - } else { - state.tenantUsers = []; - updateTenantCurrentAccountLabel(); - renderUsersTable(); - } - if (showOutput) { - setOutput(payload); - } - return payload; -} - -async function ensureTenantsLoaded() { - if (!state.tenantAccountsLoaded) { - await loadTenantAccounts({ showOutput: false }); - } -} - -function closeTenantConfirmModal() { - elements.tenantConfirmModal.hidden = true; - elements.tenantConfirmInput.value = ""; - elements.tenantConfirmError.hidden = true; - elements.tenantConfirmError.textContent = ""; - state.tenantConfirmRequest = null; -} - -function updateTenantConfirmState() { - const request = state.tenantConfirmRequest; - if (!request) { - return; - } - const expected = request.confirmToken || ""; - const value = elements.tenantConfirmInput.value.trim(); - const valid = !expected || value === expected; - elements.tenantConfirmActionBtn.disabled = !valid; - elements.tenantConfirmError.hidden = true; - elements.tenantConfirmError.textContent = ""; -} - -function openTenantConfirmModal(request) { - state.tenantConfirmRequest = request; - elements.tenantConfirmTitle.textContent = request.title; - elements.tenantConfirmMessage.textContent = request.message; - elements.tenantConfirmLabel.textContent = request.confirmLabel || "Type to confirm"; - elements.tenantConfirmActionBtn.textContent = request.actionLabel || "Confirm"; - elements.tenantConfirmInput.value = ""; - elements.tenantConfirmActionBtn.disabled = true; - elements.tenantConfirmError.hidden = true; - elements.tenantConfirmError.textContent = ""; - elements.tenantConfirmModal.hidden = false; - updateTenantConfirmState(); - elements.tenantConfirmInput.focus(); -} - -async function performTenantAction(request) { - const payload = await request.run(); - if (request.afterSuccess) { - await request.afterSuccess(payload); - } - setOutput(payload); -} - -async function executeTenantAction(request, { confirm = false } = {}) { - if (!state.writeEnabled) { - setOutput("Write mode is disabled on the server."); - return; - } - - if (confirm) { - openTenantConfirmModal(request); - return; - } - - try { - await performTenantAction(request); - } catch (error) { - setOutput(error.message); - } -} - -function bindTenantSortButtons() { - for (const button of elements.tenantAccountSortBtns) { - button.addEventListener("click", () => { - const field = button.dataset.tenantAccountSort; - if (!field) { - return; - } - if (state.tenantAccountSortField === field) { - state.tenantAccountSortDirection = state.tenantAccountSortDirection === "asc" ? "desc" : "asc"; - } else { - state.tenantAccountSortField = field; - state.tenantAccountSortDirection = "asc"; - } - renderAccountsTable(); - }); - } - - for (const button of elements.tenantUserSortBtns) { - button.addEventListener("click", () => { - const field = button.dataset.tenantUserSort; - if (!field) { - return; - } - if (state.tenantUserSortField === field) { - state.tenantUserSortDirection = state.tenantUserSortDirection === "asc" ? "desc" : "asc"; - } else { - state.tenantUserSortField = field; - state.tenantUserSortDirection = "asc"; - } - renderUsersTable(); - }); - } -} - -function bindAddMemory() { - elements.addMemoryBtn.addEventListener("click", async () => { - if (!state.writeEnabled) { - setOutput("Write mode is disabled on the server."); - return; - } - - const text = elements.addMemoryInput.value.trim(); - if (!text) { - setOutput("Please enter content to add as memory."); - return; - } - - let messages; - try { - const parsed = JSON.parse(text); - if (Array.isArray(parsed)) { - messages = parsed; - } else { - messages = [{ role: "user", content: text }]; - } - } catch (_) { - messages = [{ role: "user", content: text }]; - } - - try { - setOutput("Creating session..."); - const sessionPayload = await callConsole("/ov/sessions", { - method: "POST", - body: JSON.stringify({}), - }); - const sessionId = sessionPayload.result?.session_id; - if (!sessionId) { - throw new Error("Failed to create session: no session_id returned."); - } - - for (const msg of messages) { - const payload = { ...msg }; - await callConsole(`/ov/sessions/${sessionId}/messages`, { - method: "POST", - body: JSON.stringify(payload), - }); - } - - setOutput("Committing session..."); - const commitPayload = await callConsole(`/ov/sessions/${sessionId}/commit`, { - method: "POST", - body: JSON.stringify({}), - }); - setOutput(commitPayload); - } catch (error) { - setOutput({ error: error.message }); - } - }); -} - -function bindTenants() { - bindTenantSortButtons(); - renderAccountsTable(); - renderUsersTable(); - updateTenantCurrentAccountLabel(); - - elements.tenantAccountSearch.addEventListener("input", () => { - renderAccountsTable(); - }); - - elements.tenantRefreshAccountsBtn.addEventListener("click", async () => { - try { - await loadTenantAccounts(); - } catch (error) { - setOutput(error.message); - } - }); - - elements.tenantCreateAccountBtn.addEventListener("click", async () => { - const accountId = elements.tenantCreateAccountId.value.trim(); - const adminUserId = elements.tenantCreateAdminUserId.value.trim(); - if (!accountId || !adminUserId) { - setOutput("Please input account_id and first admin user_id."); - return; - } - - await executeTenantAction({ - title: "Create account", - message: `Create account "${accountId}" with initial admin "${adminUserId}".`, - confirmLabel: `Type ${accountId} to confirm`, - confirmToken: accountId, - actionLabel: "Create account", - run: async () => - callConsole("/ov/admin/accounts", { - method: "POST", - body: JSON.stringify({ account_id: accountId, admin_user_id: adminUserId }), - }), - afterSuccess: async () => { - elements.tenantCreateAccountId.value = ""; - await loadTenantAccounts({ showOutput: false }); - }, - }); - }); - - elements.tenantAddUserBtn.addEventListener("click", async () => { - const accountId = state.tenantSelectedAccountId; - const userId = elements.tenantAddUserId.value.trim(); - const role = elements.tenantAddUserRole.value; - if (!accountId) { - setOutput("Select an account before adding users."); - return; - } - if (!userId) { - setOutput("Please input new user_id."); - return; - } - - await executeTenantAction({ - title: "Add user", - message: `Add user "${userId}" to account "${accountId}" with role "${role}".`, - confirmLabel: `Type ${accountId}/${userId} to confirm`, - confirmToken: `${accountId}/${userId}`, - actionLabel: "Add user", - run: async () => - callConsole(`/ov/admin/accounts/${encodeURIComponent(accountId)}/users`, { - method: "POST", - body: JSON.stringify({ user_id: userId, role }), - }), - afterSuccess: async () => { - elements.tenantAddUserId.value = ""; - await loadTenantUsers(accountId, { showOutput: false }); - }, - }); - }); - - elements.tenantConfirmInput.addEventListener("input", () => { - updateTenantConfirmState(); - }); - - elements.tenantConfirmCancelBtn.addEventListener("click", () => { - closeTenantConfirmModal(); - }); - - elements.tenantConfirmModal.addEventListener("click", (event) => { - if (event.target === elements.tenantConfirmModal) { - closeTenantConfirmModal(); - } - }); - - elements.tenantConfirmActionBtn.addEventListener("click", async () => { - const request = state.tenantConfirmRequest; - if (!request) { - return; - } - - const expected = request.confirmToken || ""; - const typed = elements.tenantConfirmInput.value.trim(); - if (expected && typed !== expected) { - elements.tenantConfirmError.hidden = false; - elements.tenantConfirmError.textContent = "Confirmation text mismatch."; - return; - } - - elements.tenantConfirmActionBtn.disabled = true; - try { - await performTenantAction(request); - closeTenantConfirmModal(); - } catch (error) { - closeTenantConfirmModal(); - setOutput(error.message); - } - }); -} - -function bindMonitor() { - elements.systemBtn.addEventListener("click", async () => { - try { - const payload = await callConsole("/ov/system/status", { method: "GET" }); - const rows = Object.entries(payload.result || {}).map(([key, value]) => ({ - label: `${key}: ${typeof value === "string" ? value : JSON.stringify(value)}`, - })); - renderList(elements.monitorResults, rows); - setOutput(payload); - } catch (error) { - setOutput(error.message); - } - }); - - elements.observerBtn.addEventListener("click", async () => { - try { - const payload = await callConsole("/ov/observer/system", { method: "GET" }); - const rows = Object.entries(payload.result?.components || {}).map(([name, value]) => ({ - label: `${name}: ${value?.status || JSON.stringify(value)}`, - })); - renderList(elements.monitorResults, rows); - setOutput(payload); - } catch (error) { - setOutput(error.message); - } - }); -} - -async function init() { - state.connectionSettings = resolveRuntimeConsoleSettings(window.localStorage, getApiKey()); - if (state.connectionSettings.apiKey) { - window.sessionStorage.setItem(SESSION_KEY, state.connectionSettings.apiKey); - } - elements.accountInput.value = state.connectionSettings.accountId; - elements.userInput.value = state.connectionSettings.userId; - elements.agentIdInput.value = state.connectionSettings.agentId; - - initShellState(); - bindShellControls(); - initResizablePanes(); - initFsColumnResize(); - bindTabs(); - bindConnection(); - bindFilesystem(); - bindFind(); - renderFindTable([]); - bindAddResource(); - renderAddResourceMode(); - bindAddMemory(); - bindTenants(); - bindMonitor(); - syncResultEmptyState(); - updateConnectionHint(); - setActivePanel(state.activePanel); - await refreshCapabilities(); - - try { - await loadFilesystem("viking://"); - } catch (error) { - setOutput(error.message); - } -} - -init(); diff --git a/openviking/console/static/apple-touch-icon.png b/openviking/console/static/apple-touch-icon.png deleted file mode 100644 index 60289db06fd81c7936ed32360c86c0978fe4bd75..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 26166 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 LNkvXXu0mjfdl)Nk diff --git a/openviking/console/static/console_settings.js b/openviking/console/static/console_settings.js deleted file mode 100644 index ff023934d..000000000 --- a/openviking/console/static/console_settings.js +++ /dev/null @@ -1,107 +0,0 @@ -const LEGACY_API_KEY_STORAGE_KEY = "ov_console_api_key"; -const CONNECTION_SETTINGS_STORAGE_KEY = "ov_console_connection_settings_v1"; - -function normalizeValue(value) { - if (value === undefined || value === null) { - return ""; - } - return String(value).trim(); -} - -export function normalizeConsoleSettings(settings = {}) { - return { - apiKey: normalizeValue(settings.apiKey), - accountId: normalizeValue(settings.accountId), - userId: normalizeValue(settings.userId), - agentId: normalizeValue(settings.agentId), - }; -} - -export function serializeConsoleSettings(settings = {}) { - const normalized = normalizeConsoleSettings(settings); - const payload = {}; - - for (const [key, value] of Object.entries(normalized)) { - if (value) { - payload[key] = value; - } - } - - return JSON.stringify(payload); -} - -export function parseConsoleSettings(rawValue) { - if (!rawValue) { - return normalizeConsoleSettings(); - } - - try { - const parsed = JSON.parse(rawValue); - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return normalizeConsoleSettings(); - } - return normalizeConsoleSettings(parsed); - } catch (_error) { - return normalizeConsoleSettings(); - } -} - -export function loadConsoleSettings(storage) { - const stored = storage.getItem(CONNECTION_SETTINGS_STORAGE_KEY); - if (stored) { - return parseConsoleSettings(stored); - } - - return normalizeConsoleSettings({ - apiKey: storage.getItem(LEGACY_API_KEY_STORAGE_KEY) || "", - }); -} - -export function resolveRuntimeConsoleSettings(storage, sessionApiKey = "") { - const saved = loadConsoleSettings(storage); - const runtimeApiKey = normalizeValue(sessionApiKey) || saved.apiKey; - return normalizeConsoleSettings({ - ...saved, - apiKey: runtimeApiKey, - }); -} - -export function saveConsoleSettings(storage, settings) { - const normalized = normalizeConsoleSettings(settings); - storage.setItem(CONNECTION_SETTINGS_STORAGE_KEY, serializeConsoleSettings(normalized)); - - if (normalized.apiKey) { - storage.setItem(LEGACY_API_KEY_STORAGE_KEY, normalized.apiKey); - } else { - storage.removeItem(LEGACY_API_KEY_STORAGE_KEY); - } - - return normalized; -} - -export function clearConsoleSettings(storage) { - storage.removeItem(CONNECTION_SETTINGS_STORAGE_KEY); - storage.removeItem(LEGACY_API_KEY_STORAGE_KEY); -} - -export function buildRequestHeaders(baseHeaders = {}, settings = {}) { - const normalized = normalizeConsoleSettings(settings); - const headers = { - ...baseHeaders, - }; - - if (normalized.apiKey) { - headers["X-API-Key"] = normalized.apiKey; - } - if (normalized.accountId) { - headers["X-OpenViking-Account"] = normalized.accountId; - } - if (normalized.userId) { - headers["X-OpenViking-User"] = normalized.userId; - } - if (normalized.agentId) { - headers["X-OpenViking-Agent"] = normalized.agentId; - } - - return headers; -} diff --git a/openviking/console/static/favicon-32.png b/openviking/console/static/favicon-32.png deleted file mode 100644 index 2a1662ed7db0531b115b982a1313b9ba981dfeb6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1823 zcmV+)2jKXLP)dNoT1?eo@!`AeEgSPPavY4C+eo(Q&=8;$EOu1%7&_ zEwcA&_rxkf2wf^KxC8(oAq0Dd>17K5w`pzaikia%U9zMcXbpr5-ELwoB@$dB0N9Ua zm_y+>;3Jg|H9W3m4~NB<;>32^?+>SP97pdI1P2Lo1eX?dk@bObu&x;ehw zRRDDWGYcY5laVJ7iGrDA6&;&OEh}qwTDsZ-`m0GU@zJ4p);>2O=I7bqmI}bxGz%c6 zVK#d*OfXctObW8CS2)j4W|IWZMTAh1SJ|Hs|jJN0~KX}tQ0Pn zWyhY2zq>p*Y+O?8V!FWbyV!qCO~BtZ08N!Z*{B(0Ga`UVfoLXxVpC?ltf`?RV@C7z zG+>fdc(Tglmntj4T&!GkOEK>8AXh^O-Sc7~7YMB7W}tx7+aIUCk8hrx|4tUUwt-K8 zSQTZ}&TV-Qu_$dFZk~jcQH4sKR!Jcye%3L`^wSmz+#2F?5&S8W1Dm*^nbzkfo zoj3K@HkMdUEYBC9vp)j;L0rMAVpSF64u0kgAuPrFs-ItQ*li>&$u6&-G(kW_Vek{< zV&{OkX6verSC;39#WpKI{W*ZakU>+^5Zb$@avmryIc#0Ccg`WZbx; z2zUSpQ+RmuvS&7wXIBq}D7O4N_S|@AysWmN=TJmXetKn|SggmuIQqV(U+bMVr!>Q~ zr^p&eisSd*TiJ{oNjCXudrHReyz$SA0P%Xg+%;zv^WG>tIaHABQ1xk@KK0lG3S&IN zsyEYi?cC5@;51D%sM+vwjMEGXNl83kn5Xi2E`WHP#v7W<$>uiW*O4gIzFk44eLiG% zjRgU)J#spMY}rB#x7*E1DR>ciH$V>%GLr`$pHHZ+tTey>+krnR7OVZerhpcnqO!}G z1V#6#kZRSYQY3oXMuc}UK632wEjeZ5ny|0Fd7zl-H#N=Hk{K^=7y5Tvv| z_97Bt-~I8ev9&#;wj`&ieZvNmvh@QHE*xYUe-aa4w9u)hfpB(Ie z@cFv%I>bdINSZog+q=h%f%n@HxBcS!zQyhm&TZ~GAa!oG_p|I@m;;D}<2gKJQt|a)wH+QC(Z6GA(=;nxH6PpH;)<51 zp$US(lK0jv3O9Nq*R6^G4x5;2l}$%(jzw6R50GmEIMo`5lV|$QJ+e{Wwz)Jv%o^8x z>Mag{3G3ag;_(E3{O0K1Xp}rB%Zk)`rH`hllKEh%3p0{s)N~?PB%YTzfK-bK=)D*8k<3Oqb^fc$o3q2)W%PBTe00%6!;0 z_0`pR*6pE5Sl>Bp+w6X%inh&e#tSin7M`q_dJ`}3vF0V_G{{VeRW~Z};%AEiJ N002ovPDHLkV1kO-ZgBtr diff --git a/openviking/console/static/index.html b/openviking/console/static/index.html deleted file mode 100644 index 346ee6422..000000000 --- a/openviking/console/static/index.html +++ /dev/null @@ -1,398 +0,0 @@ - - - - - - OpenViking Console - - - - - - -
-
-
- -
- OpenViking Console - Control Plane Dashboard -
-
-
- - - UI Ready - - Readonly - -
- - - -
-
-
- -
- - - - -
-
-
-
-

FileSystem

-
-
- - - -
- -
-
- - -
- -
-
- -
- - - - - - - - - - - - -
- - - - - - - - - -
-
- -
- -
-

Find

-
-
- - -
-
- - -
-
- - -
-
- -
- - - -
-
-
- -
-

Add Resource

-
- - -
-
- - -
- - - -
- - - -
- - - - - - - - - - - - -
- -
-

Use the tabs to choose either a server path or a local upload. Result panel will show backend response.

-
- -
-

Add Memory

- - - -
- -
-

Tenants

-
-
-
-

Accounts

-
- - -
-
-
- - - -
-
- - - - - - - - - -
- - - - actions
-
-
- -
-
-

Users

- No account selected -
-
- - - -
-
- - - - - - - - - -
- - - - actions
-
-
-
- -

- Tip: Select an account to load users. Write operations require explicit confirmation. -

- - -
- -
-

Monitor

-
- - -
-
    -
    - -
    -

    Settings

    - - - - - - - - -
    - - -
    -

    No API key in session.

    - -

    Authorize an MCP client

    -

    - Confirm a pending OAuth authorization for Claude.ai / Claude Desktop / - ChatGPT / Cursor. The client shows a 6-character verification code - on its authorize page — paste it here and click Authorize. -

    - - -
    - - -
    -

    - -
    - Generate OTP for legacy push flow -

    - For CLI / scripted clients that drive the older push-style flow: - generate an OTP here, then paste it on the client side. -

    -
    - -
    - -
    -
    -
    - - - -
    -
    -

    Result

    - -
    -
    
    -            
    -
    -
    -
    -
    - - - - diff --git a/openviking/console/static/styles.css b/openviking/console/static/styles.css deleted file mode 100644 index 64cdebd8b..000000000 --- a/openviking/console/static/styles.css +++ /dev/null @@ -1,1489 +0,0 @@ -@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"); - -:root { - color-scheme: dark; - - --sidebar-width: 260px; - --panel-height: 440px; - --panel-min-height: 0px; - --result-min-height: 56px; - --result-max-height: 360px; - - --bg: #12141a; - --bg-accent: #161922; - --bg-elevated: #1c2029; - --bg-hover: #252b37; - - --panel: #151820; - --panel-strong: #1a1f29; - --card: #1b1f28; - --surface-soft: rgba(255, 255, 255, 0.02); - - --text: #e7e8ec; - --text-strong: #fafafa; - --muted: #a0a5b1; - --muted-soft: #7e8697; - - --border: #2f3543; - --border-strong: #474f62; - - --accent: #ff5c5c; - --accent-hover: #ff6f6f; - --accent-subtle: rgba(255, 92, 92, 0.15); - --accent-glow: rgba(255, 92, 92, 0.24); - - --ok: #30c482; - --ok-hover: #49d592; - --warn: #e5af3a; - --danger: #ef5b6f; - - --radius-xs: 6px; - --radius-sm: 8px; - --radius-md: 12px; - --radius-lg: 16px; - --radius-pill: 999px; - - --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.22); - --shadow-md: 0 8px 18px rgba(0, 0, 0, 0.28); - --shadow-lg: 0 18px 34px rgba(0, 0, 0, 0.36); - - --dur-fast: 130ms; - --dur-med: 210ms; - --ease-out: cubic-bezier(0.16, 1, 0.3, 1); -} - -:root[data-theme="light"] { - color-scheme: light; - - --bg: #f6f8fc; - --bg-accent: #eef2f8; - --bg-elevated: #ffffff; - --bg-hover: #e8edf6; - - --panel: #f3f6fb; - --panel-strong: #edf1f9; - --card: #ffffff; - --surface-soft: rgba(12, 24, 44, 0.04); - - --text: #2a3244; - --text-strong: #111828; - --muted: #5a657a; - --muted-soft: #7b8598; - - --border: #d7deeb; - --border-strong: #bcc7db; - - --accent: #dc3e3e; - --accent-hover: #eb4b4b; - --accent-subtle: rgba(220, 62, 62, 0.14); - --accent-glow: rgba(220, 62, 62, 0.2); - - --ok: #198a57; - --ok-hover: #117548; - --warn: #b98516; - --danger: #d63f55; - - --shadow-sm: 0 1px 2px rgba(6, 18, 38, 0.08); - --shadow-md: 0 10px 22px rgba(6, 18, 38, 0.11); - --shadow-lg: 0 18px 38px rgba(6, 18, 38, 0.14); -} - -* { - box-sizing: border-box; -} - -html, -body { - height: 100%; -} - -body { - margin: 0; - font-family: "Space Grotesk", "Segoe UI", "Noto Sans", sans-serif; - color: var(--text); - background: - radial-gradient(1100px 600px at -20% -25%, rgba(255, 92, 92, 0.14), transparent 58%), - radial-gradient(900px 620px at 122% -18%, rgba(255, 163, 96, 0.11), transparent 58%), - linear-gradient(145deg, #10131a 0%, var(--bg) 54%); - -webkit-font-smoothing: antialiased; - text-rendering: optimizeLegibility; -} - -:root[data-theme="light"] body { - background: - radial-gradient(1000px 600px at -22% -22%, rgba(220, 62, 62, 0.09), transparent 60%), - radial-gradient(900px 650px at 120% -20%, rgba(176, 74, 74, 0.08), transparent 60%), - linear-gradient(145deg, #f0f4fa 0%, var(--bg) 55%); -} - -h1, -h2, -h3 { - margin: 0; -} - -p { - margin: 0; -} - -.workspace { - height: 100vh; - display: flex; - flex-direction: column; -} - -@supports (height: 100dvh) { - .workspace { - height: 100dvh; - } -} - -.topbar { - min-height: 60px; - display: flex; - align-items: center; - justify-content: space-between; - gap: 14px; - padding: 12px 18px; - border-bottom: 1px solid var(--border); - background: rgba(18, 20, 26, 0.88); - backdrop-filter: blur(8px); - position: relative; - z-index: 20; -} - -:root[data-theme="light"] .topbar { - background: rgba(246, 248, 252, 0.9); -} - -.topbar-left, -.topbar-right { - display: flex; - align-items: center; - gap: 10px; - min-width: 0; -} - -.topbar-result-toggle { - display: inline-flex; - align-items: center; - justify-content: center; - min-height: 0; - padding: 5px 10px; - border: 1px solid var(--border); - border-radius: var(--radius-pill); - background: var(--panel-strong); - color: var(--muted); - font-size: 11px; - font-weight: 600; - letter-spacing: 0.02em; - text-transform: none; - box-shadow: none; -} - -.topbar-result-toggle:hover { - border-color: var(--border-strong); - background: var(--bg-hover); - color: var(--text-strong); -} - -.brand { - display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; -} - -.brand-title { - font-size: 15px; - letter-spacing: 0.01em; - font-weight: 700; - color: var(--text-strong); - white-space: nowrap; -} - -.brand-subtitle { - font-size: 11px; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--muted-soft); -} - -.icon-btn { - display: inline-flex; - align-items: center; - justify-content: center; - border: 1px solid var(--border); - width: 34px; - height: 34px; - padding: 0; - border-radius: var(--radius-sm); - background: var(--panel-strong); - color: var(--muted); - line-height: 1; - cursor: pointer; - transition: - border-color var(--dur-fast) var(--ease-out), - color var(--dur-fast) var(--ease-out), - background var(--dur-fast) var(--ease-out); -} - -.icon-btn:hover { - color: var(--text-strong); - border-color: var(--border-strong); - background: var(--bg-hover); -} - -.pill, -.badge { - display: inline-flex; - align-items: center; - gap: 7px; - border: 1px solid var(--border); - border-radius: var(--radius-pill); - padding: 6px 11px; - font-size: 12px; - background: var(--panel-strong); - color: var(--muted); -} - -.badge { - font-family: "JetBrains Mono", "SF Mono", Consolas, monospace; -} - -.badge.write { - border-color: rgba(48, 196, 130, 0.4); - color: var(--ok); - background: rgba(48, 196, 130, 0.12); -} - -.badge-soft { - color: var(--muted); - border-color: var(--border); -} - -.statusDot { - width: 7px; - height: 7px; - border-radius: 50%; - background: var(--muted-soft); -} - -.statusDot.ok { - background: var(--ok); - box-shadow: 0 0 0 3px rgba(48, 196, 130, 0.18); -} - -.theme-toggle { - display: inline-flex; - align-items: center; - border: 1px solid var(--border); - border-radius: var(--radius-pill); - padding: 2px; - background: var(--panel-strong); -} - -.theme-btn { - border: 0; - border-radius: var(--radius-pill); - padding: 5px 10px; - font-size: 11px; - letter-spacing: 0.02em; - font-weight: 600; - color: var(--muted); - background: transparent; - cursor: pointer; - transition: - color var(--dur-fast) var(--ease-out), - background var(--dur-fast) var(--ease-out); -} - -.theme-btn.active { - color: #fff; - background: var(--accent); -} - -.shell-body { - flex: 1; - min-height: 0; - display: grid; - grid-template-columns: var(--sidebar-width) 8px minmax(0, 1fr); - transition: grid-template-columns var(--dur-med) var(--ease-out); -} - -.shell.shell--nav-collapsed .shell-body { - grid-template-columns: 0 0 minmax(0, 1fr); -} - -.sidebar { - min-height: 0; - overflow: auto; - padding: 16px 12px; - border-right: 1px solid var(--border); - background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(255, 255, 255, 0)); -} - -:root[data-theme="light"] .sidebar { - background: linear-gradient(180deg, rgba(255, 255, 255, 0.58), rgba(255, 255, 255, 0)); -} - -.shell.shell--nav-collapsed .sidebar { - opacity: 0; - pointer-events: none; - overflow: hidden; -} - -.nav-groups { - display: flex; - flex-direction: column; - gap: 14px; -} - -.nav-group { - display: flex; - flex-direction: column; - gap: 6px; -} - -.nav-group-title { - font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.11em; - color: var(--muted-soft); - padding: 0 8px; -} - -.tab.nav-item { - width: 100%; - text-align: left; - border: 1px solid transparent; - border-radius: var(--radius-md); - padding: 10px 12px; - background: transparent; - color: var(--muted); - cursor: pointer; - font-weight: 600; - letter-spacing: 0.01em; - transition: - color var(--dur-fast) var(--ease-out), - border-color var(--dur-fast) var(--ease-out), - background var(--dur-fast) var(--ease-out), - transform var(--dur-fast) var(--ease-out); -} - -.tab.nav-item:hover { - color: var(--text-strong); - border-color: var(--border); - background: var(--surface-soft); - transform: translateX(1px); -} - -.tab.nav-item.active { - color: var(--text-strong); - border-color: rgba(255, 92, 92, 0.34); - background: linear-gradient(90deg, rgba(255, 92, 92, 0.22), rgba(255, 92, 92, 0.06)); -} - -.resizer { - position: relative; - border: 0; - background: rgba(255, 255, 255, 0.08); - transition: background var(--dur-fast) var(--ease-out); -} - -:root[data-theme="light"] .resizer { - background: rgba(20, 31, 57, 0.12); -} - -.resizer:hover { - background: rgba(255, 92, 92, 0.5); -} - -.resizer-vertical { - cursor: col-resize; -} - -.resizer-horizontal { - height: 8px; - cursor: row-resize; - border-radius: var(--radius-pill); - background: var(--surface-soft); - border: 1px solid transparent; - transition: - background var(--dur-fast) var(--ease-out), - border-color var(--dur-fast) var(--ease-out); -} - -.resizer-horizontal:hover { - background: color-mix(in srgb, var(--surface-soft) 45%, var(--accent-subtle)); - border-color: var(--border); -} - -.content-area { - min-height: 0; - min-width: 0; - padding: 12px; -} - -.content { - height: 100%; - min-height: 0; - display: grid; - align-content: start; - grid-template-rows: - minmax(0, var(--panel-height)) - 8px - minmax(var(--result-min-height), 1fr); - gap: 10px; -} - -.shell.shell--result-collapsed .content { - grid-template-rows: - minmax(0, var(--panel-height)) - 8px - minmax(0, 0); -} - -.shell.shell--result-collapsed .result-card { - display: none; -} - -.shell.shell--result-empty .content { - grid-template-rows: - minmax(0, var(--panel-height)) - minmax(var(--result-min-height), 1fr); -} - -.shell.shell--result-empty #outputResizer { - display: none; -} - -.panel-stack { - min-height: 0; - display: flex; - height: var(--panel-height); - overflow: auto; -} - -.panel { - display: none; - min-height: 0; - overflow: auto; - padding: 14px; - gap: 10px; - border: 1px solid var(--border); - border-radius: var(--radius-lg); - background: linear-gradient(170deg, rgba(255, 255, 255, 0.02), rgba(255, 255, 255, 0.01)); - box-shadow: var(--shadow-md); -} - -.panel.active { - display: flex; - flex-direction: column; - flex: 1; - min-width: 0; -} - -.panel > h2 { - font-size: 12px; - letter-spacing: 0.1em; - text-transform: uppercase; - color: var(--muted-soft); - margin-bottom: 12px; -} - -.row { - display: flex; - align-items: center; - gap: 8px; -} - -.row.wrap { - flex-wrap: wrap; -} - -.checks { - gap: 12px; -} - -.check-item { - display: inline-flex; - align-items: center; - gap: 6px; - color: var(--muted); - font-size: 12px; -} - -label { - font-size: 12px; - color: var(--muted-soft); -} - -.find-controls { - display: grid; - grid-template-columns: minmax(0, 1fr) 120px; - gap: 10px; - align-items: end; -} - -.find-field { - min-width: 0; -} - -.find-field-query { - grid-column: 1 / -1; -} - -.find-field label { - margin-top: 0; -} - -.find-field-limit input { - text-align: center; -} - -input, -textarea, -button, -select, -pre, -.badge, -.tenant-table, -.find-table, -.fs-table { - font-family: "JetBrains Mono", "SF Mono", Menlo, Consolas, monospace; -} - -input, -textarea, -select, -button { - border: 1px solid var(--border); - border-radius: var(--radius-sm); - color: var(--text); - background: var(--bg-elevated); - padding: 8px 10px; - transition: - border-color var(--dur-fast) var(--ease-out), - box-shadow var(--dur-fast) var(--ease-out), - background var(--dur-fast) var(--ease-out), - color var(--dur-fast) var(--ease-out); -} - -input, -textarea, -select { - width: 100%; -} - -textarea { - min-height: 70px; - resize: vertical; -} - -input::placeholder, -textarea::placeholder { - color: var(--muted-soft); -} - -button { - cursor: pointer; - font-weight: 600; - color: var(--text-strong); - background: linear-gradient(180deg, rgba(255, 92, 92, 0.17), rgba(255, 92, 92, 0.08)); - border-color: rgba(255, 92, 92, 0.36); -} - -button:hover { - border-color: rgba(255, 92, 92, 0.56); - background: linear-gradient(180deg, rgba(255, 92, 92, 0.24), rgba(255, 92, 92, 0.11)); -} - -button.ghost { - color: var(--text); - border-color: var(--border); - background: linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.02)); -} - -button.ghost:hover { - border-color: var(--border-strong); - background: var(--bg-hover); -} - -button.danger { - border-color: rgba(239, 91, 111, 0.54); - background: linear-gradient(180deg, rgba(239, 91, 111, 0.27), rgba(239, 91, 111, 0.12)); - color: #ffd7dd; -} - -button.tenant-create-btn { - color: #fff8f8; - border-color: var(--accent); - background: linear-gradient(180deg, var(--accent-hover), var(--accent)); - box-shadow: 0 10px 22px var(--accent-glow); -} - -button.tenant-create-btn:hover { - border-color: var(--accent-hover); - background: linear-gradient(180deg, #ff7a7a, var(--accent-hover)); -} - -:root[data-theme="light"] button.tenant-create-btn { - color: #ffffff; - box-shadow: 0 10px 18px rgba(220, 62, 62, 0.2); -} - -:root[data-theme="light"] button.tenant-create-btn:hover { - background: linear-gradient(180deg, #f05656, var(--accent-hover)); -} - -button:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -button:focus-visible, -input:focus-visible, -textarea:focus-visible, -select:focus-visible, -.tab:focus-visible, -.theme-btn:focus-visible, -.icon-btn:focus-visible { - outline: none; - border-color: var(--accent); - box-shadow: 0 0 0 3px var(--accent-subtle); -} - -.muted { - color: var(--muted); - font-size: 12px; -} - -.list { - margin: 0; - padding: 0; - list-style: none; - border: 1px solid var(--border); - border-radius: var(--radius-md); - overflow: hidden; - background: var(--surface-soft); -} - -.list li { - border-bottom: 1px solid var(--border); -} - -.list li:last-child { - border-bottom: 0; -} - -.list .row-item, -.list button { - width: 100%; - text-align: left; - border: 0; - border-radius: 0; - padding: 10px 12px; - background: transparent; - color: var(--text); -} - -.list button:hover { - background: var(--bg-hover); -} - -.tenant-layout { - display: grid; - gap: 12px; - grid-template-columns: minmax(300px, 1fr) minmax(360px, 1.2fr); - min-height: 0; -} - -.tenant-pane { - min-height: 0; - display: flex; - flex-direction: column; - gap: 8px; - padding: 10px; - border: 1px solid var(--border); - border-radius: var(--radius-md); - background: var(--surface-soft); -} - -.tenant-pane-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; -} - -.tenant-pane h3 { - font-size: 11px; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--muted-soft); -} - -.tenant-inline-form { - align-items: stretch; -} - -.tenant-inline-form > * { - min-width: 0; -} - -.tenant-inline-form input:first-child { - flex: 1.2; -} - -.tenant-inline-form input:nth-child(2), -.tenant-inline-form select { - flex: 1; -} - -.tenant-table-wrap, -.find-table-wrap, -.fs-table-wrap { - min-height: 0; - overflow: auto; - border: 1px solid var(--border); - border-radius: var(--radius-md); - background: rgba(0, 0, 0, 0.08); -} - -:root[data-theme="light"] .tenant-table-wrap, -:root[data-theme="light"] .find-table-wrap, -:root[data-theme="light"] .fs-table-wrap { - background: rgba(255, 255, 255, 0.7); -} - -.tenant-table, -.find-table, -.fs-table { - width: 100%; - border-collapse: collapse; - font-size: 12px; -} - -.tenant-table { - min-width: 620px; -} - -.find-table { - min-width: 720px; -} - -.fs-table { - min-width: 1140px; -} - -.tenant-table th, -.tenant-table td, -.find-table th, -.find-table td, -.fs-table th, -.fs-table td { - border-bottom: 1px solid var(--border); - text-align: left; -} - -.tenant-table th, -.find-table th, -.fs-table th { - position: sticky; - top: 0; - z-index: 1; - background: var(--panel-strong); - padding: 8px 10px; -} - -.tenant-table td, -.find-table td, -.fs-table td { - padding: 9px 10px; - color: var(--text); - vertical-align: top; -} - -.tenant-table tbody tr:last-child td, -.find-table tbody tr:last-child td, -.fs-table tbody tr:last-child td { - border-bottom: 0; -} - -.tenant-sort-btn, -.find-sort-btn, -.fs-sort-btn { - width: 100%; - border: 0; - border-radius: 0; - padding: 0; - background: transparent; - color: var(--muted); - text-align: left; - letter-spacing: 0.03em; - font-size: 12px; - cursor: pointer; -} - -.tenant-sort-btn:hover, -.find-sort-btn:hover, -.fs-sort-btn:hover, -.fs-sort-btn.active { - color: var(--text-strong); -} - -.fs-col-action { - width: 42px; - min-width: 42px; - text-align: center; - padding: 6px 8px !important; -} - -.fs-uri-btn, -.tenant-account-btn { - border: 0; - border-radius: 0; - background: transparent; - color: #ff8e8e; - padding: 0; - text-align: left; - font-weight: 600; -} - -:root[data-theme="light"] .fs-uri-btn, -:root[data-theme="light"] .tenant-account-btn { - color: #b33636; -} - -.fs-uri-btn:hover, -.tenant-account-btn:hover { - color: var(--accent-hover); - text-decoration: underline; -} - -.fs-open-btn { - display: inline-flex; - align-items: center; - justify-content: center; - width: 100%; - border: 0; - border-radius: 0; - background: transparent; - color: var(--ok); - padding: 0; - text-align: left; - font-weight: 700; -} - -.fs-open-btn:hover { - color: var(--ok-hover); - text-decoration: underline; -} - -.fs-col-uri, -.fs-col-size, -.fs-col-dir, -.fs-col-mod-time { - white-space: nowrap; -} - -.fs-col-abstract { - min-width: 300px; -} - -#fsColUri { - width: 360px; - min-width: 220px; -} - -#fsColAction { - width: 42px; - min-width: 42px; - max-width: 42px; - padding: 8px 8px; -} - -#fsColSize { - width: 120px; - min-width: 90px; -} - -#fsColIsDir { - width: 110px; - min-width: 90px; -} - -#fsColModTime { - width: 220px; - min-width: 160px; -} - -#fsColAbstract { - width: 340px; - min-width: 220px; -} - -.fs-col-resizer { - position: absolute; - top: 7px; - right: -4px; - width: 8px; - bottom: 7px; - cursor: col-resize; - z-index: 3; - touch-action: none; -} - -.fs-col-resizer::before { - content: ""; - position: absolute; - left: 3px; - top: 0; - bottom: 0; - width: 2px; - border-radius: var(--radius-pill); - background: rgba(255, 255, 255, 0.3); -} - -:root[data-theme="light"] .fs-col-resizer::before { - background: rgba(26, 42, 74, 0.28); -} - -.fs-col-resizer:hover::before { - background: var(--accent); -} - -.tenant-actions { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 6px; -} - -.tenant-actions button { - padding: 4px 8px; - font-size: 11px; -} - -.tenant-role-select { - max-width: 130px; - padding: 5px 8px; - font-size: 11px; -} - -.tenant-row-selected td { - background: rgba(255, 92, 92, 0.12); -} - -.tenant-empty, -.find-empty, -.fs-empty { - color: var(--muted-soft); -} - -.find-cell-expandable { - min-width: 240px; -} - -.find-col-abstract { - width: 300px; - max-width: 300px; - overflow: hidden; -} - -.find-cell-content { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: 8px; - min-width: 0; - width: 100%; - max-width: 100%; -} - -.find-cell-content.expanded { - align-items: flex-start; -} - -.find-cell-text { - flex: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.find-cell-text.expanded { - overflow: visible; - white-space: normal; - word-break: break-word; -} - -.find-cell-expand-btn { - flex-shrink: 0; - border: 0; - border-radius: 0; - padding: 0 0 0 2px; - background: transparent; - color: var(--muted-soft); - font-size: 10px; - font-weight: 600; - white-space: nowrap; - letter-spacing: 0; - opacity: 0.82; -} - -.find-cell-expand-btn:hover { - color: var(--text); - text-decoration: underline; - opacity: 1; -} - -.tenant-modal { - position: fixed; - inset: 0; - z-index: 40; - display: flex; - align-items: center; - justify-content: center; - padding: 16px; - background: rgba(12, 14, 18, 0.56); - backdrop-filter: blur(2px); -} - -.tenant-modal[hidden] { - display: none; -} - -.tenant-modal-card { - width: min(560px, 100%); - display: flex; - flex-direction: column; - gap: 10px; - padding: 14px; - border: 1px solid var(--border); - border-radius: var(--radius-md); - background: var(--card); - box-shadow: var(--shadow-lg); -} - -.tenant-modal-card h3 { - font-size: 13px; - letter-spacing: 0.06em; - text-transform: uppercase; - color: var(--muted-soft); -} - -.tenant-error { - color: var(--danger); - font-size: 12px; -} - -.result-card { - min-height: calc(var(--result-min-height) + 44px); - height: 100%; - display: flex; - flex-direction: column; - gap: 10px; - overflow: hidden; - padding: 12px; - border: 1px solid var(--border); - border-radius: var(--radius-lg); - background: linear-gradient(160deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0)); - box-shadow: var(--shadow-md); -} - -.result-card--empty { - background: rgba(255, 255, 255, 0.01); -} - -.result-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; -} - -.result-head h2 { - font-size: 12px; - letter-spacing: 0.1em; - text-transform: uppercase; - color: var(--muted-soft); -} - -pre { - margin: 0; - flex: 1 1 auto; - min-height: var(--result-min-height); - padding: 12px; - border-radius: var(--radius-md); - border: 1px solid var(--border); - background: rgba(0, 0, 0, 0.24); - color: var(--text); - overflow: auto; - white-space: pre-wrap; - word-break: break-word; - font-size: 12px; - line-height: 1.5; -} - -.result-card--empty pre { - display: flex; - align-items: center; - justify-content: center; - min-height: var(--result-min-height); - background: transparent; - border-style: dashed; - color: var(--muted-soft); -} - -#output[data-empty="true"]::before { - content: "No result yet."; - letter-spacing: 0.02em; -} - -:root[data-theme="light"] pre { - background: rgba(255, 255, 255, 0.8); -} - -:root[data-theme="light"] .result-card--empty pre { - background: transparent; -} - -body.dragging-sidebar, -body.dragging-sidebar *, -body.dragging-fs-column, -body.dragging-fs-column * { - cursor: col-resize !important; - user-select: none; -} - -body.dragging-output, -body.dragging-output * { - cursor: row-resize !important; - user-select: none; -} - -::-webkit-scrollbar { - width: 9px; - height: 9px; -} - -::-webkit-scrollbar-track { - background: transparent; -} - -::-webkit-scrollbar-thumb { - border-radius: var(--radius-pill); - border: 2px solid transparent; - background: rgba(255, 255, 255, 0.2); -} - -:root[data-theme="light"] ::-webkit-scrollbar-thumb { - background: rgba(28, 40, 63, 0.22); -} - -::-webkit-scrollbar-thumb:hover { - background: rgba(255, 92, 92, 0.55); -} - -.fs-toolbar { - display: flex; - align-items: center; - gap: 6px; - flex-shrink: 0; -} - -.fs-toolbar input { - flex: 1; - width: 0; - min-width: 0; -} - -.fs-toolbar-nav { - display: flex; - align-items: center; - gap: 2px; - flex-shrink: 0; -} - -.fs-nav-btn { - width: 30px; - height: 30px; - padding: 0; - font-size: 15px; - display: flex; - align-items: center; - justify-content: center; - font-family: "Space Grotesk", "Segoe UI", sans-serif; -} - -.fs-toolbar-controls { - display: flex; - align-items: center; - gap: 6px; - flex-shrink: 0; -} - -.fs-view-toggle { - display: flex; - align-items: center; - gap: 0; - border: 1px solid var(--border); - border-radius: var(--radius-sm); - overflow: hidden; -} - -.fs-view-toggle button { - border: 0; - border-radius: 0; - padding: 5px 10px; - font-size: 11px; - background: transparent; - color: var(--muted); -} - -.fs-view-toggle button:hover { - background: var(--bg-hover); - color: var(--text-strong); -} - -.fs-view-toggle button.active { - background: var(--accent); - color: #fff; - border-color: var(--accent); -} - -.add-resource-mode-toggle { - display: flex; - align-items: center; - gap: 4px; - width: 100%; - margin-bottom: 12px; - border: 1px solid var(--border); - border-radius: var(--radius-sm); - padding: 4px; - background: var(--panel); -} - -.add-resource-mode-toggle button { - flex: 1; - border: 1px solid transparent; - border-radius: calc(var(--radius-sm) - 2px); - min-height: 34px; - padding: 7px 12px; - font-size: 12px; - font-weight: 700; - letter-spacing: 0.02em; - background: transparent; - color: var(--muted); - box-shadow: none; -} - -.add-resource-mode-toggle button:hover { - background: var(--bg-hover); - color: var(--text-strong); -} - -.add-resource-mode-toggle button.active { - background: var(--accent); - color: #fff; - border-color: rgba(255, 255, 255, 0.08); - box-shadow: var(--shadow-sm); -} - -.add-resource-input-pane[hidden] { - display: none; -} - -.add-resource-input-pane { - margin-bottom: 4px; -} - -@media (max-width: 1200px) { - .find-controls { - grid-template-columns: minmax(0, 1fr) 120px; - } -} - -@media (max-width: 720px) { - .find-controls { - grid-template-columns: 1fr; - } - - .find-field-query, - .find-field-limit { - grid-column: auto; - } -} - -.fs-tree { - overflow: auto; - border: 1px solid var(--border); - border-radius: var(--radius-md); - background: rgba(0, 0, 0, 0.08); - padding: 6px 0; - min-height: 80px; - flex: 1; -} - -:root[data-theme="light"] .fs-tree { - background: rgba(255, 255, 255, 0.7); -} - -.fs-tree-item { - display: flex; - align-items: center; - gap: 4px; - padding: 3px 10px; - font-size: 12px; - cursor: pointer; - user-select: none; - font-family: "JetBrains Mono", "SF Mono", Menlo, Consolas, monospace; -} - -.fs-tree-item:hover { - background: var(--bg-hover); -} - -.fs-tree-toggle { - flex-shrink: 0; - width: 14px; - color: var(--muted); - font-size: 10px; -} - -.fs-tree-name { - flex: 1; - color: var(--text); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.fs-tree-item--dir .fs-tree-name { - color: #ff8e8e; - font-weight: 600; -} - -:root[data-theme="light"] .fs-tree-item--dir .fs-tree-name { - color: #b33636; -} - -.fs-tree-info-btn { - flex-shrink: 0; - width: 18px; - border: 0; - border-radius: 0; - background: transparent; - color: var(--ok); - padding: 0; - font-size: 12px; - font-weight: 700; - cursor: pointer; - text-align: center; -} - -.fs-tree-info-btn:hover { - color: var(--ok-hover); - text-decoration: underline; -} - -@media (max-width: 900px) { - .topbar { - padding: 10px 12px; - min-height: 56px; - } - - .brand-subtitle { - display: none; - } - - .topbar-right { - gap: 6px; - } - - .pill { - display: none; - } - - .shell-body { - grid-template-columns: minmax(0, 1fr); - } - - .sidebar { - position: fixed; - top: 56px; - left: 0; - bottom: 0; - width: min(82vw, 320px); - border-right: 1px solid var(--border); - transform: translateX(-104%); - transition: transform var(--dur-med) var(--ease-out); - z-index: 35; - background: var(--panel-strong); - box-shadow: var(--shadow-lg); - } - - .shell:not(.shell--nav-collapsed) .sidebar { - transform: translateX(0); - } - - .shell::after { - content: ""; - position: fixed; - inset: 56px 0 0 0; - background: rgba(0, 0, 0, 0.4); - opacity: 0; - pointer-events: none; - transition: opacity var(--dur-med) var(--ease-out); - z-index: 30; - } - - .shell:not(.shell--nav-collapsed)::after { - opacity: 1; - pointer-events: auto; - } - - #sidebarResizer { - display: none; - } - - .content-area { - padding: 10px; - } - - .content { - grid-template-rows: - auto - auto - 6px - auto; - } - - .panel-stack { - max-height: min(var(--panel-height), 38vh); - } - - .tenant-layout { - grid-template-columns: 1fr; - } - - .tenant-inline-form { - flex-wrap: wrap; - } - - .tenant-inline-form button { - width: 100%; - } -} - -@media (prefers-reduced-motion: reduce) { - *, - *::before, - *::after { - animation: none !important; - transition: none !important; - } -} diff --git a/openviking/observability/usage_audit/README.md b/openviking/observability/usage_audit/README.md index 9a0171acf..1c558969b 100644 --- a/openviking/observability/usage_audit/README.md +++ b/openviking/observability/usage_audit/README.md @@ -174,7 +174,6 @@ vector filter,也不从历史写入事件累计当前库存。 - `/favicon.ico` - `/favicon.png` - `/apple-touch-icon.png` -- `/console/*` - `/api/v1/console/*` ## Console BFF API @@ -375,9 +374,7 @@ curl "http://127.0.0.1:1933/api/v1/console/dashboard/summary" \ openviking/observability/events.py \ openviking/observability/usage_audit \ openviking/server/routers/console.py \ - openviking/console/app.py \ - tests/observability \ - tests/misc/test_console_proxy.py + tests/observability ``` ## 常见问题 diff --git a/openviking/observability/usage_audit/projection.py b/openviking/observability/usage_audit/projection.py index 728219a56..6339d5cbc 100644 --- a/openviking/observability/usage_audit/projection.py +++ b/openviking/observability/usage_audit/projection.py @@ -272,11 +272,7 @@ def _project_http_request( def should_skip_audit_route(route: str) -> bool: """Return whether an HTTP route should be omitted from product audit.""" - return ( - route in AUDIT_EXCLUDED_ROUTES - or route.startswith("/console/") - or route.startswith("/api/v1/console/") - ) + return route in AUDIT_EXCLUDED_ROUTES or route.startswith("/api/v1/console/") def retrieval_operation_for_http(method: str, route: str) -> str | None: diff --git a/openviking/server/app.py b/openviking/server/app.py index 35777c9b9..59e6ef5d6 100644 --- a/openviking/server/app.py +++ b/openviking/server/app.py @@ -606,19 +606,11 @@ def _current_role(account_id: str, user_id: str) -> Role: except Exception as e: # noqa: BLE001 logger.warning("Skipping OAuth router registration: %s", e) - # Web Studio SPA: serve the static bundle when present so the same OV - # server origin can host the new frontend at /studio. The directory is - # populated by the docker `web-studio-builder` stage; outside docker, set - # OPENVIKING_WEB_STUDIO_DIR to a local `web-studio/dist` to enable it. - # Favicon assets are bundled with web-studio (see web-studio/public/), - # so the top-level /favicon.* and /mcp/favicon.* routes are served from - # the same dist directory — no separate server-side static folder. - _studio_env = os.environ.get("OPENVIKING_WEB_STUDIO_DIR", "").strip() - if _studio_env: - _studio_dir = Path(_studio_env) - else: - _studio_dir = Path(__file__).resolve().parents[2] / "web-studio" / "dist" - + # Favicon routes — always registered so /favicon.* and /mcp/favicon.* never + # 404, even when web-studio isn't bundled. Source files live in + # openviking/server/static/ (shipped via package-data, ~30KB total) so they + # are available in every pip-install / docker / source-tree scenario. + _server_static_dir = Path(__file__).resolve().parent / "static" _favicon_headers = {"Cache-Control": "public, max-age=86400"} _favicon_files = { "/favicon.ico": ("favicon.ico", "image/x-icon"), @@ -628,21 +620,29 @@ def _current_role(account_id: str, user_id: str) -> Role: "/mcp/favicon.png": ("favicon-32.png", "image/png"), "/mcp/apple-touch-icon.png": ("apple-touch-icon.png", "image/png"), } - _favicon_source = _studio_dir if _studio_dir.is_dir() else None - if _favicon_source is not None and all( - (_favicon_source / fname).is_file() for fname, _ in _favicon_files.values() - ): - def _make_favicon_handler(filename: str, media_type: str): - path = _favicon_source / filename + def _make_favicon_handler(filename: str, media_type: str): + path = _server_static_dir / filename - async def _handler(): - return FileResponse(path, media_type=media_type, headers=_favicon_headers) + async def _handler(): + return FileResponse(path, media_type=media_type, headers=_favicon_headers) - return _handler + return _handler - for _route, (_fname, _mime) in _favicon_files.items(): - app.add_api_route(_route, _make_favicon_handler(_fname, _mime), include_in_schema=False) + for _route, (_fname, _mime) in _favicon_files.items(): + app.add_api_route(_route, _make_favicon_handler(_fname, _mime), include_in_schema=False) + + # Web Studio SPA: serve the static bundle when present so the same OV + # server origin can host the new frontend at /studio. The directory is + # populated by the docker `web-studio-builder` stage and shipped inside + # the openviking python package (see pyproject.toml package-data). Outside + # docker, set OPENVIKING_WEB_STUDIO_DIR to a local `web-studio/dist` to + # enable a dev build without rebuilding the wheel. + _studio_env = os.environ.get("OPENVIKING_WEB_STUDIO_DIR", "").strip() + if _studio_env: + _studio_dir = Path(_studio_env) + else: + _studio_dir = Path(__file__).resolve().parent.parent / "web_studio" / "dist" if _studio_dir.is_dir() and (_studio_dir / "index.html").is_file(): _studio_root = _studio_dir.resolve() diff --git a/openviking/server/static/apple-touch-icon.png b/openviking/server/static/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..5c8385566595bb59d201dea14af28720c907c4d8 GIT binary patch literal 25357 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 literal 0 HcmV?d00001 diff --git a/openviking/server/static/favicon-32.png b/openviking/server/static/favicon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..27c94520b7e7758d14d95c1425554b8e4ba6fa8b GIT binary patch literal 1801 zcmV+k2ln`hP)A8$vMCxtt7xQ{@)h(vLOP2fD8afYsPl`?C{Wz|3EN(DFA+E2>=>8BKN*q zZ}8qfO*}b!#xrMMxXn5m2=l3`Cg#U~+5QAGYm0?g47J3}G(2il7u^ftwBx5|n!~L?pX0sn?Xbolid}J*o5{m)=0Nu3uLs9S} z#j)vUsdC|;5zakmhQ7LHvLk>HrcX>RcD9Vy=`T206Mg4AwLbXV8c z*Q*jqg89zXv1kB~YEw-iNfQ>R;47}2{9Tt_7jGC(j6~(agv|UmTUuMRiW$9pt^gGP z0RYQl3Co1=6s#Dh8oMdqv9fHZqrIuscqPWGKH48m-RC2+?&M>^07fn2!H~^oSibbbjDnhG?0KpKd2BgMG8AQZ;F8=Ov z-++0^?oksJN!~?7Tw4qIa|5U?hM2Ab+RO}C0;F4j-DRJCWm%aR8Zql8C&4jJ<;fC% zqpi3Y)_m=HK>&U~OJzi2&x@@YAgq-H0KnGO6BRX|+&nvbPgA9)-cLbJyM5KpZJ7@d z5eIl~Uki&s>GUT#-nBj@UbYi~O$$=rV5IB7Um992Tws-%WxGS^4hBt(PXSq8+t&dg zX-2$@2VkZ$BJo521!tPqS(u)rsJbr9!4Zf^RKJ2IpT2G#8AN0ydZHsSZo}*w1T0g| z27sXo@LUXc4d5>Y5dn%P?*5e-TC&>(v!h=!ErJy5!uv)38s+tx$T!+<8n155bDUV7 zrJ$`RjGjSW!OT)g2?W6ZGeal~d40*xFT{FWln~?5){k3Im|5xj)V$c%tFGC)YU7pV z>1wXa2~?f~`hup2Phd2+PozIwV0hhb_rM!9p>>8i7zEJH%tS^NIH?DO}S`)fydEzu3Hhb*v1Gmzc zS&ngOPkZ8$2WFk$DFPG-1f*+8B~(UaW}oa|wj|bVOd8^;#~!es1;AMKR>H2G8|s!N zSr(hZs`?}%>83zTj4Wqoy5&qS5RHmxRjoB%*JS=WJSFsR7tv%*4J!c5IROFzmXne| zTegtt^Z6_Q2r$b&pc6!;0N}5wQA&%8t?&P~_s@2R)AN39s~(yVnl~v1Irq2`@6;#a zY~r+wl=nC~bnNjh=>?;@vahnPHx~eEYo~h&Fi&aB+Pn4o1Ca^cC@FSd0)Wa^*2Z

    ;`1WhC3qW+vNCEK9U~m>Nf&0C4={sTCPn_N(PpH>>yW zPTmm+u_h}t^Lm)3GUyXLty~;@pjMv!V- zBHKGg%-#>0nYaDo`o8&g3A%mK0*C=95AC~pEHxqS(ZpDa$~K3RyVNDVwnkG;3y4gK z=CN^prF+mk@y<=HeUlqXa3t&*9E~Pqc(pw55^ZCaS1KyZQ3{45pd)V&RX_jTq;0+m zP=ya-#s}us05eIl#D^@QzW%GG!y`k+cPxEUq$awp$2NGSoaG5@K~Y!&Zru{N(G|Y# zv@3{psqs$Dip@xOC@Zso3>R>!A&Qe{y3akbQQNjTKP$ux%$DB#05cFEK#_@m{MPW^ zsVRC+)9ki}E8QZ_ZL=QC_j2+Qjdg<*hfS7k5)kjOP`E-iRTZMOGirPJ&FeT`-Ey?& z?=Soa_&5x|UqV@#Sjg~C@J~0XBTamNRTo!Y2{E6z!TiEM1^=~?`TQXB`99`zz05rk z=8w9WpDiEc!rf=iV*P911OQng`2GIRY<6^~1+-$OWIk%2`0DCR=l0+@*0&9q>D|Lt ztV1QZRV#eCBhu5@5x&^l9X@&K4|%7V0rs httpx.Response: - assert request.method == "GET" - assert request.url.path == "/api/v1/console/dashboard/summary" - assert request.url.query == b"range=today" - assert request.headers["x-api-key"] == "test-key" - return httpx.Response( - status_code=200, - json={"status": "ok", "result": {"enabled": True}}, - headers={"x-request-id": "req-console"}, - ) - - app = create_console_app( - config=ConsoleConfig(openviking_base_url="http://openviking.test"), - upstream_transport=httpx.MockTransport(handler), - ) - transport = httpx.ASGITransport(app=app) - - async with httpx.AsyncClient(transport=transport, base_url="http://console.test") as client: - response = await client.get( - "/console/api/v1/ov/console/dashboard/summary?range=today", - headers={"x-api-key": "test-key"}, - ) - - assert response.status_code == 200 - assert response.headers["x-request-id"] == "req-console" - assert response.json() == {"status": "ok", "result": {"enabled": True}} - - -@pytest.mark.asyncio -async def test_console_bff_proxy_rejects_path_traversal(): - called = False - - async def handler(request: httpx.Request) -> httpx.Response: - nonlocal called - called = True - return httpx.Response(status_code=500) - - app = create_console_app( - config=ConsoleConfig(openviking_base_url="http://openviking.test"), - upstream_transport=httpx.MockTransport(handler), - ) - transport = httpx.ASGITransport(app=app) - - async with httpx.AsyncClient(transport=transport, base_url="http://console.test") as client: - response = await client.get("/console/api/v1/ov/console/%2e%2e/admin/accounts") - - assert response.status_code == 404 - assert called is False diff --git a/tests/misc/test_console_static_assets.py b/tests/misc/test_console_static_assets.py deleted file mode 100644 index 28de9ea34..000000000 --- a/tests/misc/test_console_static_assets.py +++ /dev/null @@ -1,15 +0,0 @@ -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parents[2] - - -def _read_text(relative_path: str) -> str: - return (REPO_ROOT / relative_path).read_text(encoding="utf-8") - - -def test_console_add_resource_payload_uses_to_field(): - app_js = _read_text("openviking/console/static/app.js") - - assert "function buildAddResourcePayload()" in app_js - assert "to: elements.addResourceTarget.value.trim()," in app_js - assert "target: elements.addResourceTarget.value.trim()," not in app_js diff --git a/web-studio/public/apple-touch-icon.png b/web-studio/public/apple-touch-icon.png index 60289db06fd81c7936ed32360c86c0978fe4bd75..5c8385566595bb59d201dea14af28720c907c4d8 100644 GIT binary patch literal 25357 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 literal 26166 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 LNkvXXu0mjfdl)Nk diff --git a/web-studio/public/favicon-32.png b/web-studio/public/favicon-32.png index 2a1662ed7db0531b115b982a1313b9ba981dfeb6..27c94520b7e7758d14d95c1425554b8e4ba6fa8b 100644 GIT binary patch delta 1785 zcmV;Vz4IS+==kZOyWob#ZHUA=>8BKN*qZ}8qfO*}b!#xrMMxXn5m2=l3`Cg#U~+5QAG zYm0?g47J3}G(2il7u^ftwBx5|n!~L?p zX0sn?Xbolie1BvuBod1P007;z`a@ChBgL`q(5Rj^5K>=?lIwJ1V<=vdByqP8ix!Su z6OrJU#c6Kv?j0%LDq2m)`-0SNPjpw;*Vn5ONrL&#)v;&*k7`p*AxRS!sNgHEocvvv zT^DZ{PmDz6!i3EHH(Od;w2B$Me69c$0098YVhPKH@P8Dn7^fP$Dc`ZOY^S5WsnvKT z#;ZQsA5GooBeL$~W5EDPwbK9*ozp;oX~A@RG+LJCOl4*oo6-+nYY|3xQhVy)d!aZ0 zpP7n9r(h~VtBC-?5UK{G#z`4O#CtCO?sDINdCBfk6BS9`MMPX%3;A;cs4a$=t^(T3 z3|IoBTYrGvWuJa!S(z9bG3zEL!7)zd$r68~t+*K0eC>Kc0DeD9Wkh1ni>(CWfPeD$*ebvrwnGX>W2Y7B@3yVPM^d~vqwLT?YwiAI(3sT=; zr0c+68d@$~V3nC=yF=*?22G4l0a;$#*8w1DMt{7E2VkZ$BJo521!tPqS(u)rsJbr9 z!4Zf^RKJ2IpT2G#8AN0ydZHsSZo}*w1T0g|27sXo@LUXc4d5>Y5dn%P?*5e-TC&>( zv!h=!ErJy5!uv)38s+tx$T!+<8n155bDUV7rJ$`RjGjSW!OT)g2?W6ZGeal~d40*x zFMq^(T$B*w(bkV!P?%Zi`_#PH)~l}Bx@zN<<>_j!%L!DT1Nwrdh)-ZNw@;)$Twr+J zZuh_&HKBEeIT!@c&dfwach|4|05LN?ym{#}8w%4(`-6h({}p*|G&ovNUe$RhY%F|eGd)@@80;;F|Tu%89MSoK!IuALj|mL*vho5HI4BqHgiKunA*XJ@+QOfL|P zifC1>HD1?b{yID*^lul@WK9h#0L(c70s)qjl0aLwkm>XJEC2{F%RZnJM5O@WuYajg zN{frF@Bg;<&vu8?^L}ls9-0uEHz@`=_qY-7)F;1*5vMud=Q; z7XWH&r+W!7Pif5ByY>15kqO->DRy51fXY_Z#&xhSkM#0?gnc8HyS%9_0u~Y6lOk?q zB-_qrCfiOdOSFBM8b_S~aQx$`6@M97_N(PpH>>yWPTmm+u_h}t^Lm)3GUyXLty~;32Z@8SORX{61dS7zV5Uuh;^y)PR)wVNOveJvw#d2 zaH=7SlV`fmJ+e{TwmCm5#0<=q-uwVF5FkL2iGTdo@ZPB@dQQ{qwtt2z-6GCyvmVU% za`F<5b%PX#O_psE5bv;1xI#8n6{58>YJ2(3>o{KBaAjI1ImELRpzu$na0_ zPdBL}O?-b<7gt^hF`u}>{K7v4|Fx0%{2=rBKIU`1%smn2kGh$kEg$5<-Dl2X{cGO@ z09hjV{r=Bvc66r&v~psmWIk%2`0DCR=l0+@*0&9q>D|LttV1QZRV#eCBhu5@5x&^l z9X@&K4|%7V0rs3{&6I(SWnpUk$s@86Rs!}>_Wm}xm7TP8y>*|C-DFntNCQ%G| zr%6nRdNoT1?eo@!`AeEgSP zPavY4C+eo(Q&=8;$EOu1%7&_EwcA&_rxkf2wf^KxC8(oAq0Dd>17K5w`pza zikia%U9zMcXnzfa3*ByFE+rCNA^_NrW|%|aIN&3d4mCWkWe#^C%x_h!CdUUuq)SOx8(UghM2^gWAYT{&yC{hU<+v#mNZD6x zMWwqg+b-VFpBRht7!124-e@#um-!%YD zl|b338GmFmB7jMOXeNMSQ)a!asi7ldM)UMEV3JjMvdZI^Dl5TUtXy+TG4AmoS3?Nh z^I{+u2(0C1pn%ldAE&;LZ=Rk1P8PYgflq)~6=l`VZFvu|D0p#UTilY|ubv$;Qf=!q zQ+b6DK*I!XFc|AS@aNW!3l~sCL|(B7eW8#6lYdcwe3$ffrsJF=Z{h$D3G@Dko(tA& zm$f{{DTtazjo=v2SX{dTZEkJdI-(Oo%yfHvEJ@Xk!61NX@P$l$q>dFZk~jcQH4sKR z!Jcye%3L`^wSmz+#2F?5&S8W1Dm*^nbzkfooj3K@HkMdUEYBC9vp)j;L0rMAVpSF6 z4u5{;3?VGV`>LN`aM*1mEy*sgpEN;0L}Bm~<6`H4xMu6BjaQcEh{ZN5K>ay@!H_{y z(-7Lbrg9!G(_N~n4*%5`UZ)$u5CC+s)MVVaqX>8a2vc}?^Rj0)lxJ5Dg($ZCJNDdo zXuPbpq32LUPkwr3o>;8Mz&QH8reEuwHh-ry!?dT!8c2%c_ugCCj2lTd`DuGf#_+uH z&x-)@dcE8=XBG3_C_Fh-knB+PX`Mdx*aHep+ro0lP-C`gw+$bU*9Jxu5LN6{6z`O4MWhMSH1cW3Ni4ze@Dyba-h z_WHGMW!bW|_97Bt-~I8ev9&#;wirJo$^fAIOb@H)grBS@M$V%xjNjDh#t z5x4!~`o6{P66DiIR;y>2DLl0A>anb}lt55?8XkG2g{ilottQqcI>y-Wq9q{=3d??m8fKZnpQc z>|dAzh=k)gJY-Vw^G(XH5*L>k-G8j&@dSVT=IGvN zlsqTPiqv|gkEW=S`CzFFGm>T0bRt+Jo|ia)REr7aD|kZ`fd+cw(kpLVhvSVMNBjTr z!jAwxih#$%u`UUBEQka=vqkDi8{Xg0i}hE+h$n6!zVI)KfAb@r4Bp+ yw6X%inh&e#tSin7M`q_dJ`}3vF0V_G{{VeRW~Z};%AEiJ00{s|MNUMnLSTY@R&m$>