Skip to content

Commit b0011fd

Browse files
authored
Improve xDS web UI: navigation, pagination, history diff, and client … (#1320)
…subscriptions Motivation: - Selecting a group landed on the Listeners page instead of the Overview page, making it hard to get a quick summary of the group at a glance. - The Resources and History pages had no pagination, making large groups hard to browse. - The group-wide History page showed commit summaries as plain text with no way to inspect what actually changed in a commit. - The References page gave no indication that inbound references from other groups are not shown, which could be confusing. - The control plane Clients table had no visibility into what resource names each client subscribed to. Modifications: - Change the default navigation target when selecting a group (group list, sidebar dropdown, breadcrumb, and the `useXdsRoute` fallback) from `listeners` to `overview`. - Add client-side pagination (default 10 rows) to the Resources and History pages. - Add a Subscriptions column to the Clients table. - Track subscribed resource names in `XdsClientStatusTracker`. Result: - Selecting a group now lands on the Overview page. - Resources and History pages are paginated. - Every commit in the group-wide History is inspectable with a before/after diff. - The References page clearly communicates its cross-group limitation.
1 parent 12bc1f0 commit b0011fd

46 files changed

Lines changed: 3585 additions & 133 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

webapp/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
/playwright-report/
33
/blob-report/
44
/playwright/.cache/
5+
tsconfig.tsbuildinfo
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/*
2+
* Copyright 2026 LY Corporation
3+
*
4+
* LY Corporation licenses this file to you under the Apache License,
5+
* version 2.0 (the "License"); you may not use this file except in compliance
6+
* with the License. You may obtain a copy of the License at:
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations
14+
* under the License.
15+
*/
16+
import { DiffEditor, loader } from '@monaco-editor/react';
17+
import { useColorMode } from '@chakra-ui/react';
18+
import { useEffect, useState } from 'react';
19+
import { Loading } from 'dogma/common/components/Loading';
20+
21+
interface JsonDiffEditorProps {
22+
// The left-hand (baseline) document, e.g. the content before a change.
23+
original: string;
24+
// The right-hand (compared) document, e.g. the content after a change.
25+
modified: string;
26+
height?: string | number;
27+
}
28+
29+
// Wraps the Monaco diff editor and configures it to use the locally bundled `monaco-editor` package (provided
30+
// by MonacoWebpackPlugin) instead of a CDN, mirroring {@link JsonEditor}.
31+
export const JsonDiffEditor = ({ original, modified, height = '60vh' }: JsonDiffEditorProps) => {
32+
const { colorMode } = useColorMode();
33+
const [ready, setReady] = useState(false);
34+
35+
useEffect(() => {
36+
let active = true;
37+
(async () => {
38+
const monaco = await import('monaco-editor');
39+
loader.config({ monaco });
40+
await loader.init();
41+
if (active) {
42+
setReady(true);
43+
}
44+
})();
45+
return () => {
46+
active = false;
47+
};
48+
}, []);
49+
50+
if (!ready) {
51+
return <Loading />;
52+
}
53+
54+
return (
55+
<DiffEditor
56+
height={height}
57+
language="json"
58+
theme={colorMode === 'light' ? 'vs' : 'vs-dark'}
59+
original={original}
60+
modified={modified}
61+
options={{
62+
readOnly: true,
63+
renderSideBySide: true,
64+
minimap: { enabled: false },
65+
automaticLayout: true,
66+
scrollBeyondLastLine: false,
67+
lineNumbersMinChars: 4,
68+
}}
69+
/>
70+
);
71+
};

webapp/src/dogma/features/api/apiSlice.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import {
4747
} from 'dogma/features/settings/server-status/ServerStatusDto';
4848
import Router from 'next/router';
4949
import { VariableDto } from 'dogma/features/project/settings/variables/VariableDto';
50+
import { XdsApp, XdsClientStatus, XdsSnapshot } from 'dogma/features/xds/ControlPlaneStatusDto';
5051

5152
export type ApiAction<Arg, Result> = {
5253
(arg: Arg): { unwrap: () => Promise<Result> };
@@ -598,6 +599,21 @@ export const apiSlice = createApi({
598599
}),
599600
transformResponse: () => true,
600601
}),
602+
// System-administrator-only views of the xDS control plane runtime state.
603+
getXdsClients: builder.query<XdsClientStatus[], void>({
604+
query: () => '/api/v1/xds/clients',
605+
}),
606+
getXdsApps: builder.query<XdsApp[], void>({
607+
query: () => '/api/v1/xds/apps',
608+
}),
609+
getXdsSnapshot: builder.query<XdsSnapshot, { group?: string; appId?: string }>({
610+
query: ({ group, appId }) => {
611+
if (appId) {
612+
return `/api/v1/xds/snapshot?appId=${encodeURIComponent(appId)}`;
613+
}
614+
return group ? `/api/v1/xds/snapshot?group=${encodeURIComponent(group)}` : '/api/v1/xds/snapshot';
615+
},
616+
}),
601617
}),
602618
});
603619

@@ -613,6 +629,9 @@ function variableApiPrefix(projectName: string, repoName?: string): string {
613629
export const {
614630
// xDS
615631
useIsXdsWebEnabledQuery,
632+
useGetXdsClientsQuery,
633+
useGetXdsAppsQuery,
634+
useGetXdsSnapshotQuery,
616635
// Project
617636
useGetProjectsQuery,
618637
useRestoreProjectMutation,
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
/*
2+
* Copyright 2026 LY Corporation
3+
*
4+
* LY Corporation licenses this file to you under the Apache License,
5+
* version 2.0 (the "License"); you may not use this file except in compliance
6+
* with the License. You may obtain a copy of the License at:
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations
14+
* under the License.
15+
*/
16+
import { Badge, Box, Code, HStack, Tag, Text, VStack, Wrap, WrapItem } from '@chakra-ui/react';
17+
import {
18+
ColumnDef,
19+
createColumnHelper,
20+
getCoreRowModel,
21+
getSortedRowModel,
22+
useReactTable,
23+
} from '@tanstack/react-table';
24+
import { useMemo } from 'react';
25+
import { DataTable } from 'dogma/features/xds/DataTable';
26+
import { XdsAckStatus, XdsClientStatus, xdsTypeOf } from 'dogma/features/xds/ControlPlaneStatusDto';
27+
28+
// One row per (stream, resource type). A single ADS stream multiplexes several types, so it produces several
29+
// rows.
30+
interface ClientRow {
31+
streamId: number;
32+
nodeId: string;
33+
nodeCluster: string;
34+
appId: string;
35+
acronym: string;
36+
status: XdsAckStatus;
37+
nackReason: string;
38+
lastSeen: number;
39+
resourceNames: string[];
40+
}
41+
42+
const columnHelper = createColumnHelper<ClientRow>();
43+
44+
function statusColorScheme(status: XdsAckStatus): string {
45+
switch (status) {
46+
case 'ACKED':
47+
return 'green';
48+
case 'NACKED':
49+
return 'red';
50+
default:
51+
return 'gray';
52+
}
53+
}
54+
55+
function formatTime(millis: number): string {
56+
return millis > 0 ? new Date(millis).toLocaleString() : '-';
57+
}
58+
59+
export const ClientStatusTable = ({ clients }: { clients: XdsClientStatus[] }) => {
60+
const rows = useMemo<ClientRow[]>(
61+
() =>
62+
clients.flatMap((client) =>
63+
client.types.map((type) => ({
64+
streamId: client.streamId,
65+
nodeId: client.nodeId,
66+
nodeCluster: client.nodeCluster,
67+
appId: client.appId,
68+
acronym: xdsTypeOf(type.typeUrl).acronym,
69+
status: type.status,
70+
nackReason: type.nackReason,
71+
lastSeen: type.lastSeen,
72+
resourceNames: type.resourceNames ?? [],
73+
})),
74+
),
75+
[clients],
76+
);
77+
78+
const columns = useMemo(() => {
79+
const cols: ColumnDef<ClientRow, unknown>[] = [
80+
columnHelper.accessor('nodeId', {
81+
header: 'Node',
82+
cell: (info) => (
83+
<VStack align="start" spacing={0}>
84+
<Text fontWeight="semibold">{info.getValue() || '(unknown)'}</Text>
85+
{info.row.original.nodeCluster && (
86+
<Text fontSize="xs" color="gray.500">
87+
{info.row.original.nodeCluster}
88+
</Text>
89+
)}
90+
<Text fontSize="xs" color="gray.400">
91+
stream #{info.row.original.streamId}
92+
</Text>
93+
</VStack>
94+
),
95+
}),
96+
columnHelper.accessor('appId', {
97+
header: 'App ID',
98+
cell: (info) =>
99+
info.getValue() ? (
100+
<Code>{info.getValue()}</Code>
101+
) : (
102+
<Text fontSize="sm" color="gray.400">
103+
anonymous
104+
</Text>
105+
),
106+
}),
107+
columnHelper.accessor('acronym', {
108+
header: 'Type',
109+
cell: (info) => (
110+
<Tag colorScheme="purple" size="sm">
111+
{info.getValue()}
112+
</Tag>
113+
),
114+
}),
115+
columnHelper.accessor((row) => row.resourceNames.join(','), {
116+
id: 'subscriptions',
117+
header: 'Subscriptions',
118+
enableSorting: false,
119+
cell: (info) => {
120+
const names = info.row.original.resourceNames;
121+
if (names.length === 0) {
122+
return (
123+
<Badge colorScheme="gray" variant="subtle">
124+
wildcard
125+
</Badge>
126+
);
127+
}
128+
return (
129+
<Wrap spacing={1}>
130+
{names.map((name) => (
131+
<WrapItem key={name}>
132+
<Code fontSize="xs">{name}</Code>
133+
</WrapItem>
134+
))}
135+
</Wrap>
136+
);
137+
},
138+
}),
139+
columnHelper.accessor('status', {
140+
header: 'Status',
141+
cell: (info) => (
142+
<Badge colorScheme={statusColorScheme(info.getValue() as XdsAckStatus)}>{info.getValue()}</Badge>
143+
),
144+
}),
145+
columnHelper.accessor('nackReason', {
146+
header: 'NACK reason',
147+
enableSorting: false,
148+
cell: (info) =>
149+
info.getValue() ? (
150+
<Text color="red.500" fontSize="sm" maxW="md" whiteSpace="pre-wrap">
151+
{info.getValue()}
152+
</Text>
153+
) : (
154+
<Text color="gray.400" fontSize="sm">
155+
-
156+
</Text>
157+
),
158+
}),
159+
columnHelper.accessor((row) => row.lastSeen, {
160+
id: 'lastSeen',
161+
header: 'Last seen',
162+
cell: (info) => (
163+
<Text fontSize="sm" color="gray.600">
164+
{formatTime(info.row.original.lastSeen)}
165+
</Text>
166+
),
167+
}),
168+
];
169+
return cols;
170+
}, []);
171+
172+
const table = useReactTable({
173+
data: rows,
174+
columns,
175+
getCoreRowModel: getCoreRowModel(),
176+
getSortedRowModel: getSortedRowModel(),
177+
});
178+
179+
if (rows.length === 0) {
180+
return (
181+
<Box mt={4}>
182+
<HStack color="gray.500">
183+
<Text>No xDS clients are currently connected to this server.</Text>
184+
</HStack>
185+
</Box>
186+
);
187+
}
188+
189+
return <DataTable table={table} />;
190+
};

0 commit comments

Comments
 (0)