feat: unify onboarding + config schema
This commit is contained in:
@@ -58,7 +58,7 @@ import {
|
||||
} from "./controllers/skills";
|
||||
import { loadNodes } from "./controllers/nodes";
|
||||
import { loadChatHistory } from "./controllers/chat";
|
||||
import { loadConfig, saveConfig } from "./controllers/config";
|
||||
import { loadConfig, saveConfig, updateConfigFormValue } from "./controllers/config";
|
||||
import { loadCronRuns, toggleCronJob, runCronJob, removeCronJob, addCronJob } from "./controllers/cron";
|
||||
import { loadDebug, callDebugMethod } from "./controllers/debug";
|
||||
|
||||
@@ -95,6 +95,11 @@ export type AppViewState = {
|
||||
configIssues: unknown[];
|
||||
configSaving: boolean;
|
||||
configSnapshot: ConfigSnapshot | null;
|
||||
configSchema: unknown | null;
|
||||
configSchemaLoading: boolean;
|
||||
configUiHints: Record<string, unknown>;
|
||||
configForm: Record<string, unknown> | null;
|
||||
configFormMode: "form" | "raw";
|
||||
providersLoading: boolean;
|
||||
providersSnapshot: ProvidersStatusSnapshot | null;
|
||||
providersError: string | null;
|
||||
@@ -392,7 +397,14 @@ export function renderApp(state: AppViewState) {
|
||||
loading: state.configLoading,
|
||||
saving: state.configSaving,
|
||||
connected: state.connected,
|
||||
schema: state.configSchema,
|
||||
schemaLoading: state.configSchemaLoading,
|
||||
uiHints: state.configUiHints,
|
||||
formMode: state.configFormMode,
|
||||
formValue: state.configForm,
|
||||
onRawChange: (next) => (state.configRaw = next),
|
||||
onFormModeChange: (mode) => (state.configFormMode = mode),
|
||||
onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
|
||||
onReload: () => loadConfig(state),
|
||||
onSave: () => saveConfig(state),
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "./theme-transition";
|
||||
import type {
|
||||
ConfigSnapshot,
|
||||
ConfigUiHints,
|
||||
CronJob,
|
||||
CronRunLogEntry,
|
||||
CronStatus,
|
||||
@@ -41,7 +42,11 @@ import {
|
||||
type ChatEventPayload,
|
||||
} from "./controllers/chat";
|
||||
import { loadNodes } from "./controllers/nodes";
|
||||
import { loadConfig } from "./controllers/config";
|
||||
import {
|
||||
loadConfig,
|
||||
loadConfigSchema,
|
||||
updateConfigFormValue,
|
||||
} from "./controllers/config";
|
||||
import {
|
||||
loadProviders,
|
||||
logoutWhatsApp,
|
||||
@@ -120,6 +125,13 @@ export class ClawdisApp extends LitElement {
|
||||
@state() configIssues: unknown[] = [];
|
||||
@state() configSaving = false;
|
||||
@state() configSnapshot: ConfigSnapshot | null = null;
|
||||
@state() configSchema: unknown | null = null;
|
||||
@state() configSchemaVersion: string | null = null;
|
||||
@state() configSchemaLoading = false;
|
||||
@state() configUiHints: ConfigUiHints = {};
|
||||
@state() configForm: Record<string, unknown> | null = null;
|
||||
@state() configFormDirty = false;
|
||||
@state() configFormMode: "form" | "raw" = "form";
|
||||
|
||||
@state() providersLoading = false;
|
||||
@state() providersSnapshot: ProvidersStatusSnapshot | null = null;
|
||||
@@ -447,7 +459,10 @@ export class ClawdisApp extends LitElement {
|
||||
await Promise.all([loadChatHistory(this), loadSessions(this)]);
|
||||
this.scheduleChatScroll();
|
||||
}
|
||||
if (this.tab === "config") await loadConfig(this);
|
||||
if (this.tab === "config") {
|
||||
await loadConfigSchema(this);
|
||||
await loadConfig(this);
|
||||
}
|
||||
if (this.tab === "debug") await loadDebug(this);
|
||||
}
|
||||
|
||||
|
||||
106
ui/src/ui/config-form.browser.test.ts
Normal file
106
ui/src/ui/config-form.browser.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { render } from "lit";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderConfigForm } from "./views/config-form";
|
||||
|
||||
const rootSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
gateway: {
|
||||
type: "object",
|
||||
properties: {
|
||||
auth: {
|
||||
type: "object",
|
||||
properties: {
|
||||
token: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
allowFrom: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
mode: {
|
||||
type: "string",
|
||||
enum: ["off", "token"],
|
||||
},
|
||||
enabled: {
|
||||
type: "boolean",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe("config form renderer", () => {
|
||||
it("renders inputs and patches values", () => {
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderConfigForm({
|
||||
schema: rootSchema,
|
||||
uiHints: {
|
||||
"gateway.auth.token": { label: "Gateway Token", sensitive: true },
|
||||
},
|
||||
value: {},
|
||||
onPatch,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
const tokenInput = container.querySelector(
|
||||
"input[type='password']",
|
||||
) as HTMLInputElement | null;
|
||||
expect(tokenInput).not.toBeNull();
|
||||
if (!tokenInput) return;
|
||||
tokenInput.value = "abc123";
|
||||
tokenInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
expect(onPatch).toHaveBeenCalledWith(
|
||||
["gateway", "auth", "token"],
|
||||
"abc123",
|
||||
);
|
||||
|
||||
const select = container.querySelector("select") as HTMLSelectElement | null;
|
||||
expect(select).not.toBeNull();
|
||||
if (!select) return;
|
||||
select.value = "token";
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
expect(onPatch).toHaveBeenCalledWith(["mode"], "token");
|
||||
|
||||
const checkbox = container.querySelector(
|
||||
"input[type='checkbox']",
|
||||
) as HTMLInputElement | null;
|
||||
expect(checkbox).not.toBeNull();
|
||||
if (!checkbox) return;
|
||||
checkbox.checked = true;
|
||||
checkbox.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
expect(onPatch).toHaveBeenCalledWith(["enabled"], true);
|
||||
});
|
||||
|
||||
it("adds and removes array entries", () => {
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderConfigForm({
|
||||
schema: rootSchema,
|
||||
uiHints: {},
|
||||
value: { allowFrom: ["+1"] },
|
||||
onPatch,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
const addButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(btn) => btn.textContent?.trim() === "Add",
|
||||
);
|
||||
expect(addButton).not.toBeUndefined();
|
||||
addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(onPatch).toHaveBeenCalledWith(["allowFrom"], ["+1", ""]);
|
||||
|
||||
const removeButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(btn) => btn.textContent?.trim() === "Remove",
|
||||
);
|
||||
expect(removeButton).not.toBeUndefined();
|
||||
removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(onPatch).toHaveBeenCalledWith(["allowFrom"], []);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { GatewayBrowserClient } from "../gateway";
|
||||
import type { ConfigSnapshot } from "../types";
|
||||
import type {
|
||||
ConfigSchemaResponse,
|
||||
ConfigSnapshot,
|
||||
ConfigUiHints,
|
||||
} from "../types";
|
||||
import {
|
||||
defaultDiscordActions,
|
||||
type DiscordActionForm,
|
||||
@@ -20,6 +24,13 @@ export type ConfigState = {
|
||||
configIssues: unknown[];
|
||||
configSaving: boolean;
|
||||
configSnapshot: ConfigSnapshot | null;
|
||||
configSchema: unknown | null;
|
||||
configSchemaVersion: string | null;
|
||||
configSchemaLoading: boolean;
|
||||
configUiHints: ConfigUiHints;
|
||||
configForm: Record<string, unknown> | null;
|
||||
configFormDirty: boolean;
|
||||
configFormMode: "form" | "raw";
|
||||
lastError: string | null;
|
||||
telegramForm: TelegramForm;
|
||||
discordForm: DiscordForm;
|
||||
@@ -45,6 +56,32 @@ export async function loadConfig(state: ConfigState) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadConfigSchema(state: ConfigState) {
|
||||
if (!state.client || !state.connected) return;
|
||||
if (state.configSchemaLoading) return;
|
||||
state.configSchemaLoading = true;
|
||||
try {
|
||||
const res = (await state.client.request(
|
||||
"config.schema",
|
||||
{},
|
||||
)) as ConfigSchemaResponse;
|
||||
applyConfigSchema(state, res);
|
||||
} catch (err) {
|
||||
state.lastError = String(err);
|
||||
} finally {
|
||||
state.configSchemaLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function applyConfigSchema(
|
||||
state: ConfigState,
|
||||
res: ConfigSchemaResponse,
|
||||
) {
|
||||
state.configSchema = res.schema ?? null;
|
||||
state.configUiHints = res.uiHints ?? {};
|
||||
state.configSchemaVersion = res.version ?? null;
|
||||
}
|
||||
|
||||
export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot) {
|
||||
state.configSnapshot = snapshot;
|
||||
if (typeof snapshot.raw === "string") {
|
||||
@@ -239,6 +276,10 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot
|
||||
state.discordConfigStatus = configInvalid;
|
||||
state.signalConfigStatus = configInvalid;
|
||||
state.imessageConfigStatus = configInvalid;
|
||||
|
||||
if (!state.configFormDirty) {
|
||||
state.configForm = cloneConfigObject(snapshot.config ?? {});
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveConfig(state: ConfigState) {
|
||||
@@ -246,7 +287,12 @@ export async function saveConfig(state: ConfigState) {
|
||||
state.configSaving = true;
|
||||
state.lastError = null;
|
||||
try {
|
||||
await state.client.request("config.set", { raw: state.configRaw });
|
||||
const raw =
|
||||
state.configFormMode === "form" && state.configForm
|
||||
? `${JSON.stringify(state.configForm, null, 2).trimEnd()}\n`
|
||||
: state.configRaw;
|
||||
await state.client.request("config.set", { raw });
|
||||
state.configFormDirty = false;
|
||||
await loadConfig(state);
|
||||
} catch (err) {
|
||||
state.lastError = String(err);
|
||||
@@ -254,3 +300,101 @@ export async function saveConfig(state: ConfigState) {
|
||||
state.configSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function updateConfigFormValue(
|
||||
state: ConfigState,
|
||||
path: Array<string | number>,
|
||||
value: unknown,
|
||||
) {
|
||||
const base = cloneConfigObject(state.configForm ?? {});
|
||||
setPathValue(base, path, value);
|
||||
state.configForm = base;
|
||||
state.configFormDirty = true;
|
||||
}
|
||||
|
||||
export function removeConfigFormValue(
|
||||
state: ConfigState,
|
||||
path: Array<string | number>,
|
||||
) {
|
||||
const base = cloneConfigObject(state.configForm ?? {});
|
||||
removePathValue(base, path);
|
||||
state.configForm = base;
|
||||
state.configFormDirty = true;
|
||||
}
|
||||
|
||||
function cloneConfigObject<T>(value: T): T {
|
||||
if (typeof structuredClone === "function") {
|
||||
return structuredClone(value);
|
||||
}
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
function setPathValue(
|
||||
obj: Record<string, unknown> | unknown[],
|
||||
path: Array<string | number>,
|
||||
value: unknown,
|
||||
) {
|
||||
if (path.length === 0) return;
|
||||
let current: Record<string, unknown> | unknown[] = obj;
|
||||
for (let i = 0; i < path.length - 1; i += 1) {
|
||||
const key = path[i];
|
||||
const nextKey = path[i + 1];
|
||||
if (typeof key === "number") {
|
||||
if (!Array.isArray(current)) return;
|
||||
if (current[key] == null) {
|
||||
current[key] =
|
||||
typeof nextKey === "number" ? [] : ({} as Record<string, unknown>);
|
||||
}
|
||||
current = current[key] as Record<string, unknown> | unknown[];
|
||||
} else {
|
||||
if (typeof current !== "object" || current == null) return;
|
||||
const record = current as Record<string, unknown>;
|
||||
if (record[key] == null) {
|
||||
record[key] =
|
||||
typeof nextKey === "number" ? [] : ({} as Record<string, unknown>);
|
||||
}
|
||||
current = record[key] as Record<string, unknown> | unknown[];
|
||||
}
|
||||
}
|
||||
const lastKey = path[path.length - 1];
|
||||
if (typeof lastKey === "number") {
|
||||
if (Array.isArray(current)) {
|
||||
current[lastKey] = value;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeof current === "object" && current != null) {
|
||||
(current as Record<string, unknown>)[lastKey] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function removePathValue(
|
||||
obj: Record<string, unknown> | unknown[],
|
||||
path: Array<string | number>,
|
||||
) {
|
||||
if (path.length === 0) return;
|
||||
let current: Record<string, unknown> | unknown[] = obj;
|
||||
for (let i = 0; i < path.length - 1; i += 1) {
|
||||
const key = path[i];
|
||||
if (typeof key === "number") {
|
||||
if (!Array.isArray(current)) return;
|
||||
current = current[key] as Record<string, unknown> | unknown[];
|
||||
} else {
|
||||
if (typeof current !== "object" || current == null) return;
|
||||
current = (current as Record<string, unknown>)[key] as
|
||||
| Record<string, unknown>
|
||||
| unknown[];
|
||||
}
|
||||
if (current == null) return;
|
||||
}
|
||||
const lastKey = path[path.length - 1];
|
||||
if (typeof lastKey === "number") {
|
||||
if (Array.isArray(current)) {
|
||||
current.splice(lastKey, 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeof current === "object" && current != null) {
|
||||
delete (current as Record<string, unknown>)[lastKey];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +140,26 @@ export type ConfigSnapshot = {
|
||||
issues?: ConfigSnapshotIssue[] | null;
|
||||
};
|
||||
|
||||
export type ConfigUiHint = {
|
||||
label?: string;
|
||||
help?: string;
|
||||
group?: string;
|
||||
order?: number;
|
||||
advanced?: boolean;
|
||||
sensitive?: boolean;
|
||||
placeholder?: string;
|
||||
itemTemplate?: unknown;
|
||||
};
|
||||
|
||||
export type ConfigUiHints = Record<string, ConfigUiHint>;
|
||||
|
||||
export type ConfigSchemaResponse = {
|
||||
schema: unknown;
|
||||
uiHints: ConfigUiHints;
|
||||
version: string;
|
||||
generatedAt: string;
|
||||
};
|
||||
|
||||
export type PresenceEntry = {
|
||||
instanceId?: string | null;
|
||||
host?: string | null;
|
||||
|
||||
274
ui/src/ui/views/config-form.ts
Normal file
274
ui/src/ui/views/config-form.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { html, nothing } from "lit";
|
||||
import type { ConfigUiHint, ConfigUiHints } from "../types";
|
||||
|
||||
export type ConfigFormProps = {
|
||||
schema: unknown | null;
|
||||
uiHints: ConfigUiHints;
|
||||
value: Record<string, unknown> | null;
|
||||
onPatch: (path: Array<string | number>, value: unknown) => void;
|
||||
};
|
||||
|
||||
type JsonSchema = {
|
||||
type?: string | string[];
|
||||
title?: string;
|
||||
description?: string;
|
||||
properties?: Record<string, JsonSchema>;
|
||||
items?: JsonSchema | JsonSchema[];
|
||||
enum?: unknown[];
|
||||
default?: unknown;
|
||||
anyOf?: JsonSchema[];
|
||||
oneOf?: JsonSchema[];
|
||||
allOf?: JsonSchema[];
|
||||
};
|
||||
|
||||
export function renderConfigForm(props: ConfigFormProps) {
|
||||
if (!props.schema) {
|
||||
return html`<div class="muted">Schema unavailable.</div>`;
|
||||
}
|
||||
const schema = props.schema as JsonSchema;
|
||||
const value = props.value ?? {};
|
||||
if (schemaType(schema) !== "object" || !schema.properties) {
|
||||
return html`<div class="callout danger">Unsupported schema. Use Raw.</div>`;
|
||||
}
|
||||
const entries = Object.entries(schema.properties);
|
||||
const sorted = entries.sort((a, b) => {
|
||||
const orderA = hintForPath([a[0]], props.uiHints)?.order ?? 0;
|
||||
const orderB = hintForPath([b[0]], props.uiHints)?.order ?? 0;
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
return a[0].localeCompare(b[0]);
|
||||
});
|
||||
|
||||
return html`
|
||||
<div class="config-form">
|
||||
${sorted.map(([key, node]) =>
|
||||
renderNode({
|
||||
schema: node,
|
||||
value: (value as Record<string, unknown>)[key],
|
||||
path: [key],
|
||||
hints: props.uiHints,
|
||||
onPatch: props.onPatch,
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderNode(params: {
|
||||
schema: JsonSchema;
|
||||
value: unknown;
|
||||
path: Array<string | number>;
|
||||
hints: ConfigUiHints;
|
||||
onPatch: (path: Array<string | number>, value: unknown) => void;
|
||||
}) {
|
||||
const { schema, value, path, hints, onPatch } = params;
|
||||
const type = schemaType(schema);
|
||||
const hint = hintForPath(path, hints);
|
||||
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
|
||||
const help = hint?.help ?? schema.description;
|
||||
|
||||
if (schema.anyOf || schema.oneOf || schema.allOf) {
|
||||
return html`<div class="callout danger">
|
||||
${label}: unsupported schema node. Use Raw.
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (type === "object") {
|
||||
const props = schema.properties ?? {};
|
||||
const entries = Object.entries(props);
|
||||
if (entries.length === 0) return nothing;
|
||||
return html`
|
||||
<fieldset class="field-group">
|
||||
<legend>${label}</legend>
|
||||
${help ? html`<div class="muted">${help}</div>` : nothing}
|
||||
${entries.map(([key, node]) =>
|
||||
renderNode({
|
||||
schema: node,
|
||||
value: value && typeof value === "object" ? (value as any)[key] : undefined,
|
||||
path: [...path, key],
|
||||
hints,
|
||||
onPatch,
|
||||
}),
|
||||
)}
|
||||
</fieldset>
|
||||
`;
|
||||
}
|
||||
|
||||
if (type === "array") {
|
||||
const itemSchema = Array.isArray(schema.items)
|
||||
? schema.items[0]
|
||||
: schema.items;
|
||||
const arr = Array.isArray(value) ? value : [];
|
||||
return html`
|
||||
<div class="field">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<span>${label}</span>
|
||||
<button
|
||||
class="btn"
|
||||
@click=${() => {
|
||||
const next = [...arr, defaultValue(itemSchema)];
|
||||
onPatch(path, next);
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
${help ? html`<div class="muted">${help}</div>` : nothing}
|
||||
${arr.map((entry, index) =>
|
||||
html`<div class="array-item">
|
||||
${itemSchema
|
||||
? renderNode({
|
||||
schema: itemSchema,
|
||||
value: entry,
|
||||
path: [...path, index],
|
||||
hints,
|
||||
onPatch,
|
||||
})
|
||||
: nothing}
|
||||
<button
|
||||
class="btn danger"
|
||||
@click=${() => {
|
||||
const next = arr.slice();
|
||||
next.splice(index, 1);
|
||||
onPatch(path, next);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>`,
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (schema.enum) {
|
||||
return html`
|
||||
<label class="field">
|
||||
<span>${label}</span>
|
||||
${help ? html`<div class="muted">${help}</div>` : nothing}
|
||||
<select
|
||||
.value=${value == null ? "" : String(value)}
|
||||
@change=${(e: Event) =>
|
||||
onPatch(path, (e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
${schema.enum.map(
|
||||
(opt) => html`<option value=${String(opt)}>${String(opt)}</option>`,
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
`;
|
||||
}
|
||||
|
||||
if (type === "boolean") {
|
||||
return html`
|
||||
<label class="field">
|
||||
<span>${label}</span>
|
||||
${help ? html`<div class="muted">${help}</div>` : nothing}
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${Boolean(value)}
|
||||
@change=${(e: Event) =>
|
||||
onPatch(path, (e.target as HTMLInputElement).checked)}
|
||||
/>
|
||||
</label>
|
||||
`;
|
||||
}
|
||||
|
||||
if (type === "number" || type === "integer") {
|
||||
return html`
|
||||
<label class="field">
|
||||
<span>${label}</span>
|
||||
${help ? html`<div class="muted">${help}</div>` : nothing}
|
||||
<input
|
||||
type="number"
|
||||
.value=${value == null ? "" : String(value)}
|
||||
@input=${(e: Event) => {
|
||||
const raw = (e.target as HTMLInputElement).value;
|
||||
const parsed = raw === "" ? undefined : Number(raw);
|
||||
onPatch(path, parsed);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
`;
|
||||
}
|
||||
|
||||
if (type === "string") {
|
||||
const isSensitive = hint?.sensitive ?? isSensitivePath(path);
|
||||
const placeholder = hint?.placeholder ?? (isSensitive ? "••••" : "");
|
||||
return html`
|
||||
<label class="field">
|
||||
<span>${label}</span>
|
||||
${help ? html`<div class="muted">${help}</div>` : nothing}
|
||||
<input
|
||||
type=${isSensitive ? "password" : "text"}
|
||||
placeholder=${placeholder}
|
||||
.value=${value == null ? "" : String(value)}
|
||||
@input=${(e: Event) =>
|
||||
onPatch(path, (e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</label>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`<div class="field">
|
||||
<span>${label}</span>
|
||||
<div class="muted">Unsupported type. Use Raw.</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function schemaType(schema: JsonSchema): string | undefined {
|
||||
if (!schema) return undefined;
|
||||
if (Array.isArray(schema.type)) {
|
||||
const filtered = schema.type.filter((t) => t !== "null");
|
||||
return filtered[0] ?? schema.type[0];
|
||||
}
|
||||
return schema.type;
|
||||
}
|
||||
|
||||
function defaultValue(schema?: JsonSchema): unknown {
|
||||
if (!schema) return "";
|
||||
if (schema.default !== undefined) return schema.default;
|
||||
const type = schemaType(schema);
|
||||
switch (type) {
|
||||
case "object":
|
||||
return {};
|
||||
case "array":
|
||||
return [];
|
||||
case "boolean":
|
||||
return false;
|
||||
case "number":
|
||||
case "integer":
|
||||
return 0;
|
||||
case "string":
|
||||
return "";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function hintForPath(path: Array<string | number>, hints: ConfigUiHints) {
|
||||
const key = pathKey(path);
|
||||
return hints[key];
|
||||
}
|
||||
|
||||
function pathKey(path: Array<string | number>): string {
|
||||
return path.filter((segment) => typeof segment === "string").join(".");
|
||||
}
|
||||
|
||||
function humanize(raw: string) {
|
||||
return raw
|
||||
.replace(/_/g, " ")
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/^./, (m) => m.toUpperCase());
|
||||
}
|
||||
|
||||
function isSensitivePath(path: Array<string | number>): boolean {
|
||||
const key = pathKey(path).toLowerCase();
|
||||
return (
|
||||
key.includes("token") ||
|
||||
key.includes("password") ||
|
||||
key.includes("secret") ||
|
||||
key.includes("apikey") ||
|
||||
key.endsWith("key")
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { html, nothing } from "lit";
|
||||
import type { ConfigUiHints } from "../types";
|
||||
import { renderConfigForm } from "./config-form";
|
||||
|
||||
export type ConfigProps = {
|
||||
raw: string;
|
||||
@@ -7,7 +9,14 @@ export type ConfigProps = {
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
connected: boolean;
|
||||
schema: unknown | null;
|
||||
schemaLoading: boolean;
|
||||
uiHints: ConfigUiHints;
|
||||
formMode: "form" | "raw";
|
||||
formValue: Record<string, unknown> | null;
|
||||
onRawChange: (next: string) => void;
|
||||
onFormModeChange: (mode: "form" | "raw") => void;
|
||||
onFormPatch: (path: Array<string | number>, value: unknown) => void;
|
||||
onReload: () => void;
|
||||
onSave: () => void;
|
||||
};
|
||||
@@ -23,6 +32,21 @@ export function renderConfig(props: ConfigProps) {
|
||||
<span class="pill">${validity}</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="toggle-group">
|
||||
<button
|
||||
class="btn ${props.formMode === "form" ? "primary" : ""}"
|
||||
?disabled=${props.schemaLoading || !props.schema}
|
||||
@click=${() => props.onFormModeChange("form")}
|
||||
>
|
||||
Form
|
||||
</button>
|
||||
<button
|
||||
class="btn ${props.formMode === "raw" ? "primary" : ""}"
|
||||
@click=${() => props.onFormModeChange("raw")}
|
||||
>
|
||||
Raw
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn" ?disabled=${props.loading} @click=${props.onReload}>
|
||||
${props.loading ? "Loading…" : "Reload"}
|
||||
</button>
|
||||
@@ -41,14 +65,25 @@ export function renderConfig(props: ConfigProps) {
|
||||
require a gateway restart.
|
||||
</div>
|
||||
|
||||
<label class="field" style="margin-top: 12px;">
|
||||
<span>Raw JSON5</span>
|
||||
<textarea
|
||||
.value=${props.raw}
|
||||
@input=${(e: Event) =>
|
||||
props.onRawChange((e.target as HTMLTextAreaElement).value)}
|
||||
></textarea>
|
||||
</label>
|
||||
${props.formMode === "form"
|
||||
? html`<div style="margin-top: 12px;">
|
||||
${props.schemaLoading
|
||||
? html`<div class="muted">Loading schema…</div>`
|
||||
: renderConfigForm({
|
||||
schema: props.schema,
|
||||
uiHints: props.uiHints,
|
||||
value: props.formValue,
|
||||
onPatch: props.onFormPatch,
|
||||
})}
|
||||
</div>`
|
||||
: html`<label class="field" style="margin-top: 12px;">
|
||||
<span>Raw JSON5</span>
|
||||
<textarea
|
||||
.value=${props.raw}
|
||||
@input=${(e: Event) =>
|
||||
props.onRawChange((e.target as HTMLTextAreaElement).value)}
|
||||
></textarea>
|
||||
</label>`}
|
||||
|
||||
${props.issues.length > 0
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">
|
||||
@@ -58,4 +93,3 @@ export function renderConfig(props: ConfigProps) {
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user