feat: configurable control ui base path
This commit is contained in:
@@ -9,7 +9,7 @@
|
|||||||
### Features
|
### Features
|
||||||
- Gateway: support `gateway.port` + `CLAWDIS_GATEWAY_PORT` across CLI, TUI, and macOS app.
|
- Gateway: support `gateway.port` + `CLAWDIS_GATEWAY_PORT` across CLI, TUI, and macOS app.
|
||||||
- UI: centralize tool display metadata and show action/detail summaries across Web Chat, SwiftUI, Android, and the TUI.
|
- UI: centralize tool display metadata and show action/detail summaries across Web Chat, SwiftUI, Android, and the TUI.
|
||||||
- Control UI: support configurable base paths (`gateway.controlUi.basePath`) for hosting under URL prefixes.
|
- Control UI: support configurable base paths (`gateway.controlUi.basePath`, default unchanged) for hosting under URL prefixes.
|
||||||
- Onboarding: shared wizard engine powering CLI + macOS via gateway wizard RPC.
|
- Onboarding: shared wizard engine powering CLI + macOS via gateway wizard RPC.
|
||||||
- Config: expose schema + UI hints for generic config forms (Web UI + future clients).
|
- Config: expose schema + UI hints for generic config forms (Web UI + future clients).
|
||||||
|
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ Send these in WhatsApp/Telegram/WebChat (group commands are owner-only):
|
|||||||
- **Events + snapshot**: handshake returns a snapshot (presence/health) and declares event types; runtime events include `agent`, `chat`, `presence`, `tick`, `health`, `heartbeat`, `cron`, `node.pair.*`, `voicewake.changed`, `shutdown`.
|
- **Events + snapshot**: handshake returns a snapshot (presence/health) and declares event types; runtime events include `agent`, `chat`, `presence`, `tick`, `health`, `heartbeat`, `cron`, `node.pair.*`, `voicewake.changed`, `shutdown`.
|
||||||
- **Idempotency & safety**: `send`/`agent`/`chat.send` require idempotency keys with a TTL cache (5 min, cap 1000) to avoid double‑sends on reconnects; payload sizes are capped per connection.
|
- **Idempotency & safety**: `send`/`agent`/`chat.send` require idempotency keys with a TTL cache (5 min, cap 1000) to avoid double‑sends on reconnects; payload sizes are capped per connection.
|
||||||
- **Bridge for nodes**: optional TCP bridge (`src/infra/bridge/server.ts`) is newline‑delimited JSON frames (`hello`, pairing, RPC, `invoke`); node connect/disconnect is surfaced into presence.
|
- **Bridge for nodes**: optional TCP bridge (`src/infra/bridge/server.ts`) is newline‑delimited JSON frames (`hello`, pairing, RPC, `invoke`); node connect/disconnect is surfaced into presence.
|
||||||
- **Control UI + Canvas Host**: HTTP serves `/ui` assets (if built) and can host a live‑reload Canvas host for nodes (`src/canvas-host/server.ts`), injecting the A2UI postMessage bridge.
|
- **Control UI + Canvas Host**: HTTP serves Control UI assets (default `/`, optional base path) and can host a live‑reload Canvas host for nodes (`src/canvas-host/server.ts`), injecting the A2UI postMessage bridge.
|
||||||
|
|
||||||
### iOS app (apps/ios)
|
### iOS app (apps/ios)
|
||||||
- **Discovery + pairing**: Bonjour discovery via `BridgeDiscoveryModel` (NWBrowser). `BridgeConnectionController` auto‑connects using Keychain token or allows manual host/port.
|
- **Discovery + pairing**: Bonjour discovery via `BridgeDiscoveryModel` (NWBrowser). `BridgeConnectionController` auto‑connects using Keychain token or allows manual host/port.
|
||||||
|
|||||||
@@ -645,13 +645,18 @@ Defaults:
|
|||||||
mode: "local", // or "remote"
|
mode: "local", // or "remote"
|
||||||
port: 18789, // WS + HTTP multiplex
|
port: 18789, // WS + HTTP multiplex
|
||||||
bind: "loopback",
|
bind: "loopback",
|
||||||
// controlUi: { enabled: true }
|
// controlUi: { enabled: true, basePath: "/clawdis" }
|
||||||
// auth: { mode: "token", token: "your-token" } // token is for multi-machine CLI access
|
// auth: { mode: "token", token: "your-token" } // token is for multi-machine CLI access
|
||||||
// tailscale: { mode: "off" | "serve" | "funnel" }
|
// tailscale: { mode: "off" | "serve" | "funnel" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Control UI base path:
|
||||||
|
- `gateway.controlUi.basePath` sets the URL prefix where the Control UI is served.
|
||||||
|
- Examples: `"/ui"`, `"/clawdis"`, `"/apps/clawdis"`.
|
||||||
|
- Default: root (`/`) (unchanged).
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- `clawdis gateway` refuses to start unless `gateway.mode` is set to `local` (or you pass the override flag).
|
- `clawdis gateway` refuses to start unless `gateway.mode` is set to `local` (or you pass the override flag).
|
||||||
- `gateway.port` controls the single multiplexed port used for WebSocket + HTTP (control UI, hooks, A2UI).
|
- `gateway.port` controls the single multiplexed port used for WebSocket + HTTP (control UI, hooks, A2UI).
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ read_when:
|
|||||||
---
|
---
|
||||||
# Control UI (browser)
|
# Control UI (browser)
|
||||||
|
|
||||||
The Control UI is a small **Vite + Lit** single-page app served by the Gateway under:
|
The Control UI is a small **Vite + Lit** single-page app served by the Gateway:
|
||||||
|
|
||||||
- `http://<host>:18789/`
|
- default: `http://<host>:18789/`
|
||||||
|
- optional prefix: set `gateway.controlUi.basePath` (e.g. `/clawdis`)
|
||||||
|
|
||||||
It speaks **directly to the Gateway WebSocket** on the same port.
|
It speaks **directly to the Gateway WebSocket** on the same port.
|
||||||
|
|
||||||
@@ -40,7 +41,7 @@ clawdis gateway --tailscale serve
|
|||||||
```
|
```
|
||||||
|
|
||||||
Open:
|
Open:
|
||||||
- `https://<magicdns>/ui/`
|
- `https://<magicdns>/` (or your configured `gateway.controlUi.basePath`)
|
||||||
|
|
||||||
By default, the gateway trusts Tailscale identity headers in serve mode. You can still set
|
By default, the gateway trusts Tailscale identity headers in serve mode. You can still set
|
||||||
`CLAWDIS_GATEWAY_TOKEN` or `gateway.auth` if you want a shared secret instead.
|
`CLAWDIS_GATEWAY_TOKEN` or `gateway.auth` if you want a shared secret instead.
|
||||||
@@ -52,7 +53,7 @@ clawdis gateway --bind tailnet --token "$(openssl rand -hex 32)"
|
|||||||
```
|
```
|
||||||
|
|
||||||
Then open:
|
Then open:
|
||||||
- `http://<tailscale-ip>:18789/ui/`
|
- `http://<tailscale-ip>:18789/` (or your configured `gateway.controlUi.basePath`)
|
||||||
|
|
||||||
Paste the token into the UI settings (sent as `connect.params.auth.token`).
|
Paste the token into the UI settings (sent as `connect.params.auth.token`).
|
||||||
|
|
||||||
@@ -65,6 +66,12 @@ pnpm ui:install
|
|||||||
pnpm ui:build
|
pnpm ui:build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Optional absolute base (when you want fixed asset URLs):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CLAWDIS_CONTROL_UI_BASE_PATH=/clawdis/ pnpm ui:build
|
||||||
|
```
|
||||||
|
|
||||||
For local development (separate dev server):
|
For local development (separate dev server):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ read_when:
|
|||||||
---
|
---
|
||||||
# Dashboard (Control UI)
|
# Dashboard (Control UI)
|
||||||
|
|
||||||
The Gateway dashboard is the browser Control UI served at `/ui/`.
|
The Gateway dashboard is the browser Control UI served at `/` by default
|
||||||
|
(override with `gateway.controlUi.basePath`).
|
||||||
|
|
||||||
Key references:
|
Key references:
|
||||||
- `docs/control-ui.md` for usage and UI capabilities.
|
- `docs/control-ui.md` for usage and UI capabilities.
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ default unless you force `gateway.auth.mode` to `password` or set
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Open: `https://<magicdns>/ui/`
|
Open: `https://<magicdns>/` (or your configured `gateway.controlUi.basePath`)
|
||||||
|
|
||||||
### Public internet (Funnel + shared password)
|
### Public internet (Funnel + shared password)
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ read_when:
|
|||||||
|
|
||||||
The Gateway serves a small **browser Control UI** (Vite + Lit) from the same port as the Gateway WebSocket:
|
The Gateway serves a small **browser Control UI** (Vite + Lit) from the same port as the Gateway WebSocket:
|
||||||
|
|
||||||
- `http://<host>:18789/ui/`
|
- default: `http://<host>:18789/`
|
||||||
|
- optional prefix: set `gateway.controlUi.basePath` (e.g. `/clawdis`)
|
||||||
|
|
||||||
The UI talks directly to the Gateway WS and supports:
|
The UI talks directly to the Gateway WS and supports:
|
||||||
- Chat (`chat.history`, `chat.send`, `chat.abort`)
|
- Chat (`chat.history`, `chat.send`, `chat.abort`)
|
||||||
@@ -34,7 +35,7 @@ You can control it via config:
|
|||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
gateway: {
|
gateway: {
|
||||||
controlUi: { enabled: true } // set false to disable /ui/
|
controlUi: { enabled: true, basePath: "/clawdis" } // basePath optional
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -61,7 +62,7 @@ clawdis gateway
|
|||||||
```
|
```
|
||||||
|
|
||||||
Open:
|
Open:
|
||||||
- `https://<magicdns>/ui/`
|
- `https://<magicdns>/` (or your configured `gateway.controlUi.basePath`)
|
||||||
|
|
||||||
### Tailnet bind + token (legacy)
|
### Tailnet bind + token (legacy)
|
||||||
|
|
||||||
@@ -82,7 +83,7 @@ clawdis gateway
|
|||||||
```
|
```
|
||||||
|
|
||||||
Open:
|
Open:
|
||||||
- `http://<tailscale-ip>:18789/ui/`
|
- `http://<tailscale-ip>:18789/` (or your configured `gateway.controlUi.basePath`)
|
||||||
|
|
||||||
### Public internet (Funnel)
|
### Public internet (Funnel)
|
||||||
|
|
||||||
|
|||||||
@@ -618,7 +618,11 @@ export async function runConfigureWizard(
|
|||||||
note(
|
note(
|
||||||
(() => {
|
(() => {
|
||||||
const bind = nextConfig.gateway?.bind ?? "loopback";
|
const bind = nextConfig.gateway?.bind ?? "loopback";
|
||||||
const links = resolveControlUiLinks({ bind, port: gatewayPort });
|
const links = resolveControlUiLinks({
|
||||||
|
bind,
|
||||||
|
port: gatewayPort,
|
||||||
|
basePath: nextConfig.gateway?.controlUi?.basePath,
|
||||||
|
});
|
||||||
return [`Web UI: ${links.httpUrl}`, `Gateway WS: ${links.wsUrl}`].join(
|
return [`Web UI: ${links.httpUrl}`, `Gateway WS: ${links.wsUrl}`].join(
|
||||||
"\n",
|
"\n",
|
||||||
);
|
);
|
||||||
@@ -635,7 +639,11 @@ export async function runConfigureWizard(
|
|||||||
);
|
);
|
||||||
if (wantsOpen) {
|
if (wantsOpen) {
|
||||||
const bind = nextConfig.gateway?.bind ?? "loopback";
|
const bind = nextConfig.gateway?.bind ?? "loopback";
|
||||||
const links = resolveControlUiLinks({ bind, port: gatewayPort });
|
const links = resolveControlUiLinks({
|
||||||
|
bind,
|
||||||
|
port: gatewayPort,
|
||||||
|
basePath: nextConfig.gateway?.controlUi?.basePath,
|
||||||
|
});
|
||||||
await openUrl(links.httpUrl);
|
await openUrl(links.httpUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type { ClawdisConfig } from "../config/config.js";
|
|||||||
import { CONFIG_PATH_CLAWDIS } from "../config/config.js";
|
import { CONFIG_PATH_CLAWDIS } from "../config/config.js";
|
||||||
import { resolveSessionTranscriptsDir } from "../config/sessions.js";
|
import { resolveSessionTranscriptsDir } from "../config/sessions.js";
|
||||||
import { callGateway } from "../gateway/call.js";
|
import { callGateway } from "../gateway/call.js";
|
||||||
|
import { normalizeControlUiBasePath } from "../gateway/control-ui.js";
|
||||||
import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js";
|
import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js";
|
||||||
import { runCommandWithTimeout } from "../process/exec.js";
|
import { runCommandWithTimeout } from "../process/exec.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
@@ -221,6 +222,7 @@ export const DEFAULT_WORKSPACE = DEFAULT_AGENT_WORKSPACE_DIR;
|
|||||||
export function resolveControlUiLinks(params: {
|
export function resolveControlUiLinks(params: {
|
||||||
port: number;
|
port: number;
|
||||||
bind?: "auto" | "lan" | "tailnet" | "loopback";
|
bind?: "auto" | "lan" | "tailnet" | "loopback";
|
||||||
|
basePath?: string;
|
||||||
}): { httpUrl: string; wsUrl: string } {
|
}): { httpUrl: string; wsUrl: string } {
|
||||||
const port = params.port;
|
const port = params.port;
|
||||||
const bind = params.bind ?? "loopback";
|
const bind = params.bind ?? "loopback";
|
||||||
@@ -229,8 +231,11 @@ export function resolveControlUiLinks(params: {
|
|||||||
bind === "tailnet" || (bind === "auto" && tailnetIPv4)
|
bind === "tailnet" || (bind === "auto" && tailnetIPv4)
|
||||||
? (tailnetIPv4 ?? "127.0.0.1")
|
? (tailnetIPv4 ?? "127.0.0.1")
|
||||||
: "127.0.0.1";
|
: "127.0.0.1";
|
||||||
|
const basePath = normalizeControlUiBasePath(params.basePath);
|
||||||
|
const uiPath = basePath ? `${basePath}/` : "/";
|
||||||
|
const wsPath = basePath ? basePath : "";
|
||||||
return {
|
return {
|
||||||
httpUrl: `http://${host}:${port}/`,
|
httpUrl: `http://${host}:${port}${uiPath}`,
|
||||||
wsUrl: `ws://${host}:${port}`,
|
wsUrl: `ws://${host}:${port}${wsPath}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -402,8 +402,10 @@ export type TalkConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type GatewayControlUiConfig = {
|
export type GatewayControlUiConfig = {
|
||||||
/** If false, the Gateway will not serve the Control UI (/). Default: true. */
|
/** If false, the Gateway will not serve the Control UI (default /). */
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
/** Optional base path prefix for the Control UI (e.g. "/clawdis"). */
|
||||||
|
basePath?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GatewayAuthMode = "token" | "password";
|
export type GatewayAuthMode = "token" | "password";
|
||||||
@@ -1269,6 +1271,7 @@ export const ClawdisSchema = z.object({
|
|||||||
controlUi: z
|
controlUi: z
|
||||||
.object({
|
.object({
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
|
basePath: z.string().optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
auth: z
|
auth: z
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
"gateway.remote.password": "Remote Gateway Password",
|
"gateway.remote.password": "Remote Gateway Password",
|
||||||
"gateway.auth.token": "Gateway Token",
|
"gateway.auth.token": "Gateway Token",
|
||||||
"gateway.auth.password": "Gateway Password",
|
"gateway.auth.password": "Gateway Password",
|
||||||
|
"gateway.controlUi.basePath": "Control UI Base Path",
|
||||||
"agent.workspace": "Workspace",
|
"agent.workspace": "Workspace",
|
||||||
"agent.model": "Default Model",
|
"agent.model": "Default Model",
|
||||||
"ui.seamColor": "Accent Color",
|
"ui.seamColor": "Accent Color",
|
||||||
@@ -97,10 +98,13 @@ const FIELD_HELP: Record<string, string> = {
|
|||||||
"gateway.auth.token":
|
"gateway.auth.token":
|
||||||
"Required for multi-machine access or non-loopback binds.",
|
"Required for multi-machine access or non-loopback binds.",
|
||||||
"gateway.auth.password": "Required for Tailscale funnel.",
|
"gateway.auth.password": "Required for Tailscale funnel.",
|
||||||
|
"gateway.controlUi.basePath":
|
||||||
|
"Optional URL prefix where the Control UI is served (e.g. /clawdis).",
|
||||||
};
|
};
|
||||||
|
|
||||||
const FIELD_PLACEHOLDERS: Record<string, string> = {
|
const FIELD_PLACEHOLDERS: Record<string, string> = {
|
||||||
"gateway.remote.url": "ws://host:18789",
|
"gateway.remote.url": "ws://host:18789",
|
||||||
|
"gateway.controlUi.basePath": "/clawdis",
|
||||||
};
|
};
|
||||||
|
|
||||||
const SENSITIVE_PATTERNS = [/token/i, /password/i, /secret/i, /api.?key/i];
|
const SENSITIVE_PATTERNS = [/token/i, /password/i, /secret/i, /api.?key/i];
|
||||||
|
|||||||
@@ -3,9 +3,22 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
const _UI_PREFIX = "/ui/";
|
|
||||||
const ROOT_PREFIX = "/";
|
const ROOT_PREFIX = "/";
|
||||||
|
|
||||||
|
export type ControlUiRequestOptions = {
|
||||||
|
basePath?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizeControlUiBasePath(basePath?: string): string {
|
||||||
|
if (!basePath) return "";
|
||||||
|
let normalized = basePath.trim();
|
||||||
|
if (!normalized) return "";
|
||||||
|
if (!normalized.startsWith("/")) normalized = `/${normalized}`;
|
||||||
|
if (normalized === "/") return "";
|
||||||
|
if (normalized.endsWith("/")) normalized = normalized.slice(0, -1);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveControlUiRoot(): string | null {
|
function resolveControlUiRoot(): string | null {
|
||||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const execDir = (() => {
|
const execDir = (() => {
|
||||||
@@ -73,6 +86,29 @@ function serveFile(res: ServerResponse, filePath: string) {
|
|||||||
res.end(fs.readFileSync(filePath));
|
res.end(fs.readFileSync(filePath));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function injectControlUiBasePath(html: string, basePath: string): string {
|
||||||
|
const script = `<script>window.__CLAWDIS_CONTROL_UI_BASE_PATH__=${JSON.stringify(
|
||||||
|
basePath,
|
||||||
|
)};</script>`;
|
||||||
|
if (html.includes("__CLAWDIS_CONTROL_UI_BASE_PATH__")) return html;
|
||||||
|
const headClose = html.indexOf("</head>");
|
||||||
|
if (headClose !== -1) {
|
||||||
|
return `${html.slice(0, headClose)}${script}${html.slice(headClose)}`;
|
||||||
|
}
|
||||||
|
return `${script}${html}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serveIndexHtml(
|
||||||
|
res: ServerResponse,
|
||||||
|
indexPath: string,
|
||||||
|
basePath: string,
|
||||||
|
) {
|
||||||
|
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||||
|
res.setHeader("Cache-Control", "no-cache");
|
||||||
|
const raw = fs.readFileSync(indexPath, "utf8");
|
||||||
|
res.end(injectControlUiBasePath(raw, basePath));
|
||||||
|
}
|
||||||
|
|
||||||
function isSafeRelativePath(relPath: string) {
|
function isSafeRelativePath(relPath: string) {
|
||||||
if (!relPath) return false;
|
if (!relPath) return false;
|
||||||
const normalized = path.posix.normalize(relPath);
|
const normalized = path.posix.normalize(relPath);
|
||||||
@@ -84,6 +120,7 @@ function isSafeRelativePath(relPath: string) {
|
|||||||
export function handleControlUiHttpRequest(
|
export function handleControlUiHttpRequest(
|
||||||
req: IncomingMessage,
|
req: IncomingMessage,
|
||||||
res: ServerResponse,
|
res: ServerResponse,
|
||||||
|
opts?: ControlUiRequestOptions,
|
||||||
): boolean {
|
): boolean {
|
||||||
const urlRaw = req.url;
|
const urlRaw = req.url;
|
||||||
if (!urlRaw) return false;
|
if (!urlRaw) return false;
|
||||||
@@ -95,10 +132,24 @@ export function handleControlUiHttpRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(urlRaw, "http://localhost");
|
const url = new URL(urlRaw, "http://localhost");
|
||||||
|
const basePath = normalizeControlUiBasePath(opts?.basePath);
|
||||||
|
const pathname = url.pathname;
|
||||||
|
|
||||||
if (url.pathname === "/ui" || url.pathname.startsWith("/ui/")) {
|
if (!basePath) {
|
||||||
respondNotFound(res);
|
if (pathname === "/ui" || pathname.startsWith("/ui/")) {
|
||||||
return true;
|
respondNotFound(res);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (basePath) {
|
||||||
|
if (pathname === basePath) {
|
||||||
|
res.statusCode = 302;
|
||||||
|
res.setHeader("Location", `${basePath}/${url.search}`);
|
||||||
|
res.end();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!pathname.startsWith(`${basePath}/`)) return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const root = resolveControlUiRoot();
|
const root = resolveControlUiRoot();
|
||||||
@@ -111,10 +162,15 @@ export function handleControlUiHttpRequest(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const uiPath =
|
||||||
|
basePath && pathname.startsWith(`${basePath}/`)
|
||||||
|
? pathname.slice(basePath.length)
|
||||||
|
: pathname;
|
||||||
const rel = (() => {
|
const rel = (() => {
|
||||||
if (url.pathname === ROOT_PREFIX) return "";
|
if (uiPath === ROOT_PREFIX) return "";
|
||||||
if (url.pathname.startsWith("/assets/")) return url.pathname.slice(1);
|
const assetsIndex = uiPath.indexOf("/assets/");
|
||||||
return url.pathname.slice(1);
|
if (assetsIndex >= 0) return uiPath.slice(assetsIndex + 1);
|
||||||
|
return uiPath.slice(1);
|
||||||
})();
|
})();
|
||||||
const requested = rel && !rel.endsWith("/") ? rel : `${rel}index.html`;
|
const requested = rel && !rel.endsWith("/") ? rel : `${rel}index.html`;
|
||||||
const fileRel = requested || "index.html";
|
const fileRel = requested || "index.html";
|
||||||
@@ -130,6 +186,10 @@ export function handleControlUiHttpRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
||||||
|
if (path.basename(filePath) === "index.html") {
|
||||||
|
serveIndexHtml(res, filePath, basePath);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
serveFile(res, filePath);
|
serveFile(res, filePath);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -137,7 +197,7 @@ export function handleControlUiHttpRequest(
|
|||||||
// SPA fallback (client-side router): serve index.html for unknown paths.
|
// SPA fallback (client-side router): serve index.html for unknown paths.
|
||||||
const indexPath = path.join(root, "index.html");
|
const indexPath = path.join(root, "index.html");
|
||||||
if (fs.existsSync(indexPath)) {
|
if (fs.existsSync(indexPath)) {
|
||||||
serveFile(res, indexPath);
|
serveIndexHtml(res, indexPath, basePath);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -501,7 +501,11 @@ export async function runOnboardingWizard(
|
|||||||
|
|
||||||
await prompter.note(
|
await prompter.note(
|
||||||
(() => {
|
(() => {
|
||||||
const links = resolveControlUiLinks({ bind, port });
|
const links = resolveControlUiLinks({
|
||||||
|
bind,
|
||||||
|
port,
|
||||||
|
basePath: config.gateway?.controlUi?.basePath,
|
||||||
|
});
|
||||||
const tokenParam =
|
const tokenParam =
|
||||||
authMode === "token" && gatewayToken
|
authMode === "token" && gatewayToken
|
||||||
? `?token=${encodeURIComponent(gatewayToken)}`
|
? `?token=${encodeURIComponent(gatewayToken)}`
|
||||||
@@ -523,7 +527,11 @@ export async function runOnboardingWizard(
|
|||||||
initialValue: true,
|
initialValue: true,
|
||||||
});
|
});
|
||||||
if (wantsOpen) {
|
if (wantsOpen) {
|
||||||
const links = resolveControlUiLinks({ bind, port });
|
const links = resolveControlUiLinks({
|
||||||
|
bind,
|
||||||
|
port,
|
||||||
|
basePath: config.gateway?.controlUi?.basePath,
|
||||||
|
});
|
||||||
const tokenParam =
|
const tokenParam =
|
||||||
authMode === "token" && gatewayToken
|
authMode === "token" && gatewayToken
|
||||||
? `?token=${encodeURIComponent(gatewayToken)}`
|
? `?token=${encodeURIComponent(gatewayToken)}`
|
||||||
|
|||||||
@@ -4,7 +4,14 @@ import { customElement, state } from "lit/decorators.js";
|
|||||||
import { GatewayBrowserClient, type GatewayEventFrame, type GatewayHelloOk } from "./gateway";
|
import { GatewayBrowserClient, type GatewayEventFrame, type GatewayHelloOk } from "./gateway";
|
||||||
import { loadSettings, saveSettings, type UiSettings } from "./storage";
|
import { loadSettings, saveSettings, type UiSettings } from "./storage";
|
||||||
import { renderApp } from "./app-render";
|
import { renderApp } from "./app-render";
|
||||||
import { normalizePath, pathForTab, tabFromPath, type Tab } from "./navigation";
|
import {
|
||||||
|
inferBasePathFromPathname,
|
||||||
|
normalizeBasePath,
|
||||||
|
normalizePath,
|
||||||
|
pathForTab,
|
||||||
|
tabFromPath,
|
||||||
|
type Tab,
|
||||||
|
} from "./navigation";
|
||||||
import {
|
import {
|
||||||
resolveTheme,
|
resolveTheme,
|
||||||
type ResolvedTheme,
|
type ResolvedTheme,
|
||||||
@@ -74,6 +81,12 @@ type EventLogEntry = {
|
|||||||
payload?: unknown;
|
payload?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__CLAWDIS_CONTROL_UI_BASE_PATH__?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_CRON_FORM: CronFormState = {
|
const DEFAULT_CRON_FORM: CronFormState = {
|
||||||
name: "",
|
name: "",
|
||||||
description: "",
|
description: "",
|
||||||
@@ -468,9 +481,11 @@ export class ClawdisApp extends LitElement {
|
|||||||
|
|
||||||
private inferBasePath() {
|
private inferBasePath() {
|
||||||
if (typeof window === "undefined") return "";
|
if (typeof window === "undefined") return "";
|
||||||
const path = window.location.pathname;
|
const configured = window.__CLAWDIS_CONTROL_UI_BASE_PATH__;
|
||||||
if (path === "/ui" || path.startsWith("/ui/")) return "/ui";
|
if (typeof configured === "string" && configured.trim()) {
|
||||||
return "";
|
return normalizeBasePath(configured);
|
||||||
|
}
|
||||||
|
return inferBasePathFromPathname(window.location.pathname);
|
||||||
}
|
}
|
||||||
|
|
||||||
private syncThemeWithSettings() {
|
private syncThemeWithSettings() {
|
||||||
|
|||||||
@@ -21,11 +21,13 @@ beforeEach(() => {
|
|||||||
ClawdisApp.prototype.connect = () => {
|
ClawdisApp.prototype.connect = () => {
|
||||||
// no-op: avoid real gateway WS connections in browser tests
|
// no-op: avoid real gateway WS connections in browser tests
|
||||||
};
|
};
|
||||||
|
window.__CLAWDIS_CONTROL_UI_BASE_PATH__ = undefined;
|
||||||
document.body.innerHTML = "";
|
document.body.innerHTML = "";
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
ClawdisApp.prototype.connect = originalConnect;
|
ClawdisApp.prototype.connect = originalConnect;
|
||||||
|
window.__CLAWDIS_CONTROL_UI_BASE_PATH__ = undefined;
|
||||||
document.body.innerHTML = "";
|
document.body.innerHTML = "";
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -47,6 +49,25 @@ describe("control UI routing", () => {
|
|||||||
expect(window.location.pathname).toBe("/ui/cron");
|
expect(window.location.pathname).toBe("/ui/cron");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("infers nested base paths", async () => {
|
||||||
|
const app = mountApp("/apps/clawdis/cron");
|
||||||
|
await app.updateComplete;
|
||||||
|
|
||||||
|
expect(app.basePath).toBe("/apps/clawdis");
|
||||||
|
expect(app.tab).toBe("cron");
|
||||||
|
expect(window.location.pathname).toBe("/apps/clawdis/cron");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honors explicit base path overrides", async () => {
|
||||||
|
window.__CLAWDIS_CONTROL_UI_BASE_PATH__ = "/clawdis";
|
||||||
|
const app = mountApp("/clawdis/sessions");
|
||||||
|
await app.updateComplete;
|
||||||
|
|
||||||
|
expect(app.basePath).toBe("/clawdis");
|
||||||
|
expect(app.tab).toBe("sessions");
|
||||||
|
expect(window.location.pathname).toBe("/clawdis/sessions");
|
||||||
|
});
|
||||||
|
|
||||||
it("updates the URL when clicking nav items", async () => {
|
it("updates the URL when clicking nav items", async () => {
|
||||||
const app = mountApp("/chat");
|
const app = mountApp("/chat");
|
||||||
await app.updateComplete;
|
await app.updateComplete;
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const PATH_TO_TAB = new Map(
|
|||||||
Object.entries(TAB_PATHS).map(([tab, path]) => [path, tab as Tab]),
|
Object.entries(TAB_PATHS).map(([tab, path]) => [path, tab as Tab]),
|
||||||
);
|
);
|
||||||
|
|
||||||
function normalizeBasePath(basePath: string): string {
|
export function normalizeBasePath(basePath: string): string {
|
||||||
if (!basePath) return "";
|
if (!basePath) return "";
|
||||||
let base = basePath.trim();
|
let base = basePath.trim();
|
||||||
if (!base.startsWith("/")) base = `/${base}`;
|
if (!base.startsWith("/")) base = `/${base}`;
|
||||||
@@ -78,6 +78,24 @@ export function tabFromPath(pathname: string, basePath = ""): Tab | null {
|
|||||||
return PATH_TO_TAB.get(normalized) ?? null;
|
return PATH_TO_TAB.get(normalized) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function inferBasePathFromPathname(pathname: string): string {
|
||||||
|
let normalized = normalizePath(pathname);
|
||||||
|
if (normalized.endsWith("/index.html")) {
|
||||||
|
normalized = normalizePath(normalized.slice(0, -"/index.html".length));
|
||||||
|
}
|
||||||
|
if (normalized === "/") return "";
|
||||||
|
const segments = normalized.split("/").filter(Boolean);
|
||||||
|
if (segments.length === 0) return "";
|
||||||
|
for (let i = 0; i < segments.length; i++) {
|
||||||
|
const candidate = `/${segments.slice(i).join("/")}`.toLowerCase();
|
||||||
|
if (PATH_TO_TAB.has(candidate)) {
|
||||||
|
const prefix = segments.slice(0, i);
|
||||||
|
return prefix.length ? `/${prefix.join("/")}` : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `/${segments.join("/")}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function titleForTab(tab: Tab) {
|
export function titleForTab(tab: Tab) {
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
case "overview":
|
case "overview":
|
||||||
|
|||||||
@@ -4,16 +4,28 @@ import { defineConfig } from "vite";
|
|||||||
|
|
||||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
export default defineConfig({
|
function normalizeBase(input: string): string {
|
||||||
base: "/",
|
const trimmed = input.trim();
|
||||||
build: {
|
if (!trimmed) return "/";
|
||||||
outDir: path.resolve(here, "../dist/control-ui"),
|
if (trimmed === "./") return "./";
|
||||||
emptyOutDir: true,
|
if (trimmed.endsWith("/")) return trimmed;
|
||||||
sourcemap: true,
|
return `${trimmed}/`;
|
||||||
},
|
}
|
||||||
server: {
|
|
||||||
host: true,
|
export default defineConfig(({ command }) => {
|
||||||
port: 5173,
|
const envBase = process.env.CLAWDIS_CONTROL_UI_BASE_PATH?.trim();
|
||||||
strictPort: true,
|
const base = envBase ? normalizeBase(envBase) : "/";
|
||||||
},
|
return {
|
||||||
|
base,
|
||||||
|
build: {
|
||||||
|
outDir: path.resolve(here, "../dist/control-ui"),
|
||||||
|
emptyOutDir: true,
|
||||||
|
sourcemap: true,
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
host: true,
|
||||||
|
port: 5173,
|
||||||
|
strictPort: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user