mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:10:44 +00:00
feat(ui): show raw config pending changes
Adds a raw config pending-changes diff panel in Control UI raw mode, with JSON5 parsing, sensitive-value redaction until explicit reveal, bounded diff work, and tests for redaction/reveal and stale reveal-state reset. Also aligns provider manifest contract coverage for google-vertex and Qwen aliases to unblock the rebased CI matrix. Supersedes stale PRs #48621 and #46654. PR #48621 had gone stale without maintainer follow-up, so this maintainer-authored PR carries the implementation forward transparently while preserving changelog credit for the original contributor and @BunsDev.
This commit is contained in:
@@ -9,6 +9,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Providers: add Cerebras as a bundled plugin with onboarding, static model catalog, docs, and manifest-owned endpoint metadata. Thanks @codex.
|
||||
- Plugins/providers: move pre-runtime model-id normalization, provider endpoint host metadata, and OpenAI-compatible request-family hints into plugin manifests so core no longer carries bundled-provider routing tables. Thanks @codex.
|
||||
- Plugins/install: allow `OPENCLAW_PLUGIN_STAGE_DIR` to contain layered runtime-dependency roots, resolving read-only preinstalled deps before installing missing deps into the final writable root. Fixes #72396. Thanks @liorb-mountapps.
|
||||
- Control UI: add a raw config pending-changes diff panel that parses JSON5, redacts sensitive values until reveal, and avoids fake raw-edit callbacks when opening the panel. Refs #39831; supersedes #48621 and #46654. Thanks @JiajunBernoulli and @BunsDev.
|
||||
- Control UI: polish the quick settings dashboard grid so common cards align across desktop, tablet, and mobile layouts without wasting horizontal space. Thanks @BunsDev.
|
||||
- Matrix/E2EE: add `openclaw matrix encryption setup` to enable Matrix encryption, bootstrap recovery, and print verification status from one setup flow. Thanks @gumadeiras.
|
||||
- Agents/compaction: add an opt-in `agents.defaults.compaction.maxActiveTranscriptBytes` preflight trigger that runs normal local compaction when the active JSONL grows too large, requiring transcript rotation so successful compaction moves future turns onto a smaller successor file instead of raw byte-splitting history. Thanks @vincentkoc.
|
||||
|
||||
@@ -28,6 +28,22 @@ export function createGoogleProvider(): ProviderPlugin {
|
||||
};
|
||||
}
|
||||
|
||||
export function createGoogleVertexProvider(): ProviderPlugin {
|
||||
return {
|
||||
id: "google-vertex",
|
||||
label: "Google Vertex AI",
|
||||
docsPath: "/providers/models",
|
||||
envVars: [
|
||||
"GOOGLE_CLOUD_API_KEY",
|
||||
"GOOGLE_CLOUD_PROJECT",
|
||||
"GCLOUD_PROJECT",
|
||||
"GOOGLE_CLOUD_LOCATION",
|
||||
"GOOGLE_APPLICATION_CREDENTIALS",
|
||||
],
|
||||
auth: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function createGoogleGeminiCliProvider(): ProviderPlugin {
|
||||
return {
|
||||
id: "google-gemini-cli",
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -1537,6 +1537,9 @@ importers:
|
||||
dompurify:
|
||||
specifier: ^3.4.1
|
||||
version: 3.4.1
|
||||
json5:
|
||||
specifier: ^2.2.3
|
||||
version: 2.2.3
|
||||
lit:
|
||||
specifier: ^3.3.2
|
||||
version: 3.3.2
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"@create-markdown/preview": "^2.0.3",
|
||||
"@noble/ed25519": "3.1.0",
|
||||
"dompurify": "^3.4.1",
|
||||
"json5": "^2.2.3",
|
||||
"lit": "^3.3.2",
|
||||
"markdown-it": "^14.1.1",
|
||||
"markdown-it-task-lists": "^2.1.1",
|
||||
|
||||
@@ -64,7 +64,7 @@ export function hintForPath(path: Array<string | number>, hints: ConfigUiHints)
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
const segments = key.split(".");
|
||||
const segments = path.map(String);
|
||||
for (const [hintKey, hint] of Object.entries(hints)) {
|
||||
if (!hintKey.includes("*")) {
|
||||
continue;
|
||||
@@ -120,6 +120,28 @@ const ENV_VAR_PLACEHOLDER_PATTERN = /^\$\{[^}]*\}$/;
|
||||
|
||||
export const REDACTED_PLACEHOLDER = "[redacted - click reveal to view]";
|
||||
|
||||
const MAX_SENSITIVE_SCAN_DEPTH = 64;
|
||||
const MAX_SENSITIVE_SCAN_NODES = 20_000;
|
||||
|
||||
type SensitiveScanState = {
|
||||
visited: number;
|
||||
};
|
||||
|
||||
function createSensitiveScanState(): SensitiveScanState {
|
||||
return { visited: 0 };
|
||||
}
|
||||
|
||||
function enterSensitiveScanNode(state: SensitiveScanState, depth: number): boolean {
|
||||
if (depth > MAX_SENSITIVE_SCAN_DEPTH) {
|
||||
return false;
|
||||
}
|
||||
state.visited += 1;
|
||||
if (state.visited > MAX_SENSITIVE_SCAN_NODES) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isEnvVarPlaceholder(value: string): boolean {
|
||||
return ENV_VAR_PLACEHOLDER_PATTERN.test(value.trim());
|
||||
}
|
||||
@@ -146,6 +168,20 @@ export function hasSensitiveConfigData(
|
||||
path: Array<string | number>,
|
||||
hints: ConfigUiHints,
|
||||
): boolean {
|
||||
return hasSensitiveConfigDataInner(value, path, hints, createSensitiveScanState(), 0);
|
||||
}
|
||||
|
||||
function hasSensitiveConfigDataInner(
|
||||
value: unknown,
|
||||
path: Array<string | number>,
|
||||
hints: ConfigUiHints,
|
||||
scan: SensitiveScanState,
|
||||
depth: number,
|
||||
): boolean {
|
||||
if (!enterSensitiveScanNode(scan, depth)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const key = pathKey(path);
|
||||
const hint = hintForPath(path, hints);
|
||||
const pathIsSensitive = isHintSensitive(hint) || isSensitiveConfigPath(key);
|
||||
@@ -155,12 +191,14 @@ export function hasSensitiveConfigData(
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.some((item, index) => hasSensitiveConfigData(item, [...path, index], hints));
|
||||
return value.some((item, index) =>
|
||||
hasSensitiveConfigDataInner(item, [...path, index], hints, scan, depth + 1),
|
||||
);
|
||||
}
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
return Object.entries(value as Record<string, unknown>).some(([childKey, childValue]) =>
|
||||
hasSensitiveConfigData(childValue, [...path, childKey], hints),
|
||||
hasSensitiveConfigDataInner(childValue, [...path, childKey], hints, scan, depth + 1),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -172,6 +210,20 @@ export function countSensitiveConfigValues(
|
||||
path: Array<string | number>,
|
||||
hints: ConfigUiHints,
|
||||
): number {
|
||||
return countSensitiveConfigValuesInner(value, path, hints, createSensitiveScanState(), 0);
|
||||
}
|
||||
|
||||
function countSensitiveConfigValuesInner(
|
||||
value: unknown,
|
||||
path: Array<string | number>,
|
||||
hints: ConfigUiHints,
|
||||
scan: SensitiveScanState,
|
||||
depth: number,
|
||||
): number {
|
||||
if (!enterSensitiveScanNode(scan, depth)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (value == null) {
|
||||
return 0;
|
||||
}
|
||||
@@ -186,7 +238,8 @@ export function countSensitiveConfigValues(
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.reduce(
|
||||
(count, item, index) => count + countSensitiveConfigValues(item, [...path, index], hints),
|
||||
(count, item, index) =>
|
||||
count + countSensitiveConfigValuesInner(item, [...path, index], hints, scan, depth + 1),
|
||||
0,
|
||||
);
|
||||
}
|
||||
@@ -194,7 +247,8 @@ export function countSensitiveConfigValues(
|
||||
if (value && typeof value === "object") {
|
||||
return Object.entries(value as Record<string, unknown>).reduce(
|
||||
(count, [childKey, childValue]) =>
|
||||
count + countSensitiveConfigValues(childValue, [...path, childKey], hints),
|
||||
count +
|
||||
countSensitiveConfigValuesInner(childValue, [...path, childKey], hints, scan, depth + 1),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -464,6 +464,283 @@ describe("config view", () => {
|
||||
expect(onRawChange).toHaveBeenCalledWith(textarea.value);
|
||||
});
|
||||
|
||||
it("opens raw pending changes without sending a fake raw edit", () => {
|
||||
const container = document.createElement("div");
|
||||
const onRawChange = vi.fn();
|
||||
let updateCount = 0;
|
||||
const props: ConfigProps = {
|
||||
...baseProps(),
|
||||
formMode: "raw",
|
||||
raw: '{\n gateway: { mode: "remote" }\n}\n',
|
||||
originalRaw: '{\n gateway: { mode: "local" }\n}\n',
|
||||
formValue: {
|
||||
gateway: {
|
||||
mode: "remote",
|
||||
},
|
||||
},
|
||||
originalValue: {
|
||||
gateway: {
|
||||
mode: "local",
|
||||
},
|
||||
},
|
||||
onRawChange,
|
||||
};
|
||||
const rerender = () =>
|
||||
render(
|
||||
renderConfig({
|
||||
...props,
|
||||
onRequestUpdate: () => {
|
||||
updateCount += 1;
|
||||
rerender();
|
||||
},
|
||||
}),
|
||||
container,
|
||||
);
|
||||
rerender();
|
||||
|
||||
expect(normalizedText(container)).toContain("View pending changes");
|
||||
expect(normalizedText(container)).not.toContain("gateway.mode");
|
||||
|
||||
const details = container.querySelector<HTMLDetailsElement>(".config-diff");
|
||||
expect(details).not.toBeNull();
|
||||
details!.open = true;
|
||||
details!.dispatchEvent(new Event("toggle"));
|
||||
|
||||
const text = normalizedText(container);
|
||||
expect(updateCount).toBe(1);
|
||||
expect(onRawChange).not.toHaveBeenCalled();
|
||||
expect(text).toContain("gateway.mode");
|
||||
expect(text).toContain('"local"');
|
||||
expect(text).toContain('"remote"');
|
||||
});
|
||||
|
||||
it("renders array diff summaries without serializing array values", () => {
|
||||
const poison = {
|
||||
value: "TOKEN_AFTER",
|
||||
toJSON: () => {
|
||||
throw new Error("array value should not be serialized");
|
||||
},
|
||||
};
|
||||
const { container } = renderConfigView({
|
||||
formValue: {
|
||||
items: [poison],
|
||||
},
|
||||
originalValue: {
|
||||
items: [],
|
||||
},
|
||||
});
|
||||
|
||||
const text = normalizedText(container);
|
||||
expect(text).toContain("View 1 pending change");
|
||||
expect(text).toContain("items");
|
||||
expect(text).toContain("[0 items]");
|
||||
expect(text).toContain("[1 item]");
|
||||
});
|
||||
|
||||
it("redacts sensitive values in raw pending changes until raw values are revealed", () => {
|
||||
const container = document.createElement("div");
|
||||
const props: ConfigProps = {
|
||||
...baseProps(),
|
||||
formMode: "raw",
|
||||
raw: '{\n channels: { discord: { token: { id: "TOKEN_AFTER" } } }\n}\n',
|
||||
originalRaw: '{\n channels: { discord: { token: { id: "TOKEN_BEFORE" } } }\n}\n',
|
||||
uiHints: {
|
||||
"channels.discord.token": { sensitive: true },
|
||||
},
|
||||
formValue: {
|
||||
channels: {
|
||||
discord: {
|
||||
token: {
|
||||
id: "TOKEN_AFTER",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
originalValue: {
|
||||
channels: {
|
||||
discord: {
|
||||
token: {
|
||||
id: "TOKEN_BEFORE",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const rerender = () =>
|
||||
render(
|
||||
renderConfig({
|
||||
...props,
|
||||
onRequestUpdate: rerender,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
rerender();
|
||||
|
||||
const details = container.querySelector<HTMLDetailsElement>(".config-diff");
|
||||
expect(details).not.toBeNull();
|
||||
details!.open = true;
|
||||
details!.dispatchEvent(new Event("toggle"));
|
||||
|
||||
const text = normalizedText(container);
|
||||
expect(text).toContain("channels.discord.token.id");
|
||||
expect(text).toContain("[redacted - click reveal to view]");
|
||||
expect(text).not.toContain("TOKEN_BEFORE");
|
||||
expect(text).not.toContain("TOKEN_AFTER");
|
||||
|
||||
const revealButton = container.querySelector<HTMLButtonElement>(".config-raw-toggle");
|
||||
expect(revealButton).not.toBeNull();
|
||||
revealButton!.click();
|
||||
|
||||
const revealedText = normalizedText(container);
|
||||
expect(revealedText).toContain("TOKEN_BEFORE");
|
||||
expect(revealedText).toContain("TOKEN_AFTER");
|
||||
});
|
||||
|
||||
it("resets raw reveal state when the config context changes", () => {
|
||||
const container = document.createElement("div");
|
||||
const props: ConfigProps = {
|
||||
...baseProps(),
|
||||
configPath: "/tmp/openclaw-a.json5",
|
||||
formMode: "raw",
|
||||
raw: '{\n token: "TOKEN_A_AFTER"\n}\n',
|
||||
originalRaw: '{\n token: "TOKEN_A_BEFORE"\n}\n',
|
||||
uiHints: {
|
||||
token: { sensitive: true },
|
||||
},
|
||||
formValue: {
|
||||
token: "TOKEN_A_AFTER",
|
||||
},
|
||||
originalValue: {
|
||||
token: "TOKEN_A_BEFORE",
|
||||
},
|
||||
};
|
||||
const rerender = () =>
|
||||
render(
|
||||
renderConfig({
|
||||
...props,
|
||||
onRequestUpdate: rerender,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
rerender();
|
||||
|
||||
const details = container.querySelector<HTMLDetailsElement>(".config-diff");
|
||||
expect(details).not.toBeNull();
|
||||
details!.open = true;
|
||||
details!.dispatchEvent(new Event("toggle"));
|
||||
const revealButton = container.querySelector<HTMLButtonElement>(".config-raw-toggle");
|
||||
expect(revealButton).not.toBeNull();
|
||||
revealButton!.click();
|
||||
expect(normalizedText(container)).toContain("TOKEN_A_AFTER");
|
||||
|
||||
props.configPath = "/tmp/openclaw-b.json5";
|
||||
props.raw = '{\n token: "TOKEN_B_AFTER"\n}\n';
|
||||
props.originalRaw = '{\n token: "TOKEN_B_BEFORE"\n}\n';
|
||||
props.formValue = {
|
||||
token: "TOKEN_B_AFTER",
|
||||
};
|
||||
props.originalValue = {
|
||||
token: "TOKEN_B_BEFORE",
|
||||
};
|
||||
rerender();
|
||||
|
||||
const text = normalizedText(container);
|
||||
expect(text).toContain("1 secret redacted");
|
||||
expect(text).not.toContain("TOKEN_A_AFTER");
|
||||
expect(text).not.toContain("TOKEN_B_AFTER");
|
||||
expect(container.querySelector("textarea")).toBeNull();
|
||||
expect(container.querySelector<HTMLDetailsElement>(".config-diff")?.open).toBe(false);
|
||||
});
|
||||
|
||||
it("redacts raw diff values under leaf wildcard sensitive hints when keys contain dots", () => {
|
||||
const container = document.createElement("div");
|
||||
const props: ConfigProps = {
|
||||
...baseProps(),
|
||||
formMode: "raw",
|
||||
raw: '{\n integrations: { "foo.bar": { credential: "TOKEN_AFTER" } }\n}\n',
|
||||
originalRaw: '{\n integrations: { "foo.bar": { credential: "TOKEN_BEFORE" } }\n}\n',
|
||||
uiHints: {
|
||||
"integrations.*.credential": { sensitive: true },
|
||||
},
|
||||
formValue: {
|
||||
integrations: {
|
||||
"foo.bar": {
|
||||
credential: "TOKEN_AFTER",
|
||||
},
|
||||
},
|
||||
},
|
||||
originalValue: {
|
||||
integrations: {
|
||||
"foo.bar": {
|
||||
credential: "TOKEN_BEFORE",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const rerender = () =>
|
||||
render(
|
||||
renderConfig({
|
||||
...props,
|
||||
onRequestUpdate: rerender,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
rerender();
|
||||
|
||||
const details = container.querySelector<HTMLDetailsElement>(".config-diff");
|
||||
expect(details).not.toBeNull();
|
||||
details!.open = true;
|
||||
details!.dispatchEvent(new Event("toggle"));
|
||||
|
||||
const text = normalizedText(container);
|
||||
expect(text).toContain("integrations.foo.bar.credential");
|
||||
expect(text).toContain("[redacted - click reveal to view]");
|
||||
expect(text).not.toContain("TOKEN_BEFORE");
|
||||
expect(text).not.toContain("TOKEN_AFTER");
|
||||
});
|
||||
|
||||
it("removes the raw pending changes panel after raw changes clear", () => {
|
||||
const container = document.createElement("div");
|
||||
const props: ConfigProps = {
|
||||
...baseProps(),
|
||||
formMode: "raw",
|
||||
raw: '{\n gateway: { mode: "remote" }\n}\n',
|
||||
originalRaw: '{\n gateway: { mode: "local" }\n}\n',
|
||||
formValue: {
|
||||
gateway: {
|
||||
mode: "remote",
|
||||
},
|
||||
},
|
||||
originalValue: {
|
||||
gateway: {
|
||||
mode: "local",
|
||||
},
|
||||
},
|
||||
};
|
||||
const rerender = () =>
|
||||
render(
|
||||
renderConfig({
|
||||
...props,
|
||||
onRequestUpdate: rerender,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
rerender();
|
||||
|
||||
const details = container.querySelector<HTMLDetailsElement>(".config-diff");
|
||||
expect(details).not.toBeNull();
|
||||
details!.open = true;
|
||||
details!.dispatchEvent(new Event("toggle"));
|
||||
expect(normalizedText(container)).toContain("gateway.mode");
|
||||
|
||||
props.raw = props.originalRaw;
|
||||
props.formValue = props.originalValue;
|
||||
rerender();
|
||||
|
||||
expect(container.querySelector(".config-diff")).toBeNull();
|
||||
expect(normalizedText(container)).toContain("No changes");
|
||||
});
|
||||
|
||||
it("renders structured SecretRef values without stringifying", () => {
|
||||
const onFormPatch = vi.fn();
|
||||
const secretRefSchema = {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import JSON5 from "json5";
|
||||
import { html, nothing, type TemplateResult } from "lit";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import { icons } from "../icons.ts";
|
||||
@@ -7,6 +8,7 @@ import type { ThemeMode, ThemeName } from "../theme.ts";
|
||||
import type { ConfigUiHints } from "../types.ts";
|
||||
import {
|
||||
countSensitiveConfigValues,
|
||||
hintForPath,
|
||||
humanize,
|
||||
isSensitiveConfigPath,
|
||||
pathKey,
|
||||
@@ -529,32 +531,132 @@ function resolveSectionMeta(
|
||||
};
|
||||
}
|
||||
|
||||
const MAX_CONFIG_DIFF_DEPTH = 64;
|
||||
const MAX_CONFIG_DIFF_NODES = 20_000;
|
||||
const MAX_CONFIG_DIFF_CHANGES = 1_000;
|
||||
const MAX_CONFIG_DIFF_ARRAY_COMPARE_ITEMS = 2_000;
|
||||
const MAX_RAW_DIFF_CHARS = 200_000;
|
||||
|
||||
type ConfigDiffPath = string[];
|
||||
type ConfigDiffEntry = { path: ConfigDiffPath; from: unknown; to: unknown };
|
||||
|
||||
let rawDiffCache:
|
||||
| {
|
||||
original: string;
|
||||
current: string;
|
||||
diff: ConfigDiffEntry[];
|
||||
}
|
||||
| undefined;
|
||||
|
||||
function formatConfigDiffPath(path: ConfigDiffPath): string {
|
||||
return path.length > 0 ? path.join(".") : "<root>";
|
||||
}
|
||||
|
||||
function computeDiff(
|
||||
original: Record<string, unknown> | null,
|
||||
current: Record<string, unknown> | null,
|
||||
): Array<{ path: string; from: unknown; to: unknown }> {
|
||||
): ConfigDiffEntry[] {
|
||||
if (!original || !current) {
|
||||
return [];
|
||||
}
|
||||
const changes: Array<{ path: string; from: unknown; to: unknown }> = [];
|
||||
const changes: ConfigDiffEntry[] = [];
|
||||
let visited = 0;
|
||||
|
||||
function compare(orig: unknown, curr: unknown, path: string) {
|
||||
function pushChange(path: ConfigDiffPath, from: unknown, to: unknown) {
|
||||
if (changes.length < MAX_CONFIG_DIFF_CHANGES) {
|
||||
changes.push({ path, from, to });
|
||||
}
|
||||
}
|
||||
|
||||
function arrayValuesDiffer(orig: unknown[], curr: unknown[], depth: number): boolean {
|
||||
if (orig.length !== curr.length) {
|
||||
return true;
|
||||
}
|
||||
if (orig.length > MAX_CONFIG_DIFF_ARRAY_COMPARE_ITEMS) {
|
||||
return true;
|
||||
}
|
||||
for (let index = 0; index < orig.length; index += 1) {
|
||||
if (valuesDiffer(orig[index], curr[index], depth + 1)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function objectValuesDiffer(
|
||||
orig: Record<string, unknown>,
|
||||
curr: Record<string, unknown>,
|
||||
depth: number,
|
||||
): boolean {
|
||||
const origKeys = Object.keys(orig);
|
||||
const currKeys = Object.keys(curr);
|
||||
if (origKeys.length !== currKeys.length) {
|
||||
return true;
|
||||
}
|
||||
for (const key of origKeys) {
|
||||
if (
|
||||
!Object.prototype.hasOwnProperty.call(curr, key) ||
|
||||
valuesDiffer(orig[key], curr[key], depth + 1)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function valuesDiffer(orig: unknown, curr: unknown, depth: number): boolean {
|
||||
visited += 1;
|
||||
if (visited > MAX_CONFIG_DIFF_NODES || depth > MAX_CONFIG_DIFF_DEPTH) {
|
||||
return true;
|
||||
}
|
||||
if (orig === curr) {
|
||||
return false;
|
||||
}
|
||||
if (typeof orig !== typeof curr) {
|
||||
return true;
|
||||
}
|
||||
if (typeof orig !== "object" || orig === null || curr === null) {
|
||||
return orig !== curr;
|
||||
}
|
||||
if (Array.isArray(orig) || Array.isArray(curr)) {
|
||||
return Array.isArray(orig) && Array.isArray(curr)
|
||||
? arrayValuesDiffer(orig, curr, depth + 1)
|
||||
: true;
|
||||
}
|
||||
return objectValuesDiffer(
|
||||
orig as Record<string, unknown>,
|
||||
curr as Record<string, unknown>,
|
||||
depth + 1,
|
||||
);
|
||||
}
|
||||
|
||||
function compare(orig: unknown, curr: unknown, path: ConfigDiffPath, depth: number) {
|
||||
visited += 1;
|
||||
if (
|
||||
visited > MAX_CONFIG_DIFF_NODES ||
|
||||
depth > MAX_CONFIG_DIFF_DEPTH ||
|
||||
changes.length >= MAX_CONFIG_DIFF_CHANGES
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (orig === curr) {
|
||||
return;
|
||||
}
|
||||
if (typeof orig !== typeof curr) {
|
||||
changes.push({ path, from: orig, to: curr });
|
||||
pushChange(path, orig, curr);
|
||||
return;
|
||||
}
|
||||
if (typeof orig !== "object" || orig === null || curr === null) {
|
||||
if (orig !== curr) {
|
||||
changes.push({ path, from: orig, to: curr });
|
||||
pushChange(path, orig, curr);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(orig) && Array.isArray(curr)) {
|
||||
if (JSON.stringify(orig) !== JSON.stringify(curr)) {
|
||||
changes.push({ path, from: orig, to: curr });
|
||||
if (Array.isArray(orig) || Array.isArray(curr)) {
|
||||
if (Array.isArray(orig) && Array.isArray(curr) && arrayValuesDiffer(orig, curr, depth + 1)) {
|
||||
pushChange(path, orig, curr);
|
||||
} else if (!Array.isArray(orig) || !Array.isArray(curr)) {
|
||||
pushChange(path, orig, curr);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -562,15 +664,52 @@ function computeDiff(
|
||||
const currObj = curr as Record<string, unknown>;
|
||||
const allKeys = new Set([...Object.keys(origObj), ...Object.keys(currObj)]);
|
||||
for (const key of allKeys) {
|
||||
compare(origObj[key], currObj[key], path ? `${path}.${key}` : key);
|
||||
compare(origObj[key], currObj[key], [...path, key], depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
compare(original, current, "");
|
||||
compare(original, current, [], 0);
|
||||
return changes;
|
||||
}
|
||||
|
||||
function computeRawDiff(original: string, current: string): ConfigDiffEntry[] {
|
||||
if (rawDiffCache?.original === original && rawDiffCache.current === current) {
|
||||
return rawDiffCache.diff;
|
||||
}
|
||||
if (original.length > MAX_RAW_DIFF_CHARS || current.length > MAX_RAW_DIFF_CHARS) {
|
||||
rawDiffCache = { original, current, diff: [] };
|
||||
return rawDiffCache.diff;
|
||||
}
|
||||
try {
|
||||
const originalValue = JSON5.parse(original) as unknown;
|
||||
const currentValue = JSON5.parse(current) as unknown;
|
||||
if (
|
||||
!originalValue ||
|
||||
!currentValue ||
|
||||
typeof originalValue !== "object" ||
|
||||
typeof currentValue !== "object" ||
|
||||
Array.isArray(originalValue) ||
|
||||
Array.isArray(currentValue)
|
||||
) {
|
||||
rawDiffCache = { original, current, diff: [] };
|
||||
return [];
|
||||
}
|
||||
const diff = computeDiff(
|
||||
originalValue as Record<string, unknown>,
|
||||
currentValue as Record<string, unknown>,
|
||||
);
|
||||
rawDiffCache = { original, current, diff };
|
||||
return diff;
|
||||
} catch {
|
||||
rawDiffCache = { original, current, diff: [] };
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function truncateValue(value: unknown, maxLen = 40): string {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.length} item${value.length === 1 ? "" : "s"}]`;
|
||||
}
|
||||
let str: string;
|
||||
try {
|
||||
const json = JSON.stringify(value);
|
||||
@@ -584,8 +723,54 @@ function truncateValue(value: unknown, maxLen = 40): string {
|
||||
return str.slice(0, maxLen - 3) + "...";
|
||||
}
|
||||
|
||||
function renderDiffValue(path: string, value: unknown, _uiHints: ConfigUiHints): string {
|
||||
if (isSensitiveConfigPath(path) && value != null && truncateValue(value).trim() !== "") {
|
||||
function renderDiffValue(path: ConfigDiffPath, value: unknown, _uiHints: ConfigUiHints): string {
|
||||
if (
|
||||
isSensitiveConfigPath(formatConfigDiffPath(path)) &&
|
||||
value != null &&
|
||||
truncateValue(value).trim() !== ""
|
||||
) {
|
||||
return REDACTED_PLACEHOLDER;
|
||||
}
|
||||
return truncateValue(value);
|
||||
}
|
||||
|
||||
function hintKeyMatchesPathPrefix(hintKey: string, path: ConfigDiffPath): boolean {
|
||||
const hintSegments = hintKey.split(".");
|
||||
if (hintSegments.length !== path.length) {
|
||||
return false;
|
||||
}
|
||||
return hintSegments.every((segment, index) => segment === "*" || segment === path[index]);
|
||||
}
|
||||
|
||||
function hasSensitiveHintForPathPrefix(path: ConfigDiffPath, uiHints: ConfigUiHints): boolean {
|
||||
return Object.entries(uiHints).some(
|
||||
([hintKey, hint]) => Boolean(hint.sensitive) && hintKeyMatchesPathPrefix(hintKey, path),
|
||||
);
|
||||
}
|
||||
|
||||
function isSensitiveDiffPath(path: ConfigDiffPath, uiHints: ConfigUiHints): boolean {
|
||||
for (let index = 1; index <= path.length; index += 1) {
|
||||
const prefix = path.slice(0, index);
|
||||
const key = formatConfigDiffPath(prefix);
|
||||
if (
|
||||
(hintForPath(prefix, uiHints)?.sensitive ?? false) ||
|
||||
hasSensitiveHintForPathPrefix(prefix, uiHints) ||
|
||||
isSensitiveConfigPath(key)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function renderRawDiffValue(
|
||||
path: ConfigDiffPath,
|
||||
value: unknown,
|
||||
uiHints: ConfigUiHints,
|
||||
rawRevealed: boolean,
|
||||
): string {
|
||||
const hasSensitiveValue = countSensitiveConfigValues(value, path, uiHints) > 0;
|
||||
if (!rawRevealed && value != null && (isSensitiveDiffPath(path, uiHints) || hasSensitiveValue)) {
|
||||
return REDACTED_PLACEHOLDER;
|
||||
}
|
||||
return truncateValue(value);
|
||||
@@ -912,6 +1097,7 @@ function renderAppearanceSection(props: ConfigProps) {
|
||||
|
||||
interface ConfigEphemeralState {
|
||||
rawRevealed: boolean;
|
||||
rawDiffOpen: boolean;
|
||||
envRevealed: boolean;
|
||||
validityDismissed: boolean;
|
||||
revealedSensitivePaths: Set<string>;
|
||||
@@ -921,6 +1107,7 @@ interface ConfigEphemeralState {
|
||||
function createConfigEphemeralState(): ConfigEphemeralState {
|
||||
return {
|
||||
rawRevealed: false,
|
||||
rawDiffOpen: false,
|
||||
envRevealed: false,
|
||||
validityDismissed: false,
|
||||
revealedSensitivePaths: new Set(),
|
||||
@@ -929,6 +1116,24 @@ function createConfigEphemeralState(): ConfigEphemeralState {
|
||||
}
|
||||
|
||||
const cvs = createConfigEphemeralState();
|
||||
let lastConfigContextKey: string | null = null;
|
||||
|
||||
function resetConfigEphemeralState() {
|
||||
Object.assign(cvs, createConfigEphemeralState());
|
||||
rawDiffCache = undefined;
|
||||
}
|
||||
|
||||
function configContextKey(props: ConfigProps): string {
|
||||
const include = props.includeSections?.join("\u001f") ?? "";
|
||||
const exclude = props.excludeSections?.join("\u001f") ?? "";
|
||||
return [
|
||||
props.configPath ?? "",
|
||||
props.gatewayUrl,
|
||||
props.navRootLabel ?? "",
|
||||
include,
|
||||
exclude,
|
||||
].join("\u001e");
|
||||
}
|
||||
|
||||
function isSensitivePathRevealed(path: Array<string | number>): boolean {
|
||||
const key = pathKey(path);
|
||||
@@ -948,7 +1153,8 @@ function toggleSensitivePathReveal(path: Array<string | number>) {
|
||||
}
|
||||
|
||||
export function resetConfigViewStateForTests() {
|
||||
Object.assign(cvs, createConfigEphemeralState());
|
||||
resetConfigEphemeralState();
|
||||
lastConfigContextKey = null;
|
||||
}
|
||||
|
||||
export function renderConfig(props: ConfigProps) {
|
||||
@@ -965,8 +1171,13 @@ export function renderConfig(props: ConfigProps) {
|
||||
const formUnsafe = analysis.schema ? analysis.unsupportedPaths.length > 0 : false;
|
||||
const rawAvailable = props.rawAvailable ?? true;
|
||||
const formMode = showModeToggle && rawAvailable ? props.formMode : "form";
|
||||
const requestUpdate = props.onRequestUpdate ?? (() => {});
|
||||
const currentContextKey = configContextKey(props);
|
||||
if (lastConfigContextKey !== currentContextKey) {
|
||||
resetConfigEphemeralState();
|
||||
lastConfigContextKey = currentContextKey;
|
||||
}
|
||||
const envSensitiveVisible = cvs.envRevealed;
|
||||
const requestUpdate = props.onRequestUpdate ?? (() => props.onRawChange(props.raw));
|
||||
|
||||
// Build categorised nav from schema - only include sections that exist in the schema
|
||||
const schemaProps = analysis.schema?.properties ?? {};
|
||||
@@ -1128,6 +1339,16 @@ export function renderConfig(props: ConfigProps) {
|
||||
// Compute diff for showing changes (works for both form and raw modes)
|
||||
const diff = formMode === "form" ? computeDiff(props.originalValue, props.formValue) : [];
|
||||
const hasRawChanges = formMode === "raw" && props.raw !== props.originalRaw;
|
||||
if ((!hasRawChanges || formMode !== "raw") && cvs.rawDiffOpen) {
|
||||
cvs.rawDiffOpen = false;
|
||||
}
|
||||
if (!hasRawChanges || formMode !== "raw" || !cvs.rawDiffOpen) {
|
||||
rawDiffCache = undefined;
|
||||
}
|
||||
const rawDiff =
|
||||
formMode === "raw" && hasRawChanges && cvs.rawDiffOpen
|
||||
? computeRawDiff(props.originalRaw, props.raw)
|
||||
: [];
|
||||
const hasChanges = formMode === "form" ? diff.length > 0 : hasRawChanges;
|
||||
|
||||
// Save/apply buttons require actual changes to be enabled.
|
||||
@@ -1332,7 +1553,7 @@ export function renderConfig(props: ConfigProps) {
|
||||
`
|
||||
: nothing}
|
||||
|
||||
<!-- Diff panel (form mode only - raw mode doesn't have granular diff) -->
|
||||
<!-- Diff panel -->
|
||||
${hasChanges && formMode === "form"
|
||||
? html`
|
||||
<details class="config-diff">
|
||||
@@ -1352,7 +1573,7 @@ export function renderConfig(props: ConfigProps) {
|
||||
${diff.map(
|
||||
(change) => html`
|
||||
<div class="config-diff__item">
|
||||
<div class="config-diff__path">${change.path}</div>
|
||||
<div class="config-diff__path">${formatConfigDiffPath(change.path)}</div>
|
||||
<div class="config-diff__values">
|
||||
<span class="config-diff__from"
|
||||
>${renderDiffValue(change.path, change.from, props.uiHints)}</span
|
||||
@@ -1369,6 +1590,74 @@ export function renderConfig(props: ConfigProps) {
|
||||
</details>
|
||||
`
|
||||
: nothing}
|
||||
${hasRawChanges && formMode === "raw"
|
||||
? html`
|
||||
<details
|
||||
class="config-diff"
|
||||
?open=${cvs.rawDiffOpen}
|
||||
@toggle=${(e: Event) => {
|
||||
const details = e.target as HTMLDetailsElement;
|
||||
if (cvs.rawDiffOpen === details.open) {
|
||||
return;
|
||||
}
|
||||
cvs.rawDiffOpen = details.open;
|
||||
if (!details.open) {
|
||||
rawDiffCache = undefined;
|
||||
}
|
||||
requestUpdate();
|
||||
}}
|
||||
>
|
||||
<summary class="config-diff__summary">
|
||||
<span>View pending changes</span>
|
||||
<svg
|
||||
class="config-diff__chevron"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</summary>
|
||||
<div class="config-diff__content">
|
||||
${rawDiff.length > 0
|
||||
? rawDiff.map(
|
||||
(change) => html`
|
||||
<div class="config-diff__item">
|
||||
<div class="config-diff__path">
|
||||
${formatConfigDiffPath(change.path)}
|
||||
</div>
|
||||
<div class="config-diff__values">
|
||||
<span class="config-diff__from"
|
||||
>${renderRawDiffValue(
|
||||
change.path,
|
||||
change.from,
|
||||
props.uiHints,
|
||||
cvs.rawRevealed,
|
||||
)}</span
|
||||
>
|
||||
<span class="config-diff__arrow">→</span>
|
||||
<span class="config-diff__to"
|
||||
>${renderRawDiffValue(
|
||||
change.path,
|
||||
change.to,
|
||||
props.uiHints,
|
||||
cvs.rawRevealed,
|
||||
)}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
: html`
|
||||
<div class="config-diff__item">
|
||||
Changes detected (JSON diff not available)
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
</details>
|
||||
`
|
||||
: nothing}
|
||||
${activeSectionMeta && formMode === "form"
|
||||
? html`
|
||||
<div class="config-section-hero">
|
||||
|
||||
Reference in New Issue
Block a user