Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions apps/desktop/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,10 @@
"@lobehub/icons": "5.16.0",
"@tanstack/react-virtual": "^3.14.10",
"@tauri-apps/api": "2.11.1",
"@xyflow/react": "^12.12.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"elkjs": "^0.12.0",
"i18next": "26.4.0",
"motion": "13.4.0",
"radix-ui": "^1.6.7",
Expand Down
14 changes: 14 additions & 0 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1172,6 +1172,19 @@ async fn get_routing_settings(
manager.get_routing_settings().await
}

#[tauri::command]
async fn routing_graph(
operation: String,
input: Option<serde_json::Value>,
etag: Option<String>,
revision: Option<u64>,
manager: State<'_, Arc<CoreManager>>,
) -> Result<serde_json::Value, String> {
manager
.routing_graph(&operation, input, etag, revision)
.await
}

#[tauri::command]
async fn update_routing_settings(
patch: serde_json::Value,
Expand Down Expand Up @@ -1472,6 +1485,7 @@ pub fn run() {
purge_request_records,
get_request_audit_content,
get_routing_settings,
routing_graph,
update_routing_settings,
get_audit_settings,
update_audit_settings,
Expand Down
40 changes: 40 additions & 0 deletions apps/desktop/src-tauri/src/sidecar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2074,6 +2074,46 @@ impl CoreManager {
Ok(value)
}

pub async fn routing_graph(
&self,
operation: &str,
input: Option<serde_json::Value>,
etag: Option<String>,
revision: Option<u64>,
) -> Result<serde_json::Value, String> {
let base = "/control/v1/routing-graph";
let (method, path) = match operation {
"get" => (Method::GET, base.to_string()),
"revision" => {
let revision = revision
.filter(|value| *value > 0)
.ok_or("revision is required")?;
(Method::GET, format!("{base}?revision={revision}"))
}
"save" | "preview" => {
let value = input
.as_ref()
.filter(|value| value.is_object())
.ok_or("graph input is required")?;
if serde_json::to_vec(value).map_err(|e| e.to_string())?.len() > 1_048_576 {
return Err("routing graph input exceeds 1 MiB".into());
}
if operation == "save" {
validate_strong_etag(etag.as_deref().ok_or("graph version is required")?)?;
(Method::PUT, base.to_string())
} else {
(Method::POST, format!("{base}/preview"))
}
}
_ => return Err("unknown routing graph operation".into()),
};
let (_, body) = self
.authenticated_control(method, &path, input, etag.as_deref())
.await?;
serde_json::from_slice(&body)
.map_err(|error| format!("invalid routing graph response: {error}"))
}

pub async fn update_routing_settings(
&self,
patch: serde_json::Value,
Expand Down
90 changes: 88 additions & 2 deletions apps/desktop/src/App.navigation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ const bridgeMocks = vi.hoisted(() => ({
.mockRejectedValue(new Error("tray unavailable in tests")),
trayAction: vi.fn().mockRejectedValue(new Error("tray unavailable in tests")),
getRoutingSettings: vi.fn(),
getRoutingGraph: vi.fn(),
saveRoutingGraph: vi.fn(),
getServiceOrder: vi
.fn()
.mockResolvedValue({ service_ids: [], etag: '"order"' }),
Expand Down Expand Up @@ -207,6 +209,24 @@ describe("App workspace navigation", () => {
}
).IS_REACT_ACT_ENVIRONMENT = true;
vi.clearAllMocks();
bridgeMocks.getRoutingGraph.mockResolvedValue({
draft: {
nodes: [
{
id: "entry_fixture",
kind: "entry",
enabled: true,
model: "fixture-route",
},
],
edges: [],
},
active: { nodes: [], edges: [] },
layout: { entry_fixture: { x: 40, y: 40 } },
revision: 0,
etag: '"graph-fixture"',
history: [],
});
bridgeMocks.getRoutingSettings.mockResolvedValue({
default_failure_policy: defaultFailurePolicy(),
allow_unmatched_failover: false,
Expand Down Expand Up @@ -444,7 +464,9 @@ describe("App workspace navigation", () => {
},
];
for (const page of pages) {
await act(async () => button(page.nav).click());
await act(async () => {
button(page.nav).click();
});
expect(
container.querySelector('[data-slot="workspace"]')?.textContent,
).toContain(page.content);
Expand Down Expand Up @@ -786,7 +808,7 @@ describe("App workspace navigation", () => {
expect(workspaceHeading().textContent).toBe("设置");
});

it("opens default routing policy without retired routing tabs", async () => {
it("opens default settings and exposes the graph through an advanced entry", async () => {
await renderApp();
const serviceCalls = bridgeMocks.listServices.mock.calls.length;
const requestCalls = bridgeMocks.getUsageSummary.mock.calls.length;
Expand All @@ -799,6 +821,18 @@ describe("App workspace navigation", () => {
expect(
document.querySelector('[aria-current="page"]')?.textContent,
).toContain("路由");
expect(bridgeMocks.getRoutingGraph).not.toHaveBeenCalled();
const advanced = button("高级路由图");
expect(advanced.closest('[data-slot="page-header"]')).toBeNull();
expect(advanced.getAttribute("aria-describedby")).toBe(
"routing-advanced-description",
);
expect(
container.querySelector("#routing-advanced-description")?.textContent,
).toContain("后备顺序与条件分支");
expect(
container.querySelector('[data-testid="routing-graph-workspace"]'),
).toBeNull();
expect(workspaceHeading().textContent).toBe("路由");
expect(
container.querySelector('[data-testid="routing-defaults-panel"]'),
Expand All @@ -817,6 +851,58 @@ describe("App workspace navigation", () => {
expect(bridgeMocks.listRoutes).not.toHaveBeenCalled();
expect(bridgeMocks.listServices).toHaveBeenCalledTimes(serviceCalls);
expect(bridgeMocks.getUsageSummary).toHaveBeenCalledTimes(requestCalls);
await act(async () => {
button("高级路由图").click();
await import("./RoutingGraphEditor");
});
expect(workspaceHeading().textContent).toBe("高级路由图");
expect(
container.querySelector('[data-testid="routing-graph-workspace"]'),
).not.toBeNull();
expect(
container.querySelector('[data-testid="routing-defaults-panel"]'),
).toBeNull();
const back = button("返回路由设置");
expect(back.closest('[data-slot="page-header"]')).not.toBeNull();
expect(
container.querySelectorAll('button[aria-label="返回路由设置"]'),
).toHaveLength(1);
expect(container.textContent).not.toContain("默认路由设置");
await act(async () => back.click());
expect(workspaceHeading().textContent).toBe("路由");
expect(
container.querySelector('[data-testid="routing-defaults-panel"]'),
).not.toBeNull();
});

it("labels pending graph edits as unsaved instead of saved", async () => {
bridgeMocks.saveRoutingGraph.mockReturnValue(new Promise(() => {}));
await renderApp();
await act(async () => {
button("路由").click();
await Promise.resolve();
});
await act(async () => {
button("高级路由图").click();
await import("./RoutingGraphEditor");
});
const status = () =>
container.querySelector(".routing-save-status")?.textContent;
expect(status()).toBe("草稿 · 尚未应用");
await act(async () => button("启用或禁用 fixture-route").click());
expect(status()).toBe("有未保存的修改");
expect(bridgeMocks.saveRoutingGraph).not.toHaveBeenCalled();
await act(async () => button("返回路由设置").click());
expect(document.querySelector('[role="alertdialog"]')).not.toBeNull();
await act(async () => button("继续编辑").click());
expect(workspaceHeading().textContent).toBe("高级路由图");
expect(status()).toBe("有未保存的修改");
await act(async () => button("返回路由设置").click());
await act(async () => button("放弃修改并离开").click());
expect(workspaceHeading().textContent).toBe("路由");
expect(
container.querySelector('[data-testid="routing-defaults-panel"]'),
).not.toBeNull();
});

it("summarizes usage over the default yearly window", async () => {
Expand Down
Loading
Loading