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..6b060076 --- /dev/null +++ b/examples/console/app.py @@ -0,0 +1,309 @@ +# 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", + ) + + @router.post("/ov/sessions") + async def create_session(request: Request): + blocked = _ensure_write_enabled(request) + if blocked: + return blocked + return await _forward_request(request, "/api/v1/sessions") + + @router.post("/ov/sessions/{session_id}/messages") + async def add_session_message(request: Request, session_id: str): + blocked = _ensure_write_enabled(request) + if blocked: + return blocked + return await _forward_request(request, f"/api/v1/sessions/{session_id}/messages") + + @router.post("/ov/sessions/{session_id}/commit") + async def commit_session(request: Request, session_id: str): + blocked = _ensure_write_enabled(request) + if blocked: + return blocked + return await _forward_request(request, f"/api/v1/sessions/{session_id}/commit") + + return router + + +def create_console_app( + config: Optional[ConsoleConfig] = None, + upstream_transport: Optional[httpx.AsyncBaseTransport] = None, +) -> FastAPI: + """Create console app instance.""" + if config is None: + config = load_console_config() + + static_dir = Path(__file__).resolve().parent / "static" + index_file = static_dir / "index.html" + + 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..451d5912 --- /dev/null +++ b/examples/console/static/app.js @@ -0,0 +1,2405 @@ +const API_BASE = "/console/api/v1"; +const SESSION_KEY = "ov_console_api_key"; +const THEME_MODE_KEY = "ov_console_theme_mode"; +const NAV_COLLAPSED_KEY = "ov_console_nav_collapsed"; +const RESULT_COLLAPSED_KEY = "ov_console_result_collapsed"; + +const PANEL_META = { + filesystem: { + title: "FileSystem", + subtitle: "Browse viking:// paths.", + }, + find: { + title: "Find", + subtitle: "Run semantic retrieval and inspect matched records.", + }, + "add-resource": { + title: "Add Resource", + subtitle: "Submit path or upload data into viking:// resources.", + }, + "add-memory": { + title: "Add Memory", + subtitle: "Store text or conversations as persistent memories.", + }, + tenants: { + title: "Tenants", + subtitle: "Manage accounts and users with explicit write confirmation.", + }, + monitor: { + title: "Monitor", + subtitle: "Inspect runtime and observer status snapshots.", + }, + settings: { + title: "Settings", + subtitle: "Configure session credentials and connection behavior.", + }, +}; + +const state = { + activePanel: "filesystem", + writeEnabled: false, + fsCurrentUri: "viking://", + fsHistory: [], + fsSortField: "uri", + fsSortDirection: "asc", + fsViewMode: "list", + fsTreeData: {}, + fsTreeExpanded: new Set(), + findRows: [], + findSortField: "", + findSortDirection: "asc", + tenantAccounts: [], + tenantFilteredAccounts: [], + tenantUsers: [], + tenantSelectedAccountId: "", + tenantAccountsLoaded: false, + tenantAccountSortField: "account_id", + tenantAccountSortDirection: "asc", + tenantUserSortField: "user_id", + tenantUserSortDirection: "asc", + tenantConfirmRequest: null, + themeMode: "dark", + navCollapsed: false, + resultCollapsed: false, +}; + +const elements = { + workspace: document.querySelector(".workspace"), + shell: document.querySelector(".shell"), + 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"), + fsModeListBtn: document.getElementById("fsModeListBtn"), + fsModeTreeBtn: document.getElementById("fsModeTreeBtn"), + fsGoBtn: document.getElementById("fsGoBtn"), + fsCurrentUri: document.getElementById("fsCurrentUri"), + fsEntries: document.getElementById("fsEntries"), + fsSortHeaders: document.querySelectorAll(".fs-sort-btn"), + fsTable: document.querySelector(".fs-table"), + fsTableWrap: document.querySelector(".fs-table-wrap"), + fsTree: document.getElementById("fsTree"), + findQuery: document.getElementById("findQuery"), + findTarget: document.getElementById("findTarget"), + 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"), + addMemoryInput: document.getElementById("addMemoryInput"), + addMemoryBtn: document.getElementById("addMemoryBtn"), + tenantAccountSearch: document.getElementById("tenantAccountSearch"), + tenantRefreshAccountsBtn: document.getElementById("tenantRefreshAccountsBtn"), + tenantCreateAccountBtn: document.getElementById("tenantCreateAccountBtn"), + tenantCreateAccountId: document.getElementById("tenantCreateAccountId"), + tenantCreateAdminUserId: document.getElementById("tenantCreateAdminUserId"), + tenantAccountsBody: document.getElementById("tenantAccountsBody"), + tenantCurrentAccount: document.getElementById("tenantCurrentAccount"), + tenantAddUserBtn: document.getElementById("tenantAddUserBtn"), + tenantAddUserId: document.getElementById("tenantAddUserId"), + tenantAddUserRole: document.getElementById("tenantAddUserRole"), + tenantUsersBody: document.getElementById("tenantUsersBody"), + tenantAccountSortBtns: document.querySelectorAll("[data-tenant-account-sort]"), + tenantUserSortBtns: document.querySelectorAll("[data-tenant-user-sort]"), + tenantConfirmModal: document.getElementById("tenantConfirmModal"), + tenantConfirmTitle: document.getElementById("tenantConfirmTitle"), + tenantConfirmMessage: document.getElementById("tenantConfirmMessage"), + tenantConfirmLabel: document.getElementById("tenantConfirmLabel"), + tenantConfirmInput: document.getElementById("tenantConfirmInput"), + tenantConfirmError: document.getElementById("tenantConfirmError"), + tenantConfirmActionBtn: document.getElementById("tenantConfirmActionBtn"), + tenantConfirmCancelBtn: document.getElementById("tenantConfirmCancelBtn"), + systemBtn: document.getElementById("systemBtn"), + observerBtn: document.getElementById("observerBtn"), + monitorResults: document.getElementById("monitorResults"), + navToggleBtn: document.getElementById("navToggleBtn"), + resultToggleBtn: document.getElementById("resultToggleBtn"), + clearOutputBtn: document.getElementById("clearOutputBtn"), + themeButtons: document.querySelectorAll("[data-theme-mode]"), + contentTitle: document.querySelector(".title-block h1"), + contentSubtitle: document.querySelector(".title-block .subtitle"), +}; + +const layoutLimits = { + minSidebar: 200, + maxSidebar: 560, + minPanel: 0, + minResult: 48, +}; + +function readLocalStorage(key) { + try { + return window.localStorage.getItem(key); + } catch (_error) { + return null; + } +} + +function writeLocalStorage(key, value) { + try { + window.localStorage.setItem(key, value); + } catch (_error) { + // Ignore storage failures in private mode or restricted browsers. + } +} + +function prefersDarkTheme() { + return window.matchMedia("(prefers-color-scheme: dark)").matches; +} + +function resolveThemeMode(mode) { + if (mode === "light") { + return "light"; + } + if (mode === "system") { + return prefersDarkTheme() ? "dark" : "light"; + } + return "dark"; +} + +function updateThemeButtons() { + for (const button of elements.themeButtons) { + const selected = button.dataset.themeMode === state.themeMode; + button.classList.toggle("active", selected); + button.setAttribute("aria-pressed", selected ? "true" : "false"); + } +} + +function applyThemeMode(mode, { persist = true } = {}) { + const normalized = mode === "light" || mode === "system" ? mode : "dark"; + state.themeMode = normalized; + const resolved = resolveThemeMode(normalized); + document.documentElement.setAttribute("data-theme", resolved); + updateThemeButtons(); + if (persist) { + writeLocalStorage(THEME_MODE_KEY, normalized); + } +} + +function applyShellStateClasses() { + if (!elements.shell) { + return; + } + elements.shell.classList.toggle("shell--nav-collapsed", state.navCollapsed); + elements.shell.classList.toggle("shell--result-collapsed", state.resultCollapsed); +} + +function setNavCollapsed(collapsed, { persist = true } = {}) { + state.navCollapsed = Boolean(collapsed); + applyShellStateClasses(); + if (persist) { + writeLocalStorage(NAV_COLLAPSED_KEY, state.navCollapsed ? "1" : "0"); + } +} + +function setResultCollapsed(collapsed, { persist = true } = {}) { + state.resultCollapsed = Boolean(collapsed); + applyShellStateClasses(); + if (elements.resultToggleBtn) { + elements.resultToggleBtn.textContent = state.resultCollapsed ? "Show Result" : "Hide Result"; + } + if (persist) { + writeLocalStorage(RESULT_COLLAPSED_KEY, state.resultCollapsed ? "1" : "0"); + } +} + +function 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}`); + } + + const meta = PANEL_META[panel]; + if (meta && elements.contentTitle && elements.contentSubtitle) { + elements.contentTitle.textContent = `OpenViking ${meta.title}`; + elements.contentSubtitle.textContent = meta.subtitle; + } + + if (window.matchMedia("(max-width: 900px)").matches) { + setNavCollapsed(true); + } + + // If a confirmation dialog was left open, never carry it across panel switches. + if (elements.tenantConfirmModal && !elements.tenantConfirmModal.hidden) { + closeTenantConfirmModal(); + } + + if (panel === "tenants") { + ensureTenantsLoaded().catch((error) => { + setOutput(error.message); + }); + } +} + +function getApiKey() { + return window.sessionStorage.getItem(SESSION_KEY) || ""; +} + +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); + }); + } +} + +function buildFsTreeItem(entry, depth) { + const uriStr = entry.uri || ""; + const trimmed = uriStr.replace(/\/$/, ""); + const lastSlash = trimmed.lastIndexOf("/"); + const displayName = lastSlash >= 0 ? trimmed.slice(lastSlash + 1) || trimmed : trimmed; + + const item = document.createElement("div"); + item.className = `fs-tree-item${entry.isDir ? " fs-tree-item--dir" : ""}`; + item.style.paddingLeft = `${10 + depth * 16}px`; + + // ⓘ button — leftmost, matches list view action column + const infoBtn = document.createElement("button"); + infoBtn.type = "button"; + infoBtn.className = "fs-tree-info-btn"; + infoBtn.textContent = "ⓘ"; + infoBtn.title = "Show stat info"; + infoBtn.setAttribute("aria-label", `Show stat info for ${uriStr}`); + infoBtn.addEventListener("click", async (event) => { + event.stopPropagation(); + try { + await statFilesystemResource(entry); + } catch (error) { + setOutput(error.message); + } + }); + item.appendChild(infoBtn); + + // collapse/expand arrow (dirs only; files get a fixed-width placeholder) + const toggle = document.createElement("span"); + toggle.className = "fs-tree-toggle"; + toggle.setAttribute("aria-hidden", "true"); + toggle.textContent = entry.isDir ? (state.fsTreeExpanded.has(entry.uri) ? "▼" : "▶") : ""; + item.appendChild(toggle); + + const name = document.createElement("span"); + name.className = "fs-tree-name"; + name.textContent = displayName; + name.title = uriStr; + item.appendChild(name); + + item.addEventListener("click", async () => { + if (entry.isDir) { + if (state.fsTreeExpanded.has(entry.uri)) { + state.fsTreeExpanded.delete(entry.uri); + await renderFsTree(); + } else if (state.fsTreeData[entry.uri]) { + state.fsTreeExpanded.add(entry.uri); + await renderFsTree(); + } else { + try { + const payload = await callConsole( + `/ov/fs/ls?uri=${encodeURIComponent(entry.uri)}&show_all_hidden=true`, + { method: "GET" } + ); + const children = normalizeFsEntries(payload.result, entry.uri); + children.sort((a, b) => { + if (a.isDir !== b.isDir) { + return a.isDir ? -1 : 1; + } + return (a.uri || "").localeCompare(b.uri || ""); + }); + state.fsTreeData[entry.uri] = children; + state.fsTreeExpanded.add(entry.uri); + await renderFsTree(); + } catch (error) { + setOutput(error.message); + } + } + } else { + try { + await readFilesystemFile(entry); + } catch (error) { + setOutput(error.message); + } + } + }); + + return item; +} + +async function renderFsTreeLevel(container, uri, depth) { + const entries = state.fsTreeData[uri] || []; + for (const entry of entries) { + const item = buildFsTreeItem(entry, depth); + container.appendChild(item); + if (entry.isDir && state.fsTreeExpanded.has(entry.uri)) { + const childContainer = document.createElement("div"); + childContainer.className = "fs-tree-children"; + container.appendChild(childContainer); + await renderFsTreeLevel(childContainer, entry.uri, depth + 1); + } + } +} + +async function renderFsTree() { + elements.fsTree.innerHTML = ""; + await renderFsTreeLevel(elements.fsTree, state.fsCurrentUri, 0); +} + +function setFsViewMode(mode) { + state.fsViewMode = mode; + elements.fsModeListBtn.classList.toggle("active", mode === "list"); + elements.fsModeTreeBtn.classList.toggle("active", mode === "tree"); + elements.fsModeListBtn.setAttribute("aria-pressed", String(mode === "list")); + elements.fsModeTreeBtn.setAttribute("aria-pressed", String(mode === "tree")); + elements.fsTableWrap.hidden = mode === "tree"; + elements.fsTree.hidden = mode === "list"; +} + +async function loadFilesystem(uri, { pushHistory = false } = {}) { + const targetUri = normalizeDirUri(uri); + const payload = await callConsole( + `/ov/fs/ls?uri=${encodeURIComponent(targetUri)}&show_all_hidden=true`, + { method: "GET" } + ); + + if (pushHistory && state.fsCurrentUri !== targetUri) { + state.fsHistory.push(state.fsCurrentUri); + } + + state.fsCurrentUri = targetUri; + elements.fsCurrentUri.value = targetUri; + + const rawEntries = normalizeFsEntries(payload.result, targetUri); + + if (state.fsViewMode === "list") { + const entries = sortFilesystemEntries(rawEntries); + renderFilesystemEntries( + elements.fsEntries, + entries, + async (entry) => { + if (entry.isDir) { + try { + await loadFilesystem(entry.uri, { pushHistory: true }); + } catch (error) { + setOutput(error.message); + } + return; + } + try { + await readFilesystemFile(entry); + } catch (error) { + setOutput(error.message); + } + }, + async (entry) => { + await statFilesystemResource(entry); + } + ); + } else { + rawEntries.sort((a, b) => { + if (a.isDir !== b.isDir) { + return a.isDir ? -1 : 1; + } + return (a.uri || "").localeCompare(b.uri || ""); + }); + state.fsTreeData[targetUri] = rawEntries; + await renderFsTree(); + } + + 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 bindShellControls() { + const preferDark = window.matchMedia("(prefers-color-scheme: dark)"); + + if (elements.navToggleBtn) { + elements.navToggleBtn.addEventListener("click", () => { + setNavCollapsed(!state.navCollapsed); + }); + } + + if (elements.resultToggleBtn) { + elements.resultToggleBtn.addEventListener("click", () => { + setResultCollapsed(!state.resultCollapsed); + }); + } + + if (elements.clearOutputBtn) { + elements.clearOutputBtn.addEventListener("click", () => { + setOutput("Ready."); + }); + } + + for (const button of elements.themeButtons) { + button.addEventListener("click", () => { + applyThemeMode(button.dataset.themeMode || "dark"); + }); + } + + if (elements.content) { + elements.content.addEventListener("click", () => { + if (window.matchMedia("(max-width: 900px)").matches && !state.navCollapsed) { + setNavCollapsed(true); + } + }); + } + + const onThemeChange = () => { + if (state.themeMode === "system") { + applyThemeMode("system", { persist: false }); + } + }; + if (typeof preferDark.addEventListener === "function") { + preferDark.addEventListener("change", onThemeChange); + } else if (typeof preferDark.addListener === "function") { + preferDark.addListener(onThemeChange); + } +} + +function initShellState() { + const storedTheme = readLocalStorage(THEME_MODE_KEY); + const themeMode = storedTheme === "light" || storedTheme === "system" ? storedTheme : "dark"; + applyThemeMode(themeMode, { persist: false }); + + const storedNav = readLocalStorage(NAV_COLLAPSED_KEY); + const defaultNavCollapsed = + storedNav === "1" || (storedNav === null && window.matchMedia("(max-width: 900px)").matches); + setNavCollapsed(defaultNavCollapsed, { persist: false }); + + const storedResult = readLocalStorage(RESULT_COLLAPSED_KEY); + setResultCollapsed(storedResult === "1", { persist: false }); +} + +function bindTabs() { + for (const tab of elements.tabs) { + tab.addEventListener("click", () => { + const panel = tab.dataset.panel; + if (!panel) { + return; + } + setActivePanel(panel); + }); + } +} + +function bindConnection() { + const 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); + } + }); + + elements.fsModeListBtn.addEventListener("click", () => { + setFsViewMode("list"); + loadFilesystem(state.fsCurrentUri).catch((e) => setOutput(e.message)); + }); + + elements.fsModeTreeBtn.addEventListener("click", async () => { + if (state.fsViewMode === "tree") { + // Already in tree mode: toggle all collapse ↔ expand (first level) + if (state.fsTreeExpanded.size > 0) { + state.fsTreeExpanded.clear(); + await renderFsTree(); + } else { + const firstLevel = state.fsTreeData[state.fsCurrentUri] || []; + await Promise.all( + firstLevel + .filter((e) => e.isDir && !state.fsTreeData[e.uri]) + .map(async (e) => { + try { + const payload = await callConsole( + `/ov/fs/ls?uri=${encodeURIComponent(e.uri)}&show_all_hidden=true`, + { method: "GET" } + ); + const children = normalizeFsEntries(payload.result, e.uri); + children.sort((a, b) => { + if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; + return (a.uri || "").localeCompare(b.uri || ""); + }); + state.fsTreeData[e.uri] = children; + } catch (_) {} + }) + ); + for (const entry of firstLevel) { + if (entry.isDir) state.fsTreeExpanded.add(entry.uri); + } + await renderFsTree(); + } + return; + } + setFsViewMode("tree"); + state.fsTreeData = {}; + state.fsTreeExpanded = new Set(); + loadFilesystem(state.fsCurrentUri).catch((e) => setOutput(e.message)); + }); +} + +function bindFind() { + elements.findBtn.addEventListener("click", async () => { + const query = elements.findQuery.value.trim(); + 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 bindAddMemory() { + elements.addMemoryBtn.addEventListener("click", async () => { + if (!state.writeEnabled) { + setOutput("Write mode is disabled on the server."); + return; + } + + const text = elements.addMemoryInput.value.trim(); + if (!text) { + setOutput("Please enter content to add as memory."); + return; + } + + let messages; + try { + const parsed = JSON.parse(text); + if (Array.isArray(parsed)) { + messages = parsed; + } else { + messages = [{ role: "user", content: text }]; + } + } catch (_) { + messages = [{ role: "user", content: text }]; + } + + try { + setOutput("Creating session..."); + const sessionPayload = await callConsole("/ov/sessions", { + method: "POST", + body: JSON.stringify({}), + }); + const sessionId = sessionPayload.result?.session_id; + if (!sessionId) { + throw new Error("Failed to create session: no session_id returned."); + } + + for (const msg of messages) { + await callConsole(`/ov/sessions/${sessionId}/messages`, { + method: "POST", + body: JSON.stringify(msg), + }); + } + + setOutput("Committing session..."); + const commitPayload = await callConsole(`/ov/sessions/${sessionId}/commit`, { + method: "POST", + body: JSON.stringify({}), + }); + setOutput(commitPayload); + } catch (error) { + setOutput({ error: error.message }); + } + }); +} + +function bindTenants() { + bindTenantSortButtons(); + renderAccountsTable(); + renderUsersTable(); + updateTenantCurrentAccountLabel(); + + elements.tenantAccountSearch.addEventListener("input", () => { + renderAccountsTable(); + }); + + elements.tenantRefreshAccountsBtn.addEventListener("click", async () => { + try { + await loadTenantAccounts(); + } catch (error) { + setOutput(error.message); + } + }); + + elements.tenantCreateAccountBtn.addEventListener("click", async () => { + const accountId = elements.tenantCreateAccountId.value.trim(); + const adminUserId = elements.tenantCreateAdminUserId.value.trim(); + if (!accountId || !adminUserId) { + setOutput("Please input account_id and first admin user_id."); + return; + } + + await executeTenantAction({ + title: "Create account", + message: `Create account "${accountId}" with initial admin "${adminUserId}".`, + confirmLabel: `Type ${accountId} to confirm`, + confirmToken: accountId, + actionLabel: "Create account", + run: async () => + callConsole("/ov/admin/accounts", { + method: "POST", + body: JSON.stringify({ account_id: accountId, admin_user_id: adminUserId }), + }), + afterSuccess: async () => { + elements.tenantCreateAccountId.value = ""; + await loadTenantAccounts({ showOutput: false }); + }, + }); + }); + + elements.tenantAddUserBtn.addEventListener("click", async () => { + const accountId = state.tenantSelectedAccountId; + const userId = elements.tenantAddUserId.value.trim(); + const role = elements.tenantAddUserRole.value; + if (!accountId) { + setOutput("Select an account before adding users."); + return; + } + if (!userId) { + setOutput("Please input new user_id."); + return; + } + + await executeTenantAction({ + title: "Add user", + message: `Add user "${userId}" to account "${accountId}" with role "${role}".`, + confirmLabel: `Type ${accountId}/${userId} to confirm`, + confirmToken: `${accountId}/${userId}`, + actionLabel: "Add user", + run: async () => + callConsole(`/ov/admin/accounts/${encodeURIComponent(accountId)}/users`, { + method: "POST", + body: JSON.stringify({ user_id: userId, role }), + }), + afterSuccess: async () => { + elements.tenantAddUserId.value = ""; + await loadTenantUsers(accountId, { showOutput: false }); + }, + }); + }); + + elements.tenantConfirmInput.addEventListener("input", () => { + updateTenantConfirmState(); + }); + + elements.tenantConfirmCancelBtn.addEventListener("click", () => { + closeTenantConfirmModal(); + }); + + elements.tenantConfirmModal.addEventListener("click", (event) => { + if (event.target === elements.tenantConfirmModal) { + closeTenantConfirmModal(); + } + }); + + elements.tenantConfirmActionBtn.addEventListener("click", async () => { + const request = state.tenantConfirmRequest; + if (!request) { + return; + } + + const expected = request.confirmToken || ""; + const typed = elements.tenantConfirmInput.value.trim(); + if (expected && typed !== expected) { + elements.tenantConfirmError.hidden = false; + elements.tenantConfirmError.textContent = "Confirmation text mismatch."; + return; + } + + elements.tenantConfirmActionBtn.disabled = true; + try { + await performTenantAction(request); + closeTenantConfirmModal(); + } catch (error) { + closeTenantConfirmModal(); + setOutput(error.message); + } + }); +} + +function bindMonitor() { + elements.systemBtn.addEventListener("click", async () => { + try { + const payload = await callConsole("/ov/system/status", { method: "GET" }); + const rows = Object.entries(payload.result || {}).map(([key, value]) => ({ + label: `${key}: ${typeof value === "string" ? value : JSON.stringify(value)}`, + })); + renderList(elements.monitorResults, rows); + setOutput(payload); + } catch (error) { + setOutput(error.message); + } + }); + + elements.observerBtn.addEventListener("click", async () => { + try { + const payload = await callConsole("/ov/observer/system", { method: "GET" }); + const rows = Object.entries(payload.result?.components || {}).map(([name, value]) => ({ + label: `${name}: ${value?.status || JSON.stringify(value)}`, + })); + renderList(elements.monitorResults, rows); + setOutput(payload); + } catch (error) { + setOutput(error.message); + } + }); +} + +async function init() { + initShellState(); + bindShellControls(); + initResizablePanes(); + initFsColumnResize(); + bindTabs(); + bindConnection(); + bindFilesystem(); + bindFind(); + renderFindTable([]); + bindAddResource(); + bindAddMemory(); + bindTenants(); + bindMonitor(); + updateConnectionHint(); + setActivePanel(state.activePanel); + 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..7286dd5e --- /dev/null +++ b/examples/console/static/index.html @@ -0,0 +1,329 @@ + + + + + + OpenViking Console + + + +
+
+
+ +
+ OpenViking Console + Control Plane Dashboard +
+
+
+ + + UI Ready + + Readonly +
+ + + +
+
+
+ +
+ + + + +
+
+
+
+

OpenViking Operations Console

+

Inspect resources, manage tenants, and check runtime state from one place.

+
+ +
+ +
+
+

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.

+
+ +
+

Add Memory

+

Store text or conversations as persistent memories.

+ + + +
+ +
+

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. +

    + + +
    + + +
    +

    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..73fb4f75 --- /dev/null +++ b/examples/console/static/styles.css @@ -0,0 +1,1266 @@ +@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"); + +:root { + color-scheme: dark; + + --sidebar-width: 260px; + --panel-height: 440px; + --panel-min-height: 0px; + --result-min-height: 72px; + + --bg: #12141a; + --bg-accent: #161922; + --bg-elevated: #1c2029; + --bg-hover: #252b37; + + --panel: #151820; + --panel-strong: #1a1f29; + --card: #1b1f28; + --surface-soft: rgba(255, 255, 255, 0.02); + + --text: #e7e8ec; + --text-strong: #fafafa; + --muted: #a0a5b1; + --muted-soft: #7e8697; + + --border: #2f3543; + --border-strong: #474f62; + + --accent: #ff5c5c; + --accent-hover: #ff6f6f; + --accent-subtle: rgba(255, 92, 92, 0.15); + --accent-glow: rgba(255, 92, 92, 0.24); + + --ok: #30c482; + --warn: #e5af3a; + --danger: #ef5b6f; + + --radius-xs: 6px; + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-pill: 999px; + + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.22); + --shadow-md: 0 8px 18px rgba(0, 0, 0, 0.28); + --shadow-lg: 0 18px 34px rgba(0, 0, 0, 0.36); + + --dur-fast: 130ms; + --dur-med: 210ms; + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); +} + +:root[data-theme="light"] { + color-scheme: light; + + --bg: #f6f8fc; + --bg-accent: #eef2f8; + --bg-elevated: #ffffff; + --bg-hover: #e8edf6; + + --panel: #f3f6fb; + --panel-strong: #edf1f9; + --card: #ffffff; + --surface-soft: rgba(12, 24, 44, 0.04); + + --text: #2a3244; + --text-strong: #111828; + --muted: #5a657a; + --muted-soft: #7b8598; + + --border: #d7deeb; + --border-strong: #bcc7db; + + --accent: #dc3e3e; + --accent-hover: #eb4b4b; + --accent-subtle: rgba(220, 62, 62, 0.14); + --accent-glow: rgba(220, 62, 62, 0.2); + + --ok: #198a57; + --warn: #b98516; + --danger: #d63f55; + + --shadow-sm: 0 1px 2px rgba(6, 18, 38, 0.08); + --shadow-md: 0 10px 22px rgba(6, 18, 38, 0.11); + --shadow-lg: 0 18px 38px rgba(6, 18, 38, 0.14); +} + +* { + box-sizing: border-box; +} + +html, +body { + height: 100%; +} + +body { + margin: 0; + font-family: "Space Grotesk", "Segoe UI", "Noto Sans", sans-serif; + color: var(--text); + background: + radial-gradient(1100px 600px at -20% -25%, rgba(255, 92, 92, 0.14), transparent 58%), + radial-gradient(900px 620px at 122% -18%, rgba(255, 163, 96, 0.11), transparent 58%), + linear-gradient(145deg, #10131a 0%, var(--bg) 54%); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +:root[data-theme="light"] body { + background: + radial-gradient(1000px 600px at -22% -22%, rgba(220, 62, 62, 0.09), transparent 60%), + radial-gradient(900px 650px at 120% -20%, rgba(176, 74, 74, 0.08), transparent 60%), + linear-gradient(145deg, #f0f4fa 0%, var(--bg) 55%); +} + +h1, +h2, +h3 { + margin: 0; +} + +p { + margin: 0; +} + +.workspace { + height: 100vh; + display: flex; + flex-direction: column; +} + +@supports (height: 100dvh) { + .workspace { + height: 100dvh; + } +} + +.topbar { + min-height: 60px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 12px 18px; + border-bottom: 1px solid var(--border); + background: rgba(18, 20, 26, 0.88); + backdrop-filter: blur(8px); + position: relative; + z-index: 20; +} + +:root[data-theme="light"] .topbar { + background: rgba(246, 248, 252, 0.9); +} + +.topbar-left, +.topbar-right { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.brand { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.brand-title { + font-size: 15px; + letter-spacing: 0.01em; + font-weight: 700; + color: var(--text-strong); + white-space: nowrap; +} + +.brand-subtitle { + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted-soft); +} + +.icon-btn { + border: 1px solid var(--border); + width: 34px; + height: 34px; + border-radius: var(--radius-sm); + background: var(--panel-strong); + color: var(--muted); + cursor: pointer; + transition: + border-color var(--dur-fast) var(--ease-out), + color var(--dur-fast) var(--ease-out), + background var(--dur-fast) var(--ease-out); +} + +.icon-btn:hover { + color: var(--text-strong); + border-color: var(--border-strong); + background: var(--bg-hover); +} + +.pill, +.badge { + display: inline-flex; + align-items: center; + gap: 7px; + border: 1px solid var(--border); + border-radius: var(--radius-pill); + padding: 6px 11px; + font-size: 12px; + background: var(--panel-strong); + color: var(--muted); +} + +.badge { + font-family: "JetBrains Mono", "SF Mono", Consolas, monospace; +} + +.badge.write { + border-color: rgba(48, 196, 130, 0.4); + color: var(--ok); + background: rgba(48, 196, 130, 0.12); +} + +.badge-soft { + color: var(--muted); + border-color: var(--border); +} + +.statusDot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--muted-soft); +} + +.statusDot.ok { + background: var(--ok); + box-shadow: 0 0 0 3px rgba(48, 196, 130, 0.18); +} + +.theme-toggle { + display: inline-flex; + align-items: center; + border: 1px solid var(--border); + border-radius: var(--radius-pill); + padding: 2px; + background: var(--panel-strong); +} + +.theme-btn { + border: 0; + border-radius: var(--radius-pill); + padding: 5px 10px; + font-size: 11px; + letter-spacing: 0.02em; + font-weight: 600; + color: var(--muted); + background: transparent; + cursor: pointer; + transition: + color var(--dur-fast) var(--ease-out), + background var(--dur-fast) var(--ease-out); +} + +.theme-btn.active { + color: #fff; + background: var(--accent); +} + +.shell-body { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: var(--sidebar-width) 8px minmax(0, 1fr); + transition: grid-template-columns var(--dur-med) var(--ease-out); +} + +.shell.shell--nav-collapsed .shell-body { + grid-template-columns: 0 0 minmax(0, 1fr); +} + +.sidebar { + min-height: 0; + overflow: auto; + padding: 16px 12px; + border-right: 1px solid var(--border); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(255, 255, 255, 0)); +} + +:root[data-theme="light"] .sidebar { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.58), rgba(255, 255, 255, 0)); +} + +.shell.shell--nav-collapsed .sidebar { + opacity: 0; + pointer-events: none; + overflow: hidden; +} + +.nav-groups { + display: flex; + flex-direction: column; + gap: 14px; +} + +.nav-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.nav-group-title { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.11em; + color: var(--muted-soft); + padding: 0 8px; +} + +.tab.nav-item { + width: 100%; + text-align: left; + border: 1px solid transparent; + border-radius: var(--radius-md); + padding: 10px 12px; + background: transparent; + color: var(--muted); + cursor: pointer; + font-weight: 600; + letter-spacing: 0.01em; + transition: + color var(--dur-fast) var(--ease-out), + border-color var(--dur-fast) var(--ease-out), + background var(--dur-fast) var(--ease-out), + transform var(--dur-fast) var(--ease-out); +} + +.tab.nav-item:hover { + color: var(--text-strong); + border-color: var(--border); + background: var(--surface-soft); + transform: translateX(1px); +} + +.tab.nav-item.active { + color: var(--text-strong); + border-color: rgba(255, 92, 92, 0.34); + background: linear-gradient(90deg, rgba(255, 92, 92, 0.22), rgba(255, 92, 92, 0.06)); +} + +.resizer { + position: relative; + border: 0; + background: rgba(255, 255, 255, 0.08); + transition: background var(--dur-fast) var(--ease-out); +} + +:root[data-theme="light"] .resizer { + background: rgba(20, 31, 57, 0.12); +} + +.resizer:hover { + background: rgba(255, 92, 92, 0.5); +} + +.resizer-vertical { + cursor: col-resize; +} + +.resizer-horizontal { + cursor: row-resize; + border-radius: var(--radius-pill); +} + +.content-area { + min-height: 0; + min-width: 0; + padding: 12px; +} + +.content { + height: 100%; + min-height: 0; + display: grid; + grid-template-rows: + auto + minmax(var(--panel-min-height), var(--panel-height)) + 8px + minmax(var(--result-min-height), 1fr); + gap: 10px; +} + +.shell.shell--result-collapsed .content { + grid-template-rows: auto minmax(var(--panel-min-height), 1fr); +} + +.shell.shell--result-collapsed #outputResizer, +.shell.shell--result-collapsed .result-card { + display: none; +} + +.content-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: rgba(255, 255, 255, 0.02); + box-shadow: var(--shadow-sm); +} + +.title-block { + min-width: 0; +} + +h1 { + font-size: 19px; + font-weight: 700; + color: var(--text-strong); +} + +.subtitle { + margin-top: 4px; + font-size: 12px; + color: var(--muted); +} + +.panel-stack { + min-height: 0; + display: flex; + overflow: hidden; +} + +.panel { + display: none; + min-height: 0; + overflow: auto; + padding: 14px; + gap: 10px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: linear-gradient(170deg, rgba(255, 255, 255, 0.02), rgba(255, 255, 255, 0.01)); + box-shadow: var(--shadow-md); +} + +.panel.active { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; +} + +.panel > h2 { + font-size: 12px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--muted-soft); +} + +.panel-subtitle { + font-size: 12px; + color: var(--muted); + margin-top: -2px; +} + +.row { + display: flex; + align-items: center; + gap: 8px; +} + +.row.wrap { + flex-wrap: wrap; +} + +.checks { + gap: 12px; +} + +.check-item { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--muted); + font-size: 12px; +} + +label { + font-size: 12px; + color: var(--muted-soft); +} + +input, +textarea, +button, +select, +pre, +.badge, +.tenant-table, +.find-table, +.fs-table { + font-family: "JetBrains Mono", "SF Mono", Menlo, Consolas, monospace; +} + +input, +textarea, +select, +button { + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + background: var(--bg-elevated); + padding: 8px 10px; + transition: + border-color var(--dur-fast) var(--ease-out), + box-shadow var(--dur-fast) var(--ease-out), + background var(--dur-fast) var(--ease-out), + color var(--dur-fast) var(--ease-out); +} + +input, +textarea, +select { + width: 100%; +} + +textarea { + min-height: 70px; + resize: vertical; +} + +input::placeholder, +textarea::placeholder { + color: var(--muted-soft); +} + +button { + cursor: pointer; + font-weight: 600; + color: var(--text-strong); + background: linear-gradient(180deg, rgba(255, 92, 92, 0.17), rgba(255, 92, 92, 0.08)); + border-color: rgba(255, 92, 92, 0.36); +} + +button:hover { + border-color: rgba(255, 92, 92, 0.56); + background: linear-gradient(180deg, rgba(255, 92, 92, 0.24), rgba(255, 92, 92, 0.11)); +} + +button.ghost { + color: var(--text); + border-color: var(--border); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.02)); +} + +button.ghost:hover { + border-color: var(--border-strong); + background: var(--bg-hover); +} + +button.danger { + border-color: rgba(239, 91, 111, 0.54); + background: linear-gradient(180deg, rgba(239, 91, 111, 0.27), rgba(239, 91, 111, 0.12)); + color: #ffd7dd; +} + +button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +button:focus-visible, +input:focus-visible, +textarea:focus-visible, +select:focus-visible, +.tab:focus-visible, +.theme-btn:focus-visible, +.icon-btn:focus-visible { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-subtle); +} + +.muted { + color: var(--muted); + font-size: 12px; +} + +.list { + margin: 0; + padding: 0; + list-style: none; + border: 1px solid var(--border); + border-radius: var(--radius-md); + overflow: hidden; + background: var(--surface-soft); +} + +.list li { + border-bottom: 1px solid var(--border); +} + +.list li:last-child { + border-bottom: 0; +} + +.list .row-item, +.list button { + width: 100%; + text-align: left; + border: 0; + border-radius: 0; + padding: 10px 12px; + background: transparent; + color: var(--text); +} + +.list button:hover { + background: var(--bg-hover); +} + +.tenant-layout { + display: grid; + gap: 12px; + grid-template-columns: minmax(300px, 1fr) minmax(360px, 1.2fr); + min-height: 0; +} + +.tenant-pane { + min-height: 0; + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface-soft); +} + +.tenant-pane-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.tenant-pane h3 { + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted-soft); +} + +.tenant-inline-form { + align-items: stretch; +} + +.tenant-inline-form > * { + min-width: 0; +} + +.tenant-inline-form input:first-child { + flex: 1.2; +} + +.tenant-inline-form input:nth-child(2), +.tenant-inline-form select { + flex: 1; +} + +.tenant-table-wrap, +.find-table-wrap, +.fs-table-wrap { + min-height: 0; + overflow: auto; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: rgba(0, 0, 0, 0.08); +} + +:root[data-theme="light"] .tenant-table-wrap, +:root[data-theme="light"] .find-table-wrap, +:root[data-theme="light"] .fs-table-wrap { + background: rgba(255, 255, 255, 0.7); +} + +.tenant-table, +.find-table, +.fs-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.tenant-table { + min-width: 620px; +} + +.find-table { + min-width: 720px; +} + +.fs-table { + min-width: 1140px; +} + +.tenant-table th, +.tenant-table td, +.find-table th, +.find-table td, +.fs-table th, +.fs-table td { + border-bottom: 1px solid var(--border); + text-align: left; +} + +.tenant-table th, +.find-table th, +.fs-table th { + position: sticky; + top: 0; + z-index: 1; + background: var(--panel-strong); + padding: 8px 10px; +} + +.tenant-table td, +.find-table td, +.fs-table td { + padding: 9px 10px; + color: var(--text); + vertical-align: top; +} + +.tenant-table tbody tr:last-child td, +.find-table tbody tr:last-child td, +.fs-table tbody tr:last-child td { + border-bottom: 0; +} + +.tenant-sort-btn, +.find-sort-btn, +.fs-sort-btn { + width: 100%; + border: 0; + border-radius: 0; + padding: 0; + background: transparent; + color: var(--muted); + text-align: left; + letter-spacing: 0.03em; + font-size: 12px; + cursor: pointer; +} + +.tenant-sort-btn:hover, +.find-sort-btn:hover, +.fs-sort-btn:hover, +.fs-sort-btn.active { + color: var(--text-strong); +} + +.fs-col-action { + width: 34px; + min-width: 34px; + text-align: center; + padding: 6px 4px !important; +} + +.fs-open-btn, +.fs-uri-btn, +.tenant-account-btn { + border: 0; + border-radius: 0; + background: transparent; + color: #ff8e8e; + padding: 0; + text-align: left; + font-weight: 600; +} + +:root[data-theme="light"] .fs-open-btn, +:root[data-theme="light"] .fs-uri-btn, +:root[data-theme="light"] .tenant-account-btn { + color: #b33636; +} + +.fs-open-btn:hover, +.fs-uri-btn:hover, +.tenant-account-btn:hover { + color: var(--accent-hover); + text-decoration: underline; +} + +.fs-col-uri, +.fs-col-size, +.fs-col-dir, +.fs-col-mod-time { + white-space: nowrap; +} + +.fs-col-abstract { + min-width: 300px; +} + +#fsColUri { + width: 360px; + min-width: 220px; +} + +#fsColAction { + width: 34px; + min-width: 34px; + max-width: 34px; + padding: 8px 4px; +} + +#fsColSize { + width: 120px; + min-width: 90px; +} + +#fsColIsDir { + width: 110px; + min-width: 90px; +} + +#fsColModTime { + width: 220px; + min-width: 160px; +} + +#fsColAbstract { + width: 340px; + min-width: 220px; +} + +.fs-col-resizer { + position: absolute; + top: 7px; + right: -4px; + width: 8px; + bottom: 7px; + cursor: col-resize; + z-index: 3; + touch-action: none; +} + +.fs-col-resizer::before { + content: ""; + position: absolute; + left: 3px; + top: 0; + bottom: 0; + width: 2px; + border-radius: var(--radius-pill); + background: rgba(255, 255, 255, 0.3); +} + +:root[data-theme="light"] .fs-col-resizer::before { + background: rgba(26, 42, 74, 0.28); +} + +.fs-col-resizer:hover::before { + background: var(--accent); +} + +.tenant-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; +} + +.tenant-actions button { + padding: 4px 8px; + font-size: 11px; +} + +.tenant-role-select { + max-width: 130px; + padding: 5px 8px; + font-size: 11px; +} + +.tenant-row-selected td { + background: rgba(255, 92, 92, 0.12); +} + +.tenant-empty, +.find-empty, +.fs-empty { + color: var(--muted-soft); +} + +.tenant-modal { + position: fixed; + inset: 0; + z-index: 40; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + background: rgba(12, 14, 18, 0.56); + backdrop-filter: blur(2px); +} + +.tenant-modal[hidden] { + display: none; +} + +.tenant-modal-card { + width: min(560px, 100%); + display: flex; + flex-direction: column; + gap: 10px; + padding: 14px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--card); + box-shadow: var(--shadow-lg); +} + +.tenant-modal-card h3 { + font-size: 13px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--muted-soft); +} + +.tenant-error { + color: var(--danger); + font-size: 12px; +} + +.result-card { + min-height: 0; + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: linear-gradient(160deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0)); + box-shadow: var(--shadow-md); +} + +.result-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.result-head h2 { + font-size: 12px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--muted-soft); +} + +pre { + margin: 0; + flex: 1; + min-height: 0; + padding: 12px; + border-radius: var(--radius-md); + border: 1px solid var(--border); + background: rgba(0, 0, 0, 0.24); + color: var(--text); + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + font-size: 12px; + line-height: 1.5; +} + +:root[data-theme="light"] pre { + background: rgba(255, 255, 255, 0.8); +} + +body.dragging-sidebar, +body.dragging-sidebar *, +body.dragging-fs-column, +body.dragging-fs-column * { + cursor: col-resize !important; + user-select: none; +} + +body.dragging-output, +body.dragging-output * { + cursor: row-resize !important; + user-select: none; +} + +::-webkit-scrollbar { + width: 9px; + height: 9px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + border-radius: var(--radius-pill); + border: 2px solid transparent; + background: rgba(255, 255, 255, 0.2); +} + +:root[data-theme="light"] ::-webkit-scrollbar-thumb { + background: rgba(28, 40, 63, 0.22); +} + +::-webkit-scrollbar-thumb:hover { + background: rgba(255, 92, 92, 0.55); +} + +.fs-toolbar { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.fs-toolbar input { + flex: 1; + width: 0; + min-width: 0; +} + +.fs-toolbar-nav { + display: flex; + align-items: center; + gap: 2px; + flex-shrink: 0; +} + +.fs-nav-btn { + width: 30px; + height: 30px; + padding: 0; + font-size: 15px; + display: flex; + align-items: center; + justify-content: center; + font-family: "Space Grotesk", "Segoe UI", sans-serif; +} + +.fs-toolbar-controls { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.fs-view-toggle { + display: flex; + align-items: center; + gap: 0; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + overflow: hidden; +} + +.fs-view-toggle button { + border: 0; + border-radius: 0; + padding: 5px 10px; + font-size: 11px; + background: transparent; + color: var(--muted); +} + +.fs-view-toggle button:hover { + background: var(--bg-hover); + color: var(--text-strong); +} + +.fs-view-toggle button.active { + background: var(--accent); + color: #fff; + border-color: var(--accent); +} + +.fs-tree { + overflow: auto; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: rgba(0, 0, 0, 0.08); + padding: 6px 0; + min-height: 80px; + flex: 1; +} + +:root[data-theme="light"] .fs-tree { + background: rgba(255, 255, 255, 0.7); +} + +.fs-tree-item { + display: flex; + align-items: center; + gap: 4px; + padding: 3px 10px; + font-size: 12px; + cursor: pointer; + user-select: none; + font-family: "JetBrains Mono", "SF Mono", Menlo, Consolas, monospace; +} + +.fs-tree-item:hover { + background: var(--bg-hover); +} + +.fs-tree-toggle { + flex-shrink: 0; + width: 14px; + color: var(--muted); + font-size: 10px; +} + +.fs-tree-name { + flex: 1; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.fs-tree-item--dir .fs-tree-name { + color: #ff8e8e; + font-weight: 600; +} + +:root[data-theme="light"] .fs-tree-item--dir .fs-tree-name { + color: #b33636; +} + +.fs-tree-info-btn { + flex-shrink: 0; + width: 18px; + border: 0; + border-radius: 0; + background: transparent; + color: #ff8e8e; + padding: 0; + font-size: 12px; + font-weight: 600; + cursor: pointer; + text-align: center; +} + +:root[data-theme="light"] .fs-tree-info-btn { + color: #b33636; +} + +.fs-tree-info-btn:hover { + color: var(--accent-hover); + text-decoration: underline; +} + +@media (max-width: 900px) { + .topbar { + padding: 10px 12px; + min-height: 56px; + } + + .brand-subtitle { + display: none; + } + + .topbar-right { + gap: 6px; + } + + .pill { + display: none; + } + + .shell-body { + grid-template-columns: minmax(0, 1fr); + } + + .sidebar { + position: fixed; + top: 56px; + left: 0; + bottom: 0; + width: min(82vw, 320px); + border-right: 1px solid var(--border); + transform: translateX(-104%); + transition: transform var(--dur-med) var(--ease-out); + z-index: 35; + background: var(--panel-strong); + box-shadow: var(--shadow-lg); + } + + .shell:not(.shell--nav-collapsed) .sidebar { + transform: translateX(0); + } + + .shell::after { + content: ""; + position: fixed; + inset: 56px 0 0 0; + background: rgba(0, 0, 0, 0.4); + opacity: 0; + pointer-events: none; + transition: opacity var(--dur-med) var(--ease-out); + z-index: 30; + } + + .shell:not(.shell--nav-collapsed)::after { + opacity: 1; + pointer-events: auto; + } + + #sidebarResizer { + display: none; + } + + .content-area { + padding: 10px; + } + + .content { + grid-template-rows: + auto + minmax(230px, 1fr) + 6px + minmax(160px, 38vh); + } + + .content-header { + flex-direction: column; + align-items: flex-start; + } + + .tenant-layout { + grid-template-columns: 1fr; + } + + .tenant-inline-form { + flex-wrap: wrap; + } + + .tenant-inline-form button { + width: 100%; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation: none !important; + transition: none !important; + } +}