Compare commits

...
Author SHA1 Message Date
jiang b04378397c test: type logout audit fetch mock
Generic Container CI/CD / test-build-publish (push) Successful in 1m1s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m1s
2026-09-09 15:07:45 +08:00
jiang bad8769000 fix(auth): redirect expired sessions before permission checks 2026-09-09 15:07:25 +08:00
jiang 29b8babd68 fix(api): align frontend with project-scoped backend
Use project-scoped endpoints and run IDs for current project, SCADA metadata, historical time series, migrated DMA results, and sensor placement runs.

The regressions recurred because tests mocked pre-interceptor and new-only payload shapes, while history queries relied on implicit global scheme state and unbounded request fan-out. Cover post-interceptor pages, migrated result_rows, explicit run_id routing, multi-element requests, request limits, and cancellation.
2026-09-01 14:37:51 +08:00
33 changed files with 1657 additions and 3501 deletions
+5
View File
@@ -22,6 +22,11 @@ npm run start
`npm run dev` starts the Refine/Next development server. `npm run lint` runs ESLint. `npm test` runs Jest. `npm run build` creates the production build.
When the Server API changes, sync `contracts/server-v1.openapi.json` from the
backend source of truth, update its SHA-256 in `contracts/manifest.json`, then
run `npm run api:generate` and `npm run api:check` so the generated types and
contract mirror remain aligned.
## Coding Style & Naming Conventions
Use TypeScript and React function components. Follow ESLint and Next.js conventions. Use `PascalCase` for React component files and component names. Use `camelCase` for ordinary TypeScript modules, hooks, stores, providers, utilities, variables, and functions. Next.js route directories under `src/app` use `kebab-case`; route groups and dynamic segments keep the Next.js syntax such as `(main)` and `[...nextauth]`. Keep backend/Agent boundary fields and query parameters in the shape required by the API, typically `snake_case`, and do not translate third-party SDK fields. Prefer MUI components and existing design tokens/patterns for UI. Keep operational screens dense, clear, and task-focused.
+1 -1
View File
@@ -7,7 +7,7 @@
},
"server": {
"file": "server-v1.openapi.json",
"sha256": "404a196c0177faed2aa5b46ee86430a034dfe990e0a77a43428a727748a882b6"
"sha256": "d0364fb08c6f18fac2ea9c9980ef21115fc01f110cd10f25f90d97d0bc0e6367"
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,54 @@
import { render, screen } from "@testing-library/react";
import { useAccessStore } from "@/store/accessStore";
import { useAuthStore } from "@/store/authStore";
import { RoutePermissionGuard } from "./RoutePermissionGuard";
jest.mock("next/navigation", () => ({
usePathname: () => "/network-simulation",
}));
describe("RoutePermissionGuard", () => {
beforeEach(() => {
useAccessStore.setState({
context: null,
permissions: [],
loading: false,
});
useAuthStore.setState({
accessToken: null,
sessionExpired: false,
sessionExpiryReason: null,
});
});
it("prioritizes an expired session over a missing route permission", () => {
useAuthStore.setState({
sessionExpired: true,
sessionExpiryReason: "unauthorized",
});
render(
<RoutePermissionGuard>
<div></div>
</RoutePermissionGuard>,
);
expect(screen.getByText("登录状态已失效")).toBeInTheDocument();
expect(screen.getByText("正在跳转到登录页面…")).toBeInTheDocument();
expect(screen.queryByText("无权访问此功能")).not.toBeInTheDocument();
expect(screen.queryByText(/simulation\.view/)).not.toBeInTheDocument();
expect(screen.queryByText("受保护内容")).not.toBeInTheDocument();
});
it("shows the permission error when the session is still valid", () => {
render(
<RoutePermissionGuard>
<div></div>
</RoutePermissionGuard>,
);
expect(screen.getByText("无权访问此功能")).toBeInTheDocument();
expect(screen.getByText(/simulation\.view/)).toBeInTheDocument();
});
});
@@ -7,6 +7,7 @@ import type { ReactNode } from "react";
import { permissionForPath } from "@/lib/permissions";
import { useAccessStore } from "@/store/accessStore";
import { useAuthStore } from "@/store/authStore";
export const RoutePermissionGuard = ({
children,
@@ -16,8 +17,24 @@ export const RoutePermissionGuard = ({
const pathname = usePathname();
const permissions = useAccessStore((state) => state.permissions);
const loading = useAccessStore((state) => state.loading);
const sessionExpired = useAuthStore((state) => state.sessionExpired);
const requiredPermission = permissionForPath(pathname);
if (sessionExpired) {
return (
<Box sx={{ p: 3 }}>
<Alert severity="warning">
<Stack spacing={0.5}>
<Typography variant="body1"></Typography>
<Typography variant="body2">
</Typography>
</Stack>
</Alert>
</Box>
);
}
if (requiredPermission && loading) {
return (
<Box sx={{ minHeight: 320, display: "grid", placeItems: "center" }}>
@@ -1,5 +1,6 @@
import { createTheme, ThemeProvider } from "@mui/material/styles";
import { act, render, screen } from "@testing-library/react";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { signIn } from "next-auth/react";
import { useAuthStore } from "@/store/authStore";
import { SessionExpiryDialog } from "./SessionExpiryDialog";
@@ -10,6 +11,8 @@ jest.mock("next-auth/react", () => ({
describe("SessionExpiryDialog", () => {
beforeEach(() => {
jest.useFakeTimers();
jest.mocked(signIn).mockReset().mockResolvedValue(undefined);
useAuthStore.setState({
accessToken: null,
sessionExpired: true,
@@ -19,6 +22,7 @@ describe("SessionExpiryDialog", () => {
afterEach(() => {
act(() => useAuthStore.getState().clearSessionExpired());
jest.useRealTimers();
});
it("renders above every regular application overlay", () => {
@@ -36,4 +40,40 @@ describe("SessionExpiryDialog", () => {
zIndex: theme.zIndex.tooltip + 1,
});
});
it("shows the expired state before automatically starting login", () => {
window.history.replaceState({}, "", "/network-simulation?tab=history");
render(<SessionExpiryDialog />);
expect(screen.getByText("登录已过期")).toBeInTheDocument();
expect(screen.getByText("即将自动跳转到登录页面。"))
.toBeInTheDocument();
expect(signIn).not.toHaveBeenCalled();
act(() => {
jest.advanceTimersByTime(2_000);
});
expect(signIn).toHaveBeenCalledTimes(1);
expect(signIn).toHaveBeenCalledWith("keycloak", {
callbackUrl: "/network-simulation?tab=history",
redirect: true,
});
});
it("allows a retry when starting automatic login fails", async () => {
jest.mocked(signIn)
.mockRejectedValueOnce(new Error("network unavailable"))
.mockResolvedValueOnce(undefined);
render(<SessionExpiryDialog />);
await act(async () => {
jest.advanceTimersByTime(2_000);
});
fireEvent.click(screen.getByRole("button", { name: "重新认证" }));
expect(signIn).toHaveBeenCalledTimes(2);
});
});
+28 -4
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { signIn } from "next-auth/react";
import AccessTimeOutlinedIcon from "@mui/icons-material/AccessTimeOutlined";
import {
@@ -18,6 +18,7 @@ import { useTheme } from "@mui/material/styles";
import { useAuthStore } from "@/store/authStore";
const WARNING_WINDOW_MS = 15 * 60 * 1000;
const EXPIRED_REDIRECT_DELAY_MS = 2_000;
type SessionExpiryDialogProps = {
expiresAt?: number;
@@ -31,6 +32,7 @@ export const SessionExpiryDialog = ({
const reason = useAuthStore((state) => state.sessionExpiryReason);
const [now, setNow] = useState(() => Date.now());
const [warningDismissed, setWarningDismissed] = useState(false);
const redirectStartedRef = useRef(false);
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 30_000);
@@ -47,10 +49,27 @@ export const SessionExpiryDialog = ({
[expiresAt, now, sessionExpired, warningDismissed],
);
const handleReauthenticate = () => {
const handleReauthenticate = useCallback(() => {
if (redirectStartedRef.current) return;
redirectStartedRef.current = true;
const callbackUrl = `${window.location.pathname}${window.location.search}`;
void signIn("keycloak", { callbackUrl, redirect: true });
};
void signIn("keycloak", { callbackUrl, redirect: true }).catch(() => {
redirectStartedRef.current = false;
});
}, []);
useEffect(() => {
if (!sessionExpired) {
redirectStartedRef.current = false;
return;
}
const timer = window.setTimeout(
handleReauthenticate,
EXPIRED_REDIRECT_DELAY_MS,
);
return () => window.clearTimeout(timer);
}, [handleReauthenticate, sessionExpired]);
const isOpen = sessionExpired || isExpiringSoon;
const title = sessionExpired ? "登录已过期" : "登录即将到期";
@@ -77,6 +96,11 @@ export const SessionExpiryDialog = ({
<Typography variant="body2" color="text.secondary">
</Typography>
{sessionExpired && (
<Typography variant="body2" color="text.secondary">
</Typography>
)}
</Stack>
</DialogContent>
<DialogActions>
+2 -11
View File
@@ -30,6 +30,7 @@ import {
describeApplyLayerStyle,
parseApplyLayerStylePayload,
} from "./toolCallStyleHelpers";
import { buildViewHistoryAction } from "./historyToolAction";
/* ------------------------------------------------------------------ */
/* Interactive card rendered inside a chat bubble for tool actions */
@@ -428,17 +429,7 @@ function buildAction(toolCall: ToolCall): ChatToolAction | null {
};
}
case "view_history": {
const historyRange = resolveTimeRange();
return {
type: "view_history",
featureInfos:
(params.feature_infos as [string, string][] | undefined) ?? [],
dataType:
(params.data_type as "realtime" | "scheme" | "none" | undefined) ??
"realtime",
startTime: historyRange.startTime,
endTime: historyRange.endTime,
};
return buildViewHistoryAction(params);
}
case "view_scada": {
const scadaRange = resolveTimeRange();
@@ -0,0 +1,36 @@
import { buildViewHistoryAction } from "./historyToolAction";
describe("buildViewHistoryAction", () => {
it("preserves the scheme run ID from Agent tool parameters", () => {
expect(
buildViewHistoryAction({
feature_infos: [["P-1", "pipe"]],
data_type: "scheme",
run_id: " 99dd4142-368b-54cb-bfca-d59ee48f6298 ",
}),
).toEqual(
expect.objectContaining({
featureInfos: [["P-1", "pipe"]],
dataType: "scheme",
runId: "99dd4142-368b-54cb-bfca-d59ee48f6298",
}),
);
});
it("normalizes invalid history parameters", () => {
expect(
buildViewHistoryAction({
feature_infos: [["", "pipe"], "invalid"],
data_type: "unexpected",
runId: " ",
}),
).toEqual({
type: "view_history",
featureInfos: [],
dataType: "realtime",
runId: undefined,
startTime: undefined,
endTime: undefined,
});
});
});
+43
View File
@@ -0,0 +1,43 @@
import type { ChatToolAction } from "@/store/chatToolStore";
type ViewHistoryAction = Extract<ChatToolAction, { type: "view_history" }>;
const readOptionalString = (value: unknown) =>
typeof value === "string" && value.trim() ? value.trim() : undefined;
export const buildViewHistoryAction = (
params: Record<string, unknown>,
): ViewHistoryAction => {
const rawFeatureInfos = Array.isArray(params.feature_infos)
? params.feature_infos
: [];
const featureInfos = rawFeatureInfos
.filter(
(item): item is [unknown, unknown] =>
Array.isArray(item) && item.length >= 2,
)
.map(
([id, type]) => [String(id).trim(), String(type).trim()] as [string, string],
)
.filter(([id, type]) => id.length > 0 && type.length > 0);
const rawDataType = params.data_type;
const dataType =
rawDataType === "realtime" ||
rawDataType === "scheme" ||
rawDataType === "none"
? rawDataType
: "realtime";
return {
type: "view_history",
featureInfos,
dataType,
runId: readOptionalString(params.run_id ?? params.runId),
startTime: readOptionalString(
params.start_time ?? params.startTime ?? params.from ?? params.start,
),
endTime: readOptionalString(
params.end_time ?? params.endTime ?? params.to ?? params.end,
),
};
};
@@ -10,6 +10,7 @@ import {
describeApplyLayerStyle,
parseApplyLayerStylePayload,
} from "../toolCallStyleHelpers";
import { buildViewHistoryAction } from "../historyToolAction";
type ToolCallEvent = StreamEvent & { type: "tool_call" };
@@ -253,21 +254,12 @@ const buildToolAction = (
}
if (tool === "view_history") {
const featureInfos = (params.feature_infos as [string, string][] | undefined) ?? [];
const { startTime, endTime } = resolveTimeRange(params);
const action = buildViewHistoryAction(params);
return {
action: {
type: "view_history",
featureInfos,
dataType:
(params.data_type as "realtime" | "scheme" | "none" | undefined) ??
"realtime",
startTime,
endTime,
},
action,
kind: "panel",
title: "打开计算结果曲线",
description: compactNames(featureInfos.map(([id]) => id)),
description: compactNames(action.featureInfos.map(([id]) => id)),
};
}
@@ -1,12 +1,26 @@
import dayjs from "dayjs";
import { api } from "@/lib/api";
import {
buildBurstDetectionRequest,
createBurstDetectionAnalysisParametersState,
fetchPressureSamplingInterval,
parseScadaFrequencyMinutes,
resolvePressureSamplingInterval,
} from "./AnalysisParameters";
jest.mock("@/lib/api", () => ({
api: {
get: jest.fn(),
},
}));
const get = api.get as jest.Mock;
describe("burst detection request", () => {
beforeEach(() => {
get.mockReset();
});
it("requests the latest complete monitoring time by default", () => {
const state = createBurstDetectionAnalysisParametersState();
state.schemeName = " latest-case ";
@@ -45,11 +59,27 @@ describe("burst detection request", () => {
expect(parseScadaFrequencyMinutes("1:00:00")).toBe(60);
expect(
resolvePressureSamplingInterval([
{ type: "pressure", transmission_frequency: "0:15:00" },
{ type: "pressure", transmission_frequency: "0:15:00" },
{ type: "pressure", transmission_frequency: "0:30:00" },
{ type: "pipe_flow", transmission_frequency: "1:00:00" },
{ device_type: "pressure", transmission_frequency: "0:15:00" },
{ device_type: "pressure", transmission_frequency: "0:15:00" },
{ device_type: "pressure", transmission_frequency: "0:30:00" },
{ device_type: "pipe_flow", transmission_frequency: "1:00:00" },
]),
).toBe(15);
});
it("loads the pressure frequency from the pooled SCADA metadata endpoint", async () => {
get.mockResolvedValue({
data: {
success: true,
count: 2,
data: [
{ device_type: "pressure", transmission_frequency: "0:30:00" },
{ device_type: "pipe_flow", transmission_frequency: "0:15:00" },
],
},
});
await expect(fetchPressureSamplingInterval()).resolves.toBe(30);
expect(get).toHaveBeenCalledWith("/api/v1/scada-info/database-view");
});
});
@@ -46,10 +46,14 @@ export interface BurstDetectionAnalysisParametersState {
}
interface ScadaInfoItem {
type?: string;
device_type?: string;
transmission_frequency?: string | number | null;
}
interface ScadaInfoResponse {
data?: ScadaInfoItem[];
}
const currentQuarterHour = () => {
const now = dayjs().second(0).millisecond(0);
return now.minute(Math.floor(now.minute() / 15) * 15);
@@ -86,7 +90,7 @@ export const parseScadaFrequencyMinutes = (
export const resolvePressureSamplingInterval = (items: ScadaInfoItem[]) => {
const counts = new Map<number, number>();
items
.filter((item) => item.type?.toLowerCase() === "pressure")
.filter((item) => item.device_type?.toLowerCase() === "pressure")
.forEach((item) => {
const minutes = parseScadaFrequencyMinutes(item.transmission_frequency);
if (minutes && 1440 % minutes === 0) {
@@ -99,6 +103,15 @@ export const resolvePressureSamplingInterval = (items: ScadaInfoItem[]) => {
)[0]?.[0] ?? 15;
};
export const fetchPressureSamplingInterval = async () => {
const response = await api.get<ScadaInfoResponse>(
"/api/v1/scada-info/database-view",
);
return resolvePressureSamplingInterval(
Array.isArray(response.data.data) ? response.data.data : [],
);
};
export const buildBurstDetectionRequest = (
parameters: BurstDetectionAnalysisParametersState,
) => ({
@@ -141,13 +154,9 @@ const AnalysisParameters: React.FC<Props> = ({
if (samplingIntervalSource !== "metadata") return;
let active = true;
setFrequencyLoading(true);
api
.get("/api/v1/scada-info")
.then((response) => {
fetchPressureSamplingInterval()
.then((interval) => {
if (!active) return;
const interval = resolvePressureSamplingInterval(
response.data as ScadaInfoItem[],
);
setParametersState((previous) =>
previous.samplingIntervalSource === "metadata"
? { ...previous, samplingIntervalMinutes: interval }
@@ -21,6 +21,7 @@ import SchemeQuery, {
createBurstDetectionSchemeQueryState,
type BurstDetectionSchemeQueryState,
} from "./SchemeQuery";
import { useData } from "@components/olmap/core/MapComponent";
import { BurstDetectionResult, BurstDetectionSchemeRecord } from "./types";
const TabPanel = ({
@@ -38,6 +39,7 @@ const TabPanel = ({
);
const BurstDetectionPanel: React.FC = () => {
const setSchemeRunId = useData()?.setSchemeRunId;
const [open, setOpen] = useState(true);
const [tab, setTab] = useState(0);
const [result, setResult] = useState<BurstDetectionResult | null>(null);
@@ -58,10 +60,23 @@ const BurstDetectionPanel: React.FC = () => {
const drawerWidth = 450;
const panelTitle = "爆管侦测";
const handleResult = useCallback((payload: BurstDetectionResult) => {
setResult(payload);
setTab(2);
}, []);
const handleResult = useCallback(
(payload: BurstDetectionResult) => {
setSchemeRunId?.("");
setResult(payload);
setTab(2);
},
[setSchemeRunId],
);
const handleViewResult = useCallback(
(payload: BurstDetectionResult, runId: string) => {
setSchemeRunId?.(runId);
setResult(payload);
setTab(2);
},
[setSchemeRunId],
);
return (
<>
@@ -166,7 +181,7 @@ const BurstDetectionPanel: React.FC = () => {
</TabPanel>
<TabPanel value={tab} index={1}>
<SchemeQuery
onViewResult={handleResult}
onViewResult={handleViewResult}
schemes={schemes}
onSchemesChange={setSchemes}
state={queryState}
@@ -32,7 +32,7 @@ import {
} from "./types";
interface Props {
onViewResult: (result: BurstDetectionResult) => void;
onViewResult: (result: BurstDetectionResult, runId: string) => void;
schemes?: BurstDetectionSchemeRecord[];
onSchemesChange?: (schemes: BurstDetectionSchemeRecord[]) => void;
state?: BurstDetectionSchemeQueryState;
@@ -174,7 +174,7 @@ const SchemeQuery: React.FC<Props> = ({
throw new Error("方案详情缺少侦测结果数据");
}
onViewResult(normalizedResult);
onViewResult(normalizedResult, runId);
open?.({
type: "success",
message: "方案加载成功",
@@ -19,6 +19,7 @@ import SchemeQuery, {
type BurstLocationSchemeQueryState,
} from "./SchemeQuery";
import { BurstLocationResult, BurstSchemeRecord } from "./types";
import { useData } from "@components/olmap/core/MapComponent";
const TabPanel = ({
value,
@@ -35,6 +36,7 @@ const TabPanel = ({
);
const BurstLocationPanel: React.FC = () => {
const setSchemeRunId = useData()?.setSchemeRunId;
const [open, setOpen] = useState(true);
const [tab, setTab] = useState(0);
const [result, setResult] = useState<BurstLocationResult | null>(null);
@@ -51,15 +53,23 @@ const BurstLocationPanel: React.FC = () => {
const drawerWidth = 450;
const panelTitle = "爆管定位";
const handleResult = useCallback((payload: BurstLocationResult) => {
setResult(payload);
setTab(2);
}, []);
const handleResult = useCallback(
(payload: BurstLocationResult) => {
setSchemeRunId?.("");
setResult(payload);
setTab(2);
},
[setSchemeRunId],
);
const handleViewResult = useCallback((payload: BurstLocationResult) => {
setResult(payload);
setTab(2);
}, []);
const handleViewResult = useCallback(
(payload: BurstLocationResult, runId: string) => {
setSchemeRunId?.(runId);
setResult(payload);
setTab(2);
},
[setSchemeRunId],
);
return (
<>
@@ -46,7 +46,7 @@ import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorNam
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
interface Props {
onViewResult: (result: BurstLocationResult) => void;
onViewResult: (result: BurstLocationResult, runId: string) => void;
schemes?: BurstSchemeRecord[];
onSchemesChange?: (schemes: BurstSchemeRecord[]) => void;
state?: BurstLocationSchemeQueryState;
@@ -285,7 +285,7 @@ const SchemeQuery: React.FC<Props> = ({
if (!normalizedResult) {
throw new Error("方案详情缺少定位结果数据");
}
onViewResult(enrichResultWithSimulationBurstIds(normalizedResult));
onViewResult(enrichResultWithSimulationBurstIds(normalizedResult), runId);
open?.({
type: "success",
message: "方案加载成功",
@@ -17,7 +17,7 @@ import {
ChevronRight,
FormatListBulleted,
} from "@mui/icons-material";
import { useMap } from "@components/olmap/core/MapComponent";
import { useData, useMap } from "@components/olmap/core/MapComponent";
import StyleLegend from "@components/olmap/core/Controls/StyleLegend";
import AnalysisParameters, {
createDMALeakAnalysisParametersState,
@@ -50,6 +50,7 @@ const DMA_AREA_INDEX_PROPERTY = "dma_area_index";
const DMALeakDetectionPanel: React.FC = () => {
const map = useMap();
const setSchemeRunId = useData()?.setSchemeRunId;
const [open, setOpen] = useState(true);
const [tab, setTab] = useState(0);
const [result, setResult] = useState<LeakageResultDetail | null>(null);
@@ -79,15 +80,23 @@ const DMALeakDetectionPanel: React.FC = () => {
[activeAreas.length],
);
const handleAnalysisResult = useCallback((res: LeakageResultDetail) => {
setResult(res);
}, []);
const handleAnalysisResult = useCallback(
(res: LeakageResultDetail) => {
setSchemeRunId?.("");
setResult(res);
},
[setSchemeRunId],
);
const handleViewResult = useCallback((res: LeakageResultDetail) => {
setResult(res);
setLoadedResult(res);
setTab(2);
}, []);
const handleViewResult = useCallback(
(res: LeakageResultDetail, runId: string) => {
setSchemeRunId?.(runId);
setResult(res);
setLoadedResult(res);
setTab(2);
},
[setSchemeRunId],
);
useEffect(() => {
if (!map) return;
@@ -27,13 +27,17 @@ import {
listAnalysisSchemes,
} from "@/lib/analysisRuns";
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { LeakageResultDetail, LeakageSchemeRecord } from "./types";
import {
LeakageResultDetail,
LeakageSchemeRecord,
normalizeLeakageRows,
} from "./types";
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
interface Props {
onViewResult: (result: LeakageResultDetail) => void;
onViewResult: (result: LeakageResultDetail, runId: string) => void;
schemes?: LeakageSchemeRecord[];
onSchemesChange?: (schemes: LeakageSchemeRecord[]) => void;
state?: DMALeakSchemeQueryState;
@@ -131,14 +135,18 @@ const SchemeQuery: React.FC<Props> = ({
if (!result) {
throw new Error("方案详情缺少漏损识别结果");
}
onViewResult({
...result,
scheme_name: scheme.scheme_name,
scheme_detail: scheme.scheme_detail,
scheme_start_time: scheme.scheme_start_time,
create_time: scheme.create_time,
username: scheme.username,
} as LeakageResultDetail);
onViewResult(
{
...result,
rows: normalizeLeakageRows(result),
scheme_name: scheme.scheme_name,
scheme_detail: scheme.scheme_detail,
scheme_start_time: scheme.scheme_start_time,
create_time: scheme.create_time,
username: scheme.username,
} as LeakageResultDetail,
runId,
);
} catch (error: any) {
open?.({
type: "error",
@@ -0,0 +1,27 @@
import { normalizeLeakageRows } from "./types";
const migratedRow = {
Area: "DMA-01",
LeakageRatioRaw: 0.12,
LeakageRatio: 0.1,
LeakageFlow_m3_per_s: 0.02,
};
describe("normalizeLeakageRows", () => {
it("restores migrated DMA results stored under result_rows", () => {
expect(normalizeLeakageRows({ result_rows: [migratedRow] })).toEqual([
migratedRow,
]);
});
it("prefers the canonical rows field when both shapes are present", () => {
const canonicalRow = { ...migratedRow, Area: "DMA-02" };
expect(
normalizeLeakageRows({
rows: [canonicalRow],
result_rows: [migratedRow],
}),
).toEqual([canonicalRow]);
});
});
@@ -5,6 +5,18 @@ export interface LeakageRow {
LeakageFlow_m3_per_s: number;
}
export const normalizeLeakageRows = (
payload: Record<string, unknown>,
): LeakageRow[] => {
if (Array.isArray(payload.rows)) {
return payload.rows as LeakageRow[];
}
return Array.isArray(payload.result_rows)
? (payload.result_rows as LeakageRow[])
: [];
};
export interface LeakageSchemeRecord {
scheme_id: string;
scheme_name: string;
@@ -31,9 +31,12 @@ const run = {
describe("sensor placement runs API adapter", () => {
beforeEach(() => jest.clearAllMocks());
it("lists paged runs and maps run fields to the existing screen model", async () => {
it.each([
["contract page", { items: [run], total: 1, limit: 1000, offset: 0 }],
["interceptor-unwrapped page", [run]],
])("maps runs from the %s response", async (_label, data) => {
jest.mocked(api.get).mockResolvedValue({
data: { items: [run], total: 1, limit: 1000, offset: 0 },
data,
});
await expect(listSensorPlacementSchemes()).resolves.toEqual([
@@ -26,6 +26,13 @@ type SensorPlacementRunResponse = {
can_edit: boolean;
};
type SensorPlacementRunPage = {
items: SensorPlacementRunResponse[];
total: number;
limit: number;
offset: number;
};
const toSensorPlacementScheme = (
run: SensorPlacementRunResponse,
): SensorPlacementScheme => ({
@@ -53,12 +60,16 @@ export const optimizeSensorPlacement = async (
export const listSensorPlacementSchemes = async (): Promise<
SensorPlacementScheme[]
> => {
const response = await api.get<{
items: SensorPlacementRunResponse[];
}>(`${config.BACKEND_URL}/api/v1/sensor-placement-runs`, {
params: { limit: 1000, offset: 0 },
});
return response.data.items.map(toSensorPlacementScheme);
const response = await api.get<
SensorPlacementRunPage | SensorPlacementRunResponse[]
>(
`${config.BACKEND_URL}/api/v1/sensor-placement-runs`,
{ params: { limit: 1000, offset: 0 } },
);
const runs = Array.isArray(response.data)
? response.data
: response.data.items;
return runs.map(toSensorPlacementScheme);
};
export const getSensorPlacementScheme = async (
@@ -0,0 +1,110 @@
import { apiFetch } from "@/lib/apiFetch";
import { fetchHistoryData } from "./HistoryDataPanel";
jest.mock("@/lib/apiFetch", () => ({
apiFetch: jest.fn(),
}));
jest.mock("@components/olmap/core/MapComponent", () => ({
useData: () => null,
}));
const jsonResponse = (payload: unknown, status = 200) =>
({
ok: status >= 200 && status < 300,
status,
json: jest.fn().mockResolvedValue(payload),
}) as unknown as Response;
const range = {
from: new Date("2026-08-17T09:00:00.000Z"),
to: new Date("2026-08-17T10:00:00.000Z"),
};
describe("fetchHistoryData", () => {
beforeEach(() => jest.clearAllMocks());
it("queries SCADA readings once per selected network element", async () => {
jest.mocked(apiFetch).mockImplementation(async (input) => {
const url = new URL(String(input));
const elementId = url.searchParams.get("element_id") ?? "";
return jsonResponse({
[elementId]: [{ time: range.from.toISOString(), value: 1 }],
});
});
await fetchHistoryData(
[
["J-1", "junction"],
["J-2", "junction"],
],
range,
"none",
);
const elementIds = jest
.mocked(apiFetch)
.mock.calls.map(([input]) =>
new URL(String(input)).searchParams.get("element_id"),
);
expect(elementIds).toEqual(["J-1", "J-2", "J-1", "J-2"]);
});
it("uses the analysis run ID for historical scheme simulation data", async () => {
jest.mocked(apiFetch).mockResolvedValue(jsonResponse({ "P-1": [] }));
await fetchHistoryData(
[["P-1", "pipe"]],
range,
"scheme",
"99dd4142-368b-54cb-bfca-d59ee48f6298",
);
const simulationUrls = jest
.mocked(apiFetch)
.mock.calls.map(([input]) => new URL(String(input)))
.filter((url) => url.pathname.endsWith("/element-simulations"));
expect(simulationUrls).toHaveLength(2);
expect(
simulationUrls.map((url) => url.searchParams.get("run_id")),
).toEqual([null, "99dd4142-368b-54cb-bfca-d59ee48f6298"]);
});
it("rejects oversized element selections before issuing requests", async () => {
jest.mocked(apiFetch).mockResolvedValue(jsonResponse({}));
const featureInfos = Array.from(
{ length: 201 },
(_, index) => [`J-${index}`, "junction"] as [string, string],
);
await expect(
fetchHistoryData(featureInfos, range, "none"),
).rejects.toThrow("历史数据一次最多查询 200 个管网元素");
expect(apiFetch).not.toHaveBeenCalled();
});
it("limits concurrent SCADA requests for multi-element history", async () => {
let activeRequests = 0;
let peakRequests = 0;
jest.mocked(apiFetch).mockImplementation(async (input) => {
activeRequests += 1;
peakRequests = Math.max(peakRequests, activeRequests);
await new Promise((resolve) => setTimeout(resolve, 0));
activeRequests -= 1;
const elementId = new URL(String(input)).searchParams.get("element_id") ?? "";
return jsonResponse({ [elementId]: [] });
});
await fetchHistoryData(
Array.from(
{ length: 20 },
(_, index) => [`J-${index}`, "junction"] as [string, string],
),
range,
"none",
);
expect(peakRequests).toBeLessThanOrEqual(8);
});
});
@@ -53,10 +53,6 @@ export interface SCADADataPanelProps {
featureInfos: [string, string][];
/** 数据类型: realtime-查询模拟值和监测值, none-仅查询监测值, scheme-查询策略模拟值和监测值 */
type?: "realtime" | "scheme" | "none";
/** 策略类型 */
scheme_type?: string;
/** 策略名称 */
scheme_name?: string;
/** 默认展示的选项卡 */
defaultTab?: "chart" | "table";
/** Y 轴数值的小数位数 */
@@ -65,6 +61,8 @@ export interface SCADADataPanelProps {
start_time?: string;
/** 外部传入结束时间(ISO8601 字符串),用于初始化并触发查询 */
end_time?: string;
/** 方案分析运行 ID;方案数据必须显式指定,避免读取到其他页面残留的全局方案。 */
runId?: string;
/** 关闭面板 */
onClose: () => void;
}
@@ -73,6 +71,10 @@ type PanelTab = "chart" | "table";
type LoadingState = "idle" | "loading" | "success" | "error";
const MAX_HISTORY_ELEMENTS = 200;
const MAX_HISTORY_ELEMENT_ID_LENGTH = 128;
const HISTORY_SCADA_CONCURRENCY = 4;
const panelHeaderActionSx = {
color: "primary.contrastText",
backgroundColor: "rgba(255,255,255,0.08)",
@@ -81,51 +83,126 @@ const panelHeaderActionSx = {
},
};
/**
* 从后端 API 获取 SCADA 数据
*/
const fetchFromBackend = async (
const buildApiUrl = (
path: string,
params: Record<string, string | boolean>,
) => {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
searchParams.set(key, String(value));
});
return `${config.BACKEND_URL}${path}?${searchParams.toString()}`;
};
const fetchOptionalJson = async (url: string, signal?: AbortSignal) => {
const response = await apiFetch(url, { signal });
if (response.status === 404) return null;
if (!response.ok) {
throw new Error(`历史数据请求失败: HTTP ${response.status}`);
}
return response.json();
};
const mapWithConcurrency = async <Input, Output>(
items: Input[],
limit: number,
mapper: (item: Input, index: number) => Promise<Output>,
): Promise<Output[]> => {
const results = new Array<Output>(items.length);
let nextIndex = 0;
const workerCount = Math.min(limit, items.length);
await Promise.all(
Array.from({ length: workerCount }, async () => {
while (nextIndex < items.length) {
const currentIndex = nextIndex;
nextIndex += 1;
results[currentIndex] = await mapper(items[currentIndex], currentIndex);
}
}),
);
return results;
};
/** 从后端 API 获取管网元素的监测、实时模拟和方案模拟数据。 */
export const fetchHistoryData = async (
featureInfos: [string, string][],
range: { from: Date; to: Date },
type: "realtime" | "scheme" | "none",
scheme_type?: string,
scheme_name?: string
schemeRunId?: string,
signal?: AbortSignal,
): Promise<TimeSeriesPoint[]> => {
if (featureInfos.length === 0) {
return [];
}
if (featureInfos.length > MAX_HISTORY_ELEMENTS) {
throw new Error(`历史数据一次最多查询 ${MAX_HISTORY_ELEMENTS} 个管网元素`);
}
if (
featureInfos.some(
([id]) =>
id.trim().length === 0 || id.length > MAX_HISTORY_ELEMENT_ID_LENGTH,
)
) {
throw new Error("历史数据包含无效的管网元素 ID");
}
// 提取设备 ID 列表
const featureIds = featureInfos.map(([id]) => id);
const uniqueFeatureInfos = Array.from(
new Map(featureInfos.map((featureInfo) => [featureInfo[0], featureInfo])).values(),
);
const featureIds = uniqueFeatureInfos.map(([id]) => id);
const feature_ids = featureIds.join(",");
const start_time = dayjs(range.from).toISOString();
const end_time = dayjs(range.to).toISOString();
// 将 featureInfos 转换为后端期望的格式: id1:type1,id2:type2
const feature_infos = featureInfos
const feature_infos = uniqueFeatureInfos
.map(([id, type]) => `${id}:${type}`)
.join(",");
// 监测值数据接口(use_cleaned=false
const rawDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/views/element-scada-readings?element_id=${feature_ids}&start_time=${start_time}&end_time=${end_time}&use_cleaned=false`;
// 清洗数据接口(use_cleaned=true
const cleanedDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/views/element-scada-readings?element_id=${feature_ids}&start_time=${start_time}&end_time=${end_time}&use_cleaned=true`;
// 模拟数据接口
const simulationDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/views/element-simulations?feature_infos=${feature_infos}&start_time=${start_time}&end_time=${end_time}`;
// 策略模拟数据接口
const schemeSimulationDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/views/element-simulations?feature_infos=${feature_infos}&start_time=${start_time}&end_time=${end_time}&scheme_type=${scheme_type}&scheme_name=${scheme_name}`;
const fetchElementScadaData = async (useCleaned: boolean) => {
const results = await mapWithConcurrency(
featureIds,
HISTORY_SCADA_CONCURRENCY,
(elementId) =>
fetchOptionalJson(
buildApiUrl("/api/v1/timeseries/views/element-scada-readings", {
element_id: elementId,
start_time,
end_time,
use_cleaned: useCleaned,
}),
signal,
),
);
return Object.assign({}, ...results.filter(Boolean));
};
const simulationDataUrl = buildApiUrl(
"/api/v1/timeseries/views/element-simulations",
{ feature_infos, start_time, end_time },
);
if (type === "scheme" && !schemeRunId) {
throw new Error("历史方案缺少分析运行 ID,无法读取方案时序数据");
}
const schemeSimulationDataUrl = schemeRunId
? buildApiUrl("/api/v1/timeseries/views/element-simulations", {
feature_infos,
start_time,
end_time,
run_id: schemeRunId,
})
: null;
try {
if (type === "none") {
// 查询清洗值和监测值
const [cleanedRes, rawRes] = await Promise.all([
apiFetch(cleanedDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
apiFetch(rawDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
fetchElementScadaData(true),
fetchElementScadaData(false),
]);
const cleanedData = transformBackendData(cleanedRes, featureIds);
@@ -143,18 +220,10 @@ const fetchFromBackend = async (
} else if (type === "scheme") {
// 查询策略模拟值、实时模拟值、清洗值和监测值
const [cleanedRes, rawRes, simulationRes, schemeSimRes] = await Promise.all([
apiFetch(cleanedDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
apiFetch(rawDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
apiFetch(simulationDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
apiFetch(schemeSimulationDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
fetchElementScadaData(true),
fetchElementScadaData(false),
fetchOptionalJson(simulationDataUrl, signal),
fetchOptionalJson(schemeSimulationDataUrl!, signal),
]);
const cleanedData = transformBackendData(cleanedRes, featureIds);
@@ -176,15 +245,9 @@ const fetchFromBackend = async (
} else {
// realtime: 查询模拟值、清洗值和监测值
const [cleanedRes, rawRes, simulationRes] = await Promise.all([
apiFetch(cleanedDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
apiFetch(rawDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
apiFetch(simulationDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
fetchElementScadaData(true),
fetchElementScadaData(false),
fetchOptionalJson(simulationDataUrl, signal),
]);
const cleanedData = transformBackendData(cleanedRes, featureIds);
@@ -425,12 +488,11 @@ const emptyStateMessages: Record<
const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
featureInfos,
type = "none",
scheme_type = "burst_analysis",
scheme_name,
defaultTab = "chart",
fractionDigits = 2,
start_time,
end_time,
runId,
onClose,
}) => {
// 从 featureInfos 中提取设备 ID 列表
@@ -465,6 +527,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
"raw" | "clean" | "sim" | "all"
>(() => (featureInfos.length === 1 ? "all" : "clean"));
const draggableRef = useRef<HTMLDivElement>(null);
const requestControllerRef = useRef<AbortController | null>(null);
useEffect(() => {
setActiveTab(defaultTab);
@@ -503,6 +566,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
const handleFetch = useCallback(
async (reason: string) => {
if (!hasDevices) {
requestControllerRef.current?.abort();
setTimeSeries([]);
setLoadingState("idle");
setError(null);
@@ -511,26 +575,43 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
setLoadingState("loading");
setError(null);
requestControllerRef.current?.abort();
const requestController = new AbortController();
requestControllerRef.current = requestController;
try {
const { from: rangeFrom, to: rangeTo } = normalizedRange;
const result = await fetchFromBackend(
const result = await fetchHistoryData(
featureInfos,
{
from: rangeFrom.toDate(),
to: rangeTo.toDate(),
},
type,
scheme_type,
scheme_name
runId,
requestController.signal,
);
if (requestControllerRef.current !== requestController) return;
setTimeSeries(result);
setLoadingState("success");
} catch (err) {
if (
requestControllerRef.current !== requestController ||
(err instanceof Error && err.name === "AbortError")
) {
return;
}
setError(err instanceof Error ? err.message : "未知错误");
setLoadingState("error");
}
},
[featureInfos, hasDevices, normalizedRange, type, scheme_type, scheme_name]
[featureInfos, hasDevices, normalizedRange, runId, type],
);
useEffect(
() => () => {
requestControllerRef.current?.abort();
},
[],
);
// 设备变化时自动查询
@@ -123,6 +123,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
const [chatPanelType, setChatPanelType] = useState<
"realtime" | "scheme" | "none"
>("none");
const [chatPanelRunId, setChatPanelRunId] = useState<string | null>(null);
const [chatPanelTimeRange, setChatPanelTimeRange] = useState<{
startTime?: string;
endTime?: string;
@@ -142,6 +143,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
setHighlightFeatures,
setChatPanelFeatureInfos,
setChatPanelType,
setChatPanelRunId,
setChatPanelTimeRange,
setShowHistoryPanel,
setShowStyleEditor,
@@ -349,6 +351,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
setHighlightFeatures([]);
}
setChatPanelFeatureInfos(null);
setChatPanelRunId(null);
setChatPanelTimeRange(null);
break;
}
@@ -934,12 +937,14 @@ const Toolbar: React.FC<ToolbarProps> = ({
<ToolbarHistoryPanel
showHistoryPanel={showHistoryPanel}
chatPanelType={chatPanelType}
chatPanelRunId={chatPanelRunId}
chatPanelFeatureInfos={chatPanelFeatureInfos}
chatPanelTimeRange={chatPanelTimeRange}
highlightFeatures={highlightFeatures}
HistoryPanel={HistoryPanel}
schemeName={schemeName}
queryType={queryType}
schemeRunId={schemeRunId}
onClose={() => {
deactivateTool("history");
setActiveTools((prev) => prev.filter((t) => t !== "history"));
@@ -10,6 +10,7 @@ import HistoryDataPanel from "./HistoryDataPanel";
type ToolbarHistoryPanelProps = {
showHistoryPanel: boolean;
chatPanelType: "realtime" | "scheme" | "none";
chatPanelRunId: string | null;
chatPanelFeatureInfos: [string, string][] | null;
chatPanelTimeRange: {
startTime?: string;
@@ -19,18 +20,21 @@ type ToolbarHistoryPanelProps = {
HistoryPanel?: React.ComponentType<any>;
schemeName?: string;
queryType?: string;
schemeRunId?: string;
onClose: () => void;
};
const ToolbarHistoryPanel: React.FC<ToolbarHistoryPanelProps> = ({
showHistoryPanel,
chatPanelType,
chatPanelRunId,
chatPanelFeatureInfos,
chatPanelTimeRange,
highlightFeatures,
HistoryPanel,
schemeName,
queryType,
schemeRunId,
onClose,
}) => {
const featureInfos = useMemo(
@@ -75,8 +79,6 @@ const ToolbarHistoryPanel: React.FC<ToolbarHistoryPanelProps> = ({
return (
<HistoryDataPanel
featureInfos={featureInfos}
scheme_type="burst_analysis"
scheme_name={schemeName}
type={
chatPanelFeatureInfos
? chatPanelType
@@ -84,6 +86,7 @@ const ToolbarHistoryPanel: React.FC<ToolbarHistoryPanelProps> = ({
}
start_time={chatPanelTimeRange?.startTime}
end_time={chatPanelTimeRange?.endTime}
runId={chatPanelFeatureInfos ? chatPanelRunId ?? undefined : schemeRunId}
onClose={onClose}
/>
);
@@ -20,6 +20,7 @@ type UseToolbarChatActionsParams = {
setHighlightFeatures: Dispatch<SetStateAction<Feature[]>>;
setChatPanelFeatureInfos: Dispatch<SetStateAction<[string, string][] | null>>;
setChatPanelType: Dispatch<SetStateAction<"realtime" | "scheme" | "none">>;
setChatPanelRunId: Dispatch<SetStateAction<string | null>>;
setChatPanelTimeRange: Dispatch<
SetStateAction<{ startTime?: string; endTime?: string } | null>
>;
@@ -37,6 +38,7 @@ export const useToolbarChatActions = ({
setHighlightFeatures,
setChatPanelFeatureInfos,
setChatPanelType,
setChatPanelRunId,
setChatPanelTimeRange,
setShowHistoryPanel,
setShowStyleEditor,
@@ -126,6 +128,7 @@ export const useToolbarChatActions = ({
case "view_history": {
setChatPanelFeatureInfos(action.featureInfos);
setChatPanelType(action.dataType);
setChatPanelRunId(action.runId ?? null);
setChatPanelTimeRange({
startTime: action.startTime,
endTime: action.endTime,
@@ -136,6 +139,7 @@ export const useToolbarChatActions = ({
case "view_scada": {
setChatPanelFeatureInfos(action.featureInfos);
setChatPanelType("none");
setChatPanelRunId(null);
setChatPanelTimeRange({
startTime: action.startTime,
endTime: action.endTime,
@@ -249,6 +253,7 @@ export const useToolbarChatActions = ({
setChatPanelFeatureInfos,
setChatPanelTimeRange,
setChatPanelType,
setChatPanelRunId,
setHighlightFeatures,
setShowHistoryPanel,
setShowStyleEditor,
+5 -2
View File
@@ -85,7 +85,7 @@ describe("ProjectProvider authentication boundary", () => {
expect(mockApiFetch).not.toHaveBeenCalled();
});
it("restores and opens a saved project after authentication", async () => {
it("restores a saved project and reads its current metadata", async () => {
seedSavedProject();
mockUseSession.mockReturnValue({ status: "authenticated" });
@@ -95,7 +95,10 @@ describe("ProjectProvider authentication boundary", () => {
</ProjectProvider>,
);
await waitFor(() => expect(mockApiFetch).toHaveBeenCalledTimes(2));
await waitFor(() => expect(mockApiFetch).toHaveBeenCalledTimes(1));
expect(mockApiFetch).toHaveBeenCalledWith(
"http://backend.test/api/v1/projects/current",
);
expect(mockSetCurrentProjectId).toHaveBeenCalledWith(
"a2d67c84-fd9d-4feb-a500-c357244b2760",
);
+7 -13
View File
@@ -73,14 +73,6 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({
setIsConfigured(true);
try {
const openResponse = await apiFetch(
`${config.BACKEND_URL}/api/v1/projects/current`,
{ method: "POST" },
);
if (!openResponse.ok) {
throw new Error(`Failed to open project: HTTP ${openResponse.status}`);
}
const infoResponse = await apiFetch(
`${config.BACKEND_URL}/api/v1/projects/current`,
);
@@ -130,11 +122,13 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({
// If we have saved config, use it.
if (savedWorkspace && savedNetwork) {
applyConfig(
savedProjectId || savedNetwork || savedWorkspace,
savedWorkspace,
savedNetwork,
savedExtent ? savedExtent.split(",").map(Number) : config.MAP_EXTENT,
void Promise.resolve().then(() =>
applyConfig(
savedProjectId || savedNetwork || savedWorkspace,
savedWorkspace,
savedNetwork,
savedExtent ? savedExtent.split(",").map(Number) : config.MAP_EXTENT,
),
);
}
}, [applyConfig, status]);
+275 -1601
View File
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -2,12 +2,15 @@ import { completeLogout, reportLogoutAudit } from "./logoutFlow";
describe("reportLogoutAudit", () => {
it("starts an authenticated keepalive request", async () => {
const fetcher = jest.fn(async () => ({ ok: true }) as Response);
const fetcher = jest.fn(
async (_input: RequestInfo | URL, _init?: RequestInit) =>
({ ok: true }) as Response,
);
await reportLogoutAudit({
endpoint: "https://server.example/api/v1/audit-events",
accessToken: "access-token",
fetcher,
fetcher: fetcher as typeof fetch,
});
expect(fetcher).toHaveBeenCalledTimes(1);
+1
View File
@@ -26,6 +26,7 @@ export type ChatToolAction =
type: "view_history";
featureInfos: [string, string][];
dataType: "realtime" | "scheme" | "none";
runId?: string;
startTime?: string;
endTime?: string;
}