From 31a3a2e573a8ff20b732c39325c1efacd90fd701 Mon Sep 17 00:00:00 2001 From: "zhoujiahui.01" Date: Sun, 1 Mar 2026 23:43:45 +0800 Subject: [PATCH] feat: add console --- examples/console/README.md | 57 + examples/console/__init__.py | 8 + examples/console/app.py | 288 ++++ examples/console/bootstrap.py | 73 + examples/console/config.py | 84 ++ examples/console/run_console.py | 16 + examples/console/static/app.js | 1976 ++++++++++++++++++++++++++++ examples/console/static/index.html | 263 ++++ examples/console/static/styles.css | 1109 ++++++++++++++++ 9 files changed, 3874 insertions(+) create mode 100644 examples/console/README.md create mode 100644 examples/console/__init__.py create mode 100644 examples/console/app.py create mode 100644 examples/console/bootstrap.py create mode 100644 examples/console/config.py create mode 100644 examples/console/run_console.py create mode 100644 examples/console/static/app.js create mode 100644 examples/console/static/index.html create mode 100644 examples/console/static/styles.css diff --git a/examples/console/README.md b/examples/console/README.md new file mode 100644 index 00000000..4d4f96d5 --- /dev/null +++ b/examples/console/README.md @@ -0,0 +1,57 @@ +# 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 this console example: + +```bash +python examples/console/run_console.py \ + --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**, paste your OpenViking `X-API-Key` and click **Save** (or press Enter). +`X-API-Key` is configured in the web UI Settings panel and stored in browser session storage. + +## 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 examples/console/run_console.py \ + --host 127.0.0.1 \ + --port 8020 \ + --openviking-url http://127.0.0.1:1933 \ + --write-enabled +``` + diff --git a/examples/console/__init__.py b/examples/console/__init__.py new file mode 100644 index 00000000..e9f42e1d --- /dev/null +++ b/examples/console/__init__.py @@ -0,0 +1,8 @@ +# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. +# SPDX-License-Identifier: Apache-2.0 +"""OpenViking console example package.""" + +from .app import create_console_app +from .config import ConsoleConfig, load_console_config + +__all__ = ["create_console_app", "ConsoleConfig", "load_console_config"] diff --git a/examples/console/app.py b/examples/console/app.py new file mode 100644 index 00000000..689b8689 --- /dev/null +++ b/examples/console/app.py @@ -0,0 +1,288 @@ +# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. +# SPDX-License-Identifier: Apache-2.0 +"""FastAPI app for the standalone OpenViking console service.""" + +from __future__ import annotations + +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" + +_ALLOWED_FORWARD_HEADERS = { + "x-api-key", + "authorization", + "x-openviking-account", + "x-openviking-user", + "x-openviking-agent", + "content-type", +} + + +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 + + +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() + 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, + ) + + +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 _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): + return await _forward_request(request, "/api/v1/fs/ls") + + @router.get("/ov/fs/tree") + async def fs_tree(request: Request): + 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): + return await _forward_request(request, f"/api/v1/admin/accounts/{account_id}/users") + + @router.get("/ov/system/status") + async def system_status(request: Request): + return await _forward_request(request, "/api/v1/system/status") + + @router.get("/ov/observer/{component}") + async def observer_component(request: Request, component: str): + return await _forward_request(request, f"/api/v1/observer/{component}") + + # ---- 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 + 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 + 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 + 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 + 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 + return await _forward_request( + request, + f"/api/v1/admin/accounts/{account_id}/users/{user_id}/key", + ) + + 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" + + app = FastAPI( + title="OpenViking Console", + description="Standalone console for OpenViking HTTP APIs", + version="0.1.0", + ) + + 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=["*"], + allow_credentials=True, + ) + + app.include_router(_create_proxy_router()) + + @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 FileResponse(index_file) + + @app.get("/console", include_in_schema=False) + async def index_console(): + return FileResponse(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") + + requested_file = static_dir / path + if requested_file.exists() and requested_file.is_file(): + return FileResponse(requested_file) + return FileResponse(index_file) + + return app diff --git a/examples/console/bootstrap.py b/examples/console/bootstrap.py new file mode 100644 index 00000000..9d3685eb --- /dev/null +++ b/examples/console/bootstrap.py @@ -0,0 +1,73 @@ +# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. +# SPDX-License-Identifier: Apache-2.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, ""}: + sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from examples.console.app import create_console_app +from examples.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=30.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/examples/console/config.py b/examples/console/config.py new file mode 100644 index 00000000..ede3aec5 --- /dev/null +++ b/examples/console/config.py @@ -0,0 +1,84 @@ +# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. +# SPDX-License-Identifier: Apache-2.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/examples/console/run_console.py b/examples/console/run_console.py new file mode 100644 index 00000000..3548d733 --- /dev/null +++ b/examples/console/run_console.py @@ -0,0 +1,16 @@ +# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. +# SPDX-License-Identifier: Apache-2.0 +"""Run the standalone console example service.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +if __package__ in {None, ""}: + sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from examples.console.bootstrap import main + +if __name__ == "__main__": + main() diff --git a/examples/console/static/app.js b/examples/console/static/app.js new file mode 100644 index 00000000..d4744c3f --- /dev/null +++ b/examples/console/static/app.js @@ -0,0 +1,1976 @@ +const API_BASE = "/console/api/v1"; +const SESSION_KEY = "ov_console_api_key"; + +const state = { + activePanel: "filesystem", + writeEnabled: false, + fsCurrentUri: "viking://", + fsHistory: [], + fsSortField: "uri", + fsSortDirection: "asc", + findRows: [], + findSortField: "", + findSortDirection: "asc", + tenantAccounts: [], + tenantFilteredAccounts: [], + tenantUsers: [], + tenantSelectedAccountId: "", + tenantAccountsLoaded: false, + tenantAccountSortField: "account_id", + tenantAccountSortDirection: "asc", + tenantUserSortField: "user_id", + tenantUserSortDirection: "asc", + tenantConfirmRequest: null, +}; + +const elements = { + workspace: document.querySelector(".workspace"), + content: document.querySelector(".content"), + tabsTop: document.querySelector(".tabs-top"), + 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"), + saveKeyBtn: document.getElementById("saveKeyBtn"), + clearKeyBtn: document.getElementById("clearKeyBtn"), + connectionHint: document.getElementById("connectionHint"), + 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"), + fsGoBtn: document.getElementById("fsGoBtn"), + fsCurrentUri: document.getElementById("fsCurrentUri"), + fsEntries: document.getElementById("fsEntries"), + fsSortHeaders: document.querySelectorAll(".fs-sort-btn"), + fsTable: document.querySelector(".fs-table"), + findQuery: document.getElementById("findQuery"), + findTarget: document.getElementById("findTarget"), + findBtn: document.getElementById("findBtn"), + findResultsHead: document.getElementById("findResultsHead"), + findResultsBody: document.getElementById("findResultsBody"), + addResourcePath: document.getElementById("addResourcePath"), + addResourceFile: document.getElementById("addResourceFile"), + 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"), + addResourceBtn: document.getElementById("addResourceBtn"), + addResourceUploadBtn: document.getElementById("addResourceUploadBtn"), + 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"), +}; + +const layoutLimits = { + minSidebar: 200, + maxSidebar: 560, + minPanel: 0, + minResult: 48, +}; + +function setOutput(value) { + const content = typeof value === "string" ? value : JSON.stringify(value, null, 2); + elements.output.textContent = content; +} + +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 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) || ""; +} + +function updateConnectionHint() { + const key = getApiKey(); + elements.connectionHint.textContent = key + ? `API key loaded in session (${key.length} chars).` + : "No API key in session."; +} + +async function callConsole(path, options = {}) { + const headers = { + ...(options.headers || {}), + }; + + if (!(options.body instanceof FormData)) { + headers["Content-Type"] = headers["Content-Type"] || "application/json"; + } + + const apiKey = getApiKey(); + if (apiKey) { + headers["X-API-Key"] = apiKey; + } + + const response = await fetch(`${API_BASE}${path}`, { + ...options, + headers, + }); + + const payload = await response.json().catch(() => ({ + status: "error", + error: { + code: "BAD_RESPONSE", + message: "Invalid JSON response from console", + }, + })); + + if (!response.ok) { + const code = payload.error?.code || "ERROR"; + const message = payload.error?.message || `Request failed with status ${response.status}`; + const missingApiKey = + code === "UNAUTHENTICATED" && 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 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; + td.textContent = formatFindCellValue(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 tabsHeight = elements.tabsTop?.getBoundingClientRect().height || 0; + 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 * 3 : 0; + const maxPanel = Math.max( + layoutLimits.minPanel, + contentHeight - tabsHeight - resizerHeight - layoutLimits.minResult - totalGap + ); + const nextHeight = clamp( + startHeight + (moveEvent.clientY - startY), + layoutLimits.minPanel, + maxPanel + ); + rootStyle.setProperty("--panel-height", `${nextHeight}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); + }); + } +} + +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 entries = sortFilesystemEntries(normalizeFsEntries(payload.result, targetUri)); + + 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); + } + ); + + setOutput(payload); +} + +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.addResourceBtn.disabled = !state.writeEnabled; + elements.addResourceUploadBtn.disabled = !state.writeEnabled; + syncWriteControls(); + renderAccountsTable(); + renderUsersTable(); + } catch (error) { + setOutput(`Failed to load capabilities: ${error.message}`); + } +} + +function bindTabs() { + for (const tab of elements.tabs) { + tab.addEventListener("click", () => setActivePanel(tab.dataset.panel)); + } +} + +function bindConnection() { + const saveApiKey = () => { + const value = elements.apiKeyInput.value.trim(); + if (!value) { + setOutput("API key is empty."); + return false; + } + + window.sessionStorage.setItem(SESSION_KEY, value); + elements.apiKeyInput.value = ""; + updateConnectionHint(); + setOutput("API key saved in browser session storage."); + return true; + }; + + elements.saveKeyBtn.addEventListener("click", () => { + saveApiKey(); + }); + + elements.apiKeyInput.addEventListener("keydown", (event) => { + if (event.key !== "Enter") { + return; + } + event.preventDefault(); + saveApiKey(); + }); + + elements.clearKeyBtn.addEventListener("click", () => { + window.sessionStorage.removeItem(SESSION_KEY); + updateConnectionHint(); + setOutput("API key cleared from browser session."); + }); +} + +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); + } + }); +} + +function bindFind() { + elements.findBtn.addEventListener("click", async () => { + const query = elements.findQuery.value.trim(); + if (!query) { + setOutput("Query cannot be empty."); + return; + } + + try { + const payload = await callConsole("/ov/search/find", { + method: "POST", + body: JSON.stringify({ + query, + target_uri: elements.findTarget.value.trim(), + limit: 10, + }), + }); + + const rows = normalizeFindRows(payload.result); + renderFindTable(rows); + setOutput(payload); + } catch (error) { + setOutput(error.message); + } + }); +} + +function buildAddResourcePayload() { + const payload = { + target: 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 bindAddResource() { + elements.addResourceBtn.addEventListener("click", async () => { + if (!state.writeEnabled) { + setOutput("Write mode is disabled on the server."); + return; + } + + const path = elements.addResourcePath.value.trim(); + if (!path) { + setOutput("Path cannot be empty. Or use Upload & Add."); + return; + } + + try { + const payload = await callConsole("/ov/resources", { + method: "POST", + body: JSON.stringify({ + ...buildAddResourcePayload(), + path, + }), + }); + setOutput(payload); + } catch (error) { + setOutput(error.message); + } + }); + + elements.addResourceUploadBtn.addEventListener("click", async () => { + if (!state.writeEnabled) { + setOutput("Write mode is disabled on the server."); + return; + } + + const file = elements.addResourceFile.files?.[0]; + if (!file) { + setOutput("Please select a file first."); + return; + } + + const formData = new FormData(); + formData.append("file", file); + + try { + setOutput(`Uploading ${file.name} ...`); + const uploadPayload = await callConsole("/ov/resources/temp_upload", { + method: "POST", + body: formData, + }); + const tempPath = uploadPayload.result?.temp_path; + if (!tempPath) { + throw new Error("Temp upload did not return temp_path."); + } + + const addPayload = await callConsole("/ov/resources", { + method: "POST", + body: JSON.stringify({ + ...buildAddResourcePayload(), + temp_path: tempPath, + }), + }); + + setOutput({ + status: "ok", + result: { + upload: uploadPayload.result, + add_resource: addPayload.result, + }, + }); + } 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 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() { + initResizablePanes(); + initFsColumnResize(); + bindTabs(); + bindConnection(); + bindFilesystem(); + bindFind(); + renderFindTable([]); + bindAddResource(); + bindTenants(); + bindMonitor(); + updateConnectionHint(); + setActivePanel("filesystem"); + await refreshCapabilities(); + + try { + await loadFilesystem("viking://"); + } catch (error) { + setOutput(error.message); + } +} + +init(); diff --git a/examples/console/static/index.html b/examples/console/static/index.html new file mode 100644 index 00000000..58472730 --- /dev/null +++ b/examples/console/static/index.html @@ -0,0 +1,263 @@ + + + + + + OpenViking Console + + + +
+
+ + +
+
+

Filesystem

+

Browse viking:// paths.

+
+ + + +
+
+ + +
+
+ + + + + + + + + + + + +
+ + + + + + + + + +
+
+
+ +
+

Find

+

Run semantic find against a scope and inspect matched results.

+ + + + + +
+ + + +
+
+
+ +
+

Add Resource

+

Submit add-resource with local path or upload a file first.

+ + + + + + +
+ + + +
+ + + + + + + + + + + + +
+ + +
+

Use either path or upload. Result panel will show backend response.

+
+ +
+

Tenants

+

Manage accounts and users with guarded write operations.

+
+
+
+

Accounts

+
+ + +
+
+
+ + + +
+
+ + + + + + + + + +
+ + + + actions
+
+
+ +
+
+

Users

+ No account selected +
+
+ + + +
+
+ + + + + + + + + +
+ + + + actions
+
+
+
+ +

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

+ + +
+ +
+

Monitor

+

Check runtime status and observer health snapshots.

+
+ + +
+
    +
    + +
    +

    Settings

    +

    + Configure console session values and set X-API-Key used by all API requests. +

    +
    + Readonly +
    + + +
    + + +
    +

    No API key in session.

    +
    +
    + + + +
    +

    Result

    +
    Ready.
    +
    +
    +
    + + + + diff --git a/examples/console/static/styles.css b/examples/console/static/styles.css new file mode 100644 index 00000000..3aa326a8 --- /dev/null +++ b/examples/console/static/styles.css @@ -0,0 +1,1109 @@ +:root { + color-scheme: light; + --sidebar-width: 280px; + --panel-height: 420px; + --panel-min-height: 0px; + --result-min-height: 48px; + + --bg-0: #f4f8fd; + --bg-1: #ebf2fb; + --bg-2: #dfeaf8; + --surface-1: rgba(255, 255, 255, 0.86); + --surface-2: rgba(250, 253, 255, 0.94); + --surface-3: rgba(245, 250, 255, 0.96); + --line-soft: rgba(61, 100, 139, 0.2); + --line-strong: rgba(44, 112, 173, 0.4); + + --text-1: #152739; + --text-2: #314b63; + --text-3: #607890; + --accent: #1f8fe6; + --accent-strong: #0f79cc; + --danger: #d45a76; + --ok: #2f9b6c; + + --shadow-card: 0 14px 30px rgba(21, 67, 110, 0.09); + --shadow-glow: 0 0 0 1px rgba(31, 143, 230, 0.18), 0 8px 24px rgba(26, 93, 150, 0.16); + + --radius-xs: 8px; + --radius-sm: 10px; + --radius-md: 14px; + --radius-lg: 18px; + + --ease-out: cubic-bezier(0.18, 0.76, 0.29, 1); + --dur-fast: 130ms; + --dur-med: 180ms; +} + +* { + box-sizing: border-box; +} + +html, +body { + min-height: 100%; +} + +body { + margin: 0; + color: var(--text-1); + background: + radial-gradient(1080px 620px at -16% -24%, rgba(125, 181, 232, 0.24), transparent 58%), + radial-gradient(920px 680px at 122% -15%, rgba(127, 168, 228, 0.25), transparent 60%), + linear-gradient(135deg, var(--bg-1) 0%, var(--bg-0) 65%); + font-family: "Segoe UI Variable Text", "SF Pro Text", "Avenir Next", "Noto Sans", "Ubuntu", sans-serif; + letter-spacing: 0.01em; +} + +body::before { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + background-image: + linear-gradient(to bottom, rgba(255, 255, 255, 0.16), rgba(226, 238, 251, 0.35)), + repeating-linear-gradient( + 90deg, + rgba(104, 150, 194, 0.032) 0, + rgba(104, 150, 194, 0.032) 1px, + transparent 1px, + transparent 72px + ); +} + +.topbar { + position: sticky; + top: 0; + z-index: 10; + display: flex; + justify-content: space-between; + align-items: center; + gap: 18px; + padding: 14px 20px; + background: rgba(255, 255, 255, 0.78); + border-bottom: 1px solid var(--line-soft); + backdrop-filter: blur(8px); +} + +.title-block { + min-width: 0; +} + +.topbar-meta { + display: flex; + align-items: center; + gap: 8px; +} + +h1 { + margin: 0; + font-size: 21px; + font-weight: 640; + letter-spacing: 0.02em; +} + +.subtitle { + margin: 3px 0 0; + color: var(--text-3); + font-size: 12px; +} + +h2 { + margin: 0; + font-size: 12px; + font-weight: 650; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-2); +} + +.workspace { + height: 100vh; + min-height: 100vh; + padding: 16px; + overflow: hidden; +} + +.sidebar { + display: flex; + flex-direction: column; + gap: 14px; + padding: 16px; + border-right: 1px solid var(--line-soft); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.7), rgba(236, 245, 253, 0.72)), + linear-gradient(145deg, rgba(120, 175, 228, 0.1), transparent 45%); +} + +.sidebar-card, +.result-card, +.panel { + border-radius: var(--radius-lg); + border: 1px solid var(--line-soft); + background: linear-gradient(165deg, var(--surface-1), var(--surface-3)); + box-shadow: var(--shadow-card); +} + +.sidebar-card { + padding: 14px; +} + +.tabs { + display: flex; + flex-wrap: nowrap; + align-items: flex-end; + gap: 8px; +} + +.tab { + position: relative; + min-width: 112px; + padding: 8px 14px 9px; + border: 1px solid rgba(84, 134, 182, 0.26); + border-bottom: 0; + border-radius: 12px 12px 0 0; + background: linear-gradient(180deg, rgba(241, 249, 255, 0.95), rgba(225, 238, 250, 0.96)); + color: var(--text-2); + text-align: center; + cursor: pointer; + transition: + color var(--dur-fast) var(--ease-out), + border-color var(--dur-fast) var(--ease-out), + transform var(--dur-fast) var(--ease-out), + background var(--dur-fast) var(--ease-out); +} + +.tab::before { + display: none; +} + +.tab:hover { + color: var(--text-1); + border-color: rgba(54, 131, 195, 0.42); + background: linear-gradient(180deg, rgba(247, 252, 255, 0.98), rgba(235, 245, 254, 0.98)); + transform: translateY(0); +} + +.tab.active { + color: #113049; + border-color: rgba(24, 125, 204, 0.5); + background: linear-gradient(180deg, rgba(255, 255, 255, 1), rgba(248, 252, 255, 1)); + box-shadow: 0 -1px 0 rgba(255, 255, 255, 0.9); +} + +.content { + display: grid; + grid-template-rows: + auto + minmax(var(--panel-min-height), var(--panel-height)) + 8px + minmax(var(--result-min-height), 1fr); + gap: 10px; + height: 100%; + min-height: 0; +} + +.tabs-top { + padding: 8px 10px 0; + border-radius: var(--radius-lg) var(--radius-lg) 0 0; + border: 1px solid var(--line-soft); + border-bottom: 0; + overflow-x: auto; + background: linear-gradient(165deg, rgba(255, 255, 255, 0.76), rgba(230, 243, 255, 0.88)); + box-shadow: var(--shadow-card); +} + +.tabs-title { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 0; + align-self: center; + padding: 3px 10px 7px; + border-radius: 999px; + border: 1px solid rgba(46, 111, 170, 0.24); + background: + radial-gradient(130% 180% at 8% 0%, rgba(255, 255, 255, 0.92), rgba(237, 246, 255, 0.82) 46%, rgba(224, 239, 252, 0.88) 100%), + linear-gradient(170deg, rgba(255, 255, 255, 0.72), rgba(233, 244, 255, 0.78)); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.84), + 0 6px 16px rgba(31, 102, 164, 0.12); + color: #254861; + font-size: 12px; + font-weight: 650; + letter-spacing: 0.04em; + text-transform: none; + white-space: nowrap; +} + +.tabs-title-text { + line-height: 1; +} + +.panel-stack { + min-height: 0; + display: flex; + margin-top: -1px; + overflow: hidden; +} + +.panel { + display: none; + min-height: 0; + border-top-left-radius: 0; + border-top-right-radius: 0; + padding: 14px; + gap: 10px; + overflow: auto; +} + +.panel.active { + display: flex; + flex: 1; + flex-direction: column; + animation: panel-in var(--dur-med) var(--ease-out); +} + +.panel-subtitle { + margin: -2px 0 2px; + color: var(--text-3); + font-size: 12px; +} + +@keyframes panel-in { + from { + opacity: 0; + transform: translateY(6px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.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; + font-size: 12px; + color: var(--text-2); + user-select: none; +} + +.check-item input { + width: auto; + margin: 0; +} + +label { + font-size: 12px; + color: var(--text-3); +} + +input, +textarea, +button, +select, +.badge { + font-family: "SF Mono", "Monaco", "Cascadia Mono", "Roboto Mono", monospace; +} + +input, +textarea, +select, +button { + border-radius: var(--radius-sm); + border: 1px solid var(--line-soft); + padding: 8px 10px; + color: var(--text-1); + transition: + border-color var(--dur-fast) var(--ease-out), + box-shadow var(--dur-fast) var(--ease-out), + transform var(--dur-fast) var(--ease-out), + background var(--dur-fast) var(--ease-out); +} + +input { + width: 100%; + background: rgba(255, 255, 255, 0.86); +} + +textarea { + width: 100%; + min-height: 68px; + resize: vertical; + background: rgba(255, 255, 255, 0.86); +} + +select { + width: 100%; + background: rgba(255, 255, 255, 0.9); +} + +input::placeholder { + color: #6e879f; +} + +textarea::placeholder { + color: #6e879f; +} + +button { + background: linear-gradient(160deg, rgba(238, 247, 255, 0.96), rgba(228, 240, 251, 0.98)); + cursor: pointer; +} + +button:hover { + border-color: rgba(62, 142, 201, 0.52); + transform: translateY(-1px); +} + +button.ghost { + background: linear-gradient(160deg, rgba(246, 251, 255, 0.92), rgba(235, 245, 253, 0.94)); +} + +button.danger { + border-color: rgba(211, 103, 129, 0.58); + color: #8f2440; + background: linear-gradient(160deg, rgba(255, 233, 239, 0.95), rgba(255, 243, 246, 0.98)); +} + +button:disabled { + cursor: not-allowed; + opacity: 0.52; + transform: none; +} + +button:focus-visible, +input:focus-visible, +textarea:focus-visible, +select:focus-visible, +.tab:focus-visible { + outline: none; + border-color: var(--accent-strong); + box-shadow: 0 0 0 3px rgba(16, 121, 204, 0.22); +} + +.badge { + font-size: 12px; + color: var(--text-3); + border-radius: 999px; + border: 1px solid var(--line-soft); + padding: 5px 10px; + background: rgba(246, 252, 255, 0.9); +} + +.badge.write { + color: #196f49; + border-color: rgba(47, 155, 108, 0.48); + box-shadow: inset 0 0 14px rgba(47, 155, 108, 0.12); +} + +.badge-soft { + color: var(--text-2); + border-color: rgba(83, 142, 195, 0.34); +} + +.muted { + margin: 0; + color: var(--text-3); + font-size: 12px; +} + +.list { + margin: 0; + padding: 0; + list-style: none; + overflow: hidden; + border-radius: var(--radius-md); + border: 1px solid var(--line-soft); + background: rgba(243, 249, 255, 0.72); +} + +.list li { + border-bottom: 1px solid rgba(91, 134, 176, 0.2); +} + +.list li:last-child { + border-bottom: 0; +} + +.list button, +.list .row-item { + width: 100%; + border: 0; + border-radius: 0; + padding: 10px 12px; + text-align: left; + background: rgba(250, 254, 255, 0.97); + color: #17314b; + font-size: 13px; +} + +.list button:hover { + background: rgba(232, 245, 255, 0.98); +} + +.row-item { + font-family: "SF Mono", "Monaco", "Cascadia Mono", "Roboto Mono", monospace; +} + +.tenant-layout { + display: grid; + grid-template-columns: minmax(280px, 1fr) minmax(340px, 1.25fr); + gap: 12px; + min-height: 0; +} + +.tenant-pane { + min-height: 0; + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px; + border-radius: var(--radius-md); + border: 1px solid var(--line-soft); + background: rgba(248, 252, 255, 0.72); +} + +.tenant-pane h3 { + margin: 0; + font-size: 12px; + font-weight: 650; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-2); +} + +.tenant-pane-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.tenant-inline-form { + align-items: stretch; +} + +.tenant-inline-form > * { + min-width: 0; +} + +.tenant-inline-form input:first-child { + flex: 1.25; +} + +.tenant-inline-form input:nth-child(2), +.tenant-inline-form select { + flex: 1; +} + +.tenant-inline-form button { + white-space: nowrap; +} + +.tenant-table-wrap { + min-height: 0; + overflow: auto; + border-radius: var(--radius-md); + border: 1px solid var(--line-soft); + background: rgba(250, 254, 255, 0.92); +} + +.tenant-table { + width: 100%; + min-width: 620px; + border-collapse: collapse; + font-size: 12px; +} + +.tenant-table th, +.tenant-table td { + padding: 8px 10px; + border-bottom: 1px solid rgba(91, 134, 176, 0.2); + text-align: left; + vertical-align: middle; +} + +.tenant-table thead th { + position: sticky; + top: 0; + z-index: 1; + background: rgba(231, 243, 255, 0.95); +} + +.tenant-table tbody tr:last-child td { + border-bottom: 0; +} + +.tenant-row-selected td { + background: rgba(228, 244, 255, 0.72); +} + +.tenant-sort-btn { + width: 100%; + border: 0; + padding: 0; + text-align: left; + font-size: 12px; + font-weight: 650; + color: var(--text-2); + background: transparent; +} + +.tenant-sort-btn:hover { + color: #0d5f9f; + transform: none; +} + +.tenant-sort-btn:focus-visible, +.tenant-account-btn:focus-visible { + outline: none; + border-radius: 4px; + box-shadow: 0 0 0 3px rgba(16, 121, 204, 0.2); +} + +.tenant-account-btn { + width: 100%; + border: 0; + padding: 0; + background: transparent; + color: #0f4f84; + text-align: left; + font-weight: 650; +} + +.tenant-account-btn:hover { + color: #0b6eb8; + text-decoration: underline; + transform: none; +} + +.tenant-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; +} + +.tenant-actions button { + padding: 5px 8px; + font-size: 11px; +} + +.tenant-actions .danger { + color: #8f2440; +} + +.tenant-role-select { + max-width: 128px; + padding: 5px 8px; + font-size: 11px; +} + +.tenant-empty { + color: var(--text-3); +} + +.tenant-modal { + position: fixed; + inset: 0; + z-index: 25; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + background: rgba(13, 42, 66, 0.22); + 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-radius: var(--radius-md); + border: 1px solid var(--line-soft); + background: linear-gradient(170deg, var(--surface-2), rgba(244, 250, 255, 0.98)); + box-shadow: var(--shadow-card); +} + +.tenant-modal-card h3 { + margin: 0; + font-size: 13px; + font-weight: 650; + color: var(--text-2); + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.tenant-error { + margin: 0; + color: #962748; + font-size: 12px; +} + +.find-table-wrap { + min-height: 0; + overflow: auto; + border-radius: var(--radius-md); + border: 1px solid var(--line-soft); + background: rgba(248, 252, 255, 0.72); +} + +.find-table { + width: 100%; + min-width: 720px; + border-collapse: collapse; + font-size: 12px; +} + +.find-table thead th { + position: sticky; + top: 0; + z-index: 1; + padding: 8px 10px; + border-bottom: 1px solid var(--line-soft); + background: rgba(231, 243, 255, 0.95); +} + +.find-sort-btn { + width: 100%; + border: 0; + padding: 0; + text-align: left; + letter-spacing: 0.03em; + font-size: 12px; + font-weight: 650; + color: var(--text-2); + background: transparent; + cursor: pointer; + white-space: nowrap; +} + +.find-sort-btn:hover { + color: #0d5f9f; + transform: none; +} + +.find-sort-btn:focus-visible { + outline: none; + border-radius: 4px; + box-shadow: 0 0 0 3px rgba(16, 121, 204, 0.2); +} + +.find-table tbody td { + padding: 9px 10px; + border-bottom: 1px solid rgba(91, 134, 176, 0.2); + color: var(--text-1); + vertical-align: top; + white-space: pre-wrap; + word-break: break-word; +} + +.find-table tbody tr:last-child td { + border-bottom: 0; +} + +.find-empty { + color: var(--text-3); +} + +.fs-table-wrap { + min-height: 0; + overflow: auto; + border-radius: var(--radius-md); + border: 1px solid var(--line-soft); + background: rgba(248, 252, 255, 0.72); +} + +.fs-table { + width: 100%; + min-width: 1140px; + border-collapse: collapse; + font-size: 12px; +} + +.fs-table thead th { + position: sticky; + top: 0; + z-index: 1; + overflow: visible; + padding: 8px 12px; + border-bottom: 1px solid var(--line-soft); + background: rgba(231, 243, 255, 0.95); +} + +#fsColUri { + width: 360px; + min-width: 220px; +} + +#fsColAction { + width: 34px; + min-width: 34px; + max-width: 34px; + padding: 8px 4px; + text-align: center; + letter-spacing: 0.02em; + font-size: 11px; + text-transform: uppercase; + color: var(--text-3); +} + +#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-sort-btn { + width: 100%; + border: 0; + padding: 2px 0; + text-align: left; + letter-spacing: 0.04em; + font-size: 12px; + font-weight: 650; + color: var(--text-2); + background: transparent; + cursor: pointer; +} + +.fs-sort-btn:hover { + color: #0d5f9f; + transform: none; +} + +.fs-sort-btn.active { + color: #0d5f9f; +} + +.fs-sort-btn:focus-visible { + outline: none; + border-radius: 4px; + box-shadow: 0 0 0 3px rgba(16, 121, 204, 0.2); +} + +.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: 99px; + background: rgba(87, 139, 188, 0.32); +} + +.fs-col-resizer:hover::before { + background: rgba(19, 126, 204, 0.62); +} + +.fs-table tbody td { + padding: 10px 12px; + border-bottom: 1px solid rgba(91, 134, 176, 0.2); + color: var(--text-1); + vertical-align: middle; +} + +.fs-table tbody tr:last-child td { + border-bottom: 0; +} + +.fs-uri-btn { + width: 100%; + padding: 0; + border: 0; + background: transparent; + color: #0f4f84; + text-align: left; + font-weight: 600; + cursor: pointer; +} + +.fs-uri-btn:hover { + color: #0b6eb8; + text-decoration: underline; + transform: none; +} + +.fs-uri-btn:focus-visible { + outline: none; + border-radius: 4px; + box-shadow: 0 0 0 3px rgba(16, 121, 204, 0.2); +} + +.fs-col-uri { + white-space: nowrap; +} + +.fs-col-action { + width: 34px; + min-width: 34px; + text-align: center; + padding: 6px 4px !important; + color: var(--text-3); +} + +.fs-open-btn { + border: 0; + background: transparent; + padding: 0; + cursor: pointer; + font-size: 14px; + font-weight: 650; + color: #1b6daa; + line-height: 1; + filter: saturate(1.06); +} + +.fs-open-btn:hover { + transform: translateY(-1px); +} + +.fs-open-btn:focus-visible { + outline: none; + border-radius: 4px; + box-shadow: 0 0 0 3px rgba(16, 121, 204, 0.2); +} + +.fs-col-size, +.fs-col-dir, +.fs-col-mod-time { + white-space: nowrap; +} + +.fs-col-abstract { + min-width: 300px; +} + +body.dragging-fs-column, +body.dragging-fs-column * { + cursor: col-resize !important; + user-select: none; +} + +.fs-empty { + color: var(--text-3); +} + +.result-card { + min-height: 0; + display: flex; + flex-direction: column; + gap: 9px; + padding: 14px; + background: linear-gradient(170deg, var(--surface-2), rgba(241, 248, 255, 0.98)); +} + +pre { + flex: 1; + min-height: 0; + margin: 0; + padding: 12px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + border-radius: var(--radius-md); + border: 1px solid var(--line-soft); + background: rgba(254, 255, 255, 0.98); + color: #18314a; + font-size: 12.5px; + line-height: 1.5; + font-family: "SF Mono", "Monaco", "Cascadia Mono", "Roboto Mono", monospace; +} + +.resizer { + position: relative; + border-radius: 999px; + background: linear-gradient(180deg, rgba(133, 170, 207, 0.4), rgba(95, 133, 171, 0.56)); + touch-action: none; + transition: background var(--dur-fast) var(--ease-out), box-shadow var(--dur-fast) var(--ease-out); +} + +.resizer::after { + content: ""; + position: absolute; + inset: 2px; + border-radius: inherit; + border: 1px solid rgba(255, 255, 255, 0.88); +} + +.resizer:hover { + background: linear-gradient(180deg, rgba(80, 171, 231, 0.82), rgba(41, 144, 213, 0.92)); + box-shadow: var(--shadow-glow); +} + +.resizer-vertical { + cursor: col-resize; + margin: 18px 0; +} + +.resizer-horizontal { + cursor: row-resize; +} + +body.dragging-sidebar, +body.dragging-sidebar * { + cursor: col-resize !important; + user-select: none; +} + +body.dragging-output, +body.dragging-output * { + cursor: row-resize !important; + user-select: none; +} + +body.dragging-sidebar #sidebarResizer, +body.dragging-output #outputResizer { + background: linear-gradient(180deg, rgba(52, 159, 230, 0.96), rgba(15, 121, 204, 0.96)); + box-shadow: var(--shadow-glow); +} + +::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +::-webkit-scrollbar-track { + background: rgba(224, 238, 250, 0.6); +} + +::-webkit-scrollbar-thumb { + border-radius: 999px; + border: 2px solid rgba(224, 238, 250, 0.65); + background: rgba(112, 160, 204, 0.52); +} + +::-webkit-scrollbar-thumb:hover { + background: rgba(74, 140, 201, 0.72); +} + +@media (max-width: 900px) { + .workspace { + padding: 10px; + } + + .resizer-vertical, + .resizer-horizontal { + display: none; + } + + .tabs { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: stretch; + } + + .tab { + min-width: 0; + border-bottom: 1px solid rgba(84, 134, 182, 0.26); + border-radius: var(--radius-sm); + padding: 9px 8px; + } + + .tabs-top { + padding: 8px; + border-radius: var(--radius-lg); + border-bottom: 1px solid var(--line-soft); + overflow: hidden; + } + + .tabs-title { + margin-left: 0; + display: inline-flex; + justify-self: end; + align-self: end; + grid-column: 1 / -1; + padding: 2px 6px 0; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; + color: #31536f; + letter-spacing: 0.03em; + text-transform: none; + } + + .tenant-layout { + grid-template-columns: 1fr; + } + + .tenant-inline-form { + flex-wrap: wrap; + } + + .tenant-inline-form button { + width: 100%; + } + + .panel-stack { + margin-top: 0; + } + + .panel { + border-top-left-radius: var(--radius-lg); + border-top-right-radius: var(--radius-lg); + } + + .content { + grid-template-rows: auto minmax(220px, auto) minmax(170px, 240px); + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation: none !important; + transition: none !important; + } +}