Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions webapp/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
/playwright-report/
/blob-report/
/playwright/.cache/
tsconfig.tsbuildinfo
71 changes: 71 additions & 0 deletions webapp/src/dogma/common/components/JsonDiffEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*
* Copyright 2026 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { DiffEditor, loader } from '@monaco-editor/react';
import { useColorMode } from '@chakra-ui/react';
import { useEffect, useState } from 'react';
import { Loading } from 'dogma/common/components/Loading';

interface JsonDiffEditorProps {
// The left-hand (baseline) document, e.g. the content before a change.
original: string;
// The right-hand (compared) document, e.g. the content after a change.
modified: string;
height?: string | number;
}

// Wraps the Monaco diff editor and configures it to use the locally bundled `monaco-editor` package (provided
// by MonacoWebpackPlugin) instead of a CDN, mirroring {@link JsonEditor}.
export const JsonDiffEditor = ({ original, modified, height = '60vh' }: JsonDiffEditorProps) => {
const { colorMode } = useColorMode();
const [ready, setReady] = useState(false);

useEffect(() => {
let active = true;
(async () => {
const monaco = await import('monaco-editor');
loader.config({ monaco });
await loader.init();
if (active) {
setReady(true);
}
})();
return () => {
active = false;
};
}, []);

if (!ready) {
return <Loading />;
}

return (
<DiffEditor
height={height}
language="json"
theme={colorMode === 'light' ? 'vs' : 'vs-dark'}
original={original}
modified={modified}
options={{
readOnly: true,
renderSideBySide: true,
minimap: { enabled: false },
automaticLayout: true,
scrollBeyondLastLine: false,
lineNumbersMinChars: 4,
}}
/>
);
};
19 changes: 19 additions & 0 deletions webapp/src/dogma/features/api/apiSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
} from 'dogma/features/settings/server-status/ServerStatusDto';
import Router from 'next/router';
import { VariableDto } from 'dogma/features/project/settings/variables/VariableDto';
import { XdsApp, XdsClientStatus, XdsSnapshot } from 'dogma/features/xds/ControlPlaneStatusDto';

export type ApiAction<Arg, Result> = {
(arg: Arg): { unwrap: () => Promise<Result> };
Expand Down Expand Up @@ -598,6 +599,21 @@ export const apiSlice = createApi({
}),
transformResponse: () => true,
}),
// System-administrator-only views of the xDS control plane runtime state.
getXdsClients: builder.query<XdsClientStatus[], void>({
query: () => '/api/v1/xds/clients',
}),
getXdsApps: builder.query<XdsApp[], void>({
query: () => '/api/v1/xds/apps',
}),
getXdsSnapshot: builder.query<XdsSnapshot, { group?: string; appId?: string }>({
query: ({ group, appId }) => {
if (appId) {
return `/api/v1/xds/snapshot?appId=${encodeURIComponent(appId)}`;
}
return group ? `/api/v1/xds/snapshot?group=${encodeURIComponent(group)}` : '/api/v1/xds/snapshot';
},
}),
}),
});

Expand All @@ -613,6 +629,9 @@ function variableApiPrefix(projectName: string, repoName?: string): string {
export const {
// xDS
useIsXdsWebEnabledQuery,
useGetXdsClientsQuery,
useGetXdsAppsQuery,
useGetXdsSnapshotQuery,
// Project
useGetProjectsQuery,
useRestoreProjectMutation,
Expand Down
190 changes: 190 additions & 0 deletions webapp/src/dogma/features/xds/ClientStatusTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
/*
* Copyright 2026 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { Badge, Box, Code, HStack, Tag, Text, VStack, Wrap, WrapItem } from '@chakra-ui/react';
import {
ColumnDef,
createColumnHelper,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table';
import { useMemo } from 'react';
import { DataTable } from 'dogma/features/xds/DataTable';
import { XdsAckStatus, XdsClientStatus, xdsTypeOf } from 'dogma/features/xds/ControlPlaneStatusDto';

// One row per (stream, resource type). A single ADS stream multiplexes several types, so it produces several
// rows.
interface ClientRow {
streamId: number;
nodeId: string;
nodeCluster: string;
appId: string;
acronym: string;
status: XdsAckStatus;
nackReason: string;
lastSeen: number;
resourceNames: string[];
}

const columnHelper = createColumnHelper<ClientRow>();

function statusColorScheme(status: XdsAckStatus): string {
switch (status) {
case 'ACKED':
return 'green';
case 'NACKED':
return 'red';
default:
return 'gray';
}
}

function formatTime(millis: number): string {
return millis > 0 ? new Date(millis).toLocaleString() : '-';
}

export const ClientStatusTable = ({ clients }: { clients: XdsClientStatus[] }) => {
const rows = useMemo<ClientRow[]>(
() =>
clients.flatMap((client) =>
client.types.map((type) => ({
streamId: client.streamId,
nodeId: client.nodeId,
nodeCluster: client.nodeCluster,
appId: client.appId,
acronym: xdsTypeOf(type.typeUrl).acronym,
status: type.status,
nackReason: type.nackReason,
lastSeen: type.lastSeen,
resourceNames: type.resourceNames ?? [],
})),
),
[clients],
);

const columns = useMemo(() => {
const cols: ColumnDef<ClientRow, unknown>[] = [
columnHelper.accessor('nodeId', {
header: 'Node',
cell: (info) => (
<VStack align="start" spacing={0}>
<Text fontWeight="semibold">{info.getValue() || '(unknown)'}</Text>
{info.row.original.nodeCluster && (
<Text fontSize="xs" color="gray.500">
{info.row.original.nodeCluster}
</Text>
)}
<Text fontSize="xs" color="gray.400">
stream #{info.row.original.streamId}
</Text>
</VStack>
),
}),
columnHelper.accessor('appId', {
header: 'App ID',
cell: (info) =>
info.getValue() ? (
<Code>{info.getValue()}</Code>
) : (
<Text fontSize="sm" color="gray.400">
anonymous
</Text>
),
}),
columnHelper.accessor('acronym', {
header: 'Type',
cell: (info) => (
<Tag colorScheme="purple" size="sm">
{info.getValue()}
</Tag>
),
}),
columnHelper.accessor((row) => row.resourceNames.join(','), {
id: 'subscriptions',
header: 'Subscriptions',
enableSorting: false,
cell: (info) => {
const names = info.row.original.resourceNames;
if (names.length === 0) {
return (
<Badge colorScheme="gray" variant="subtle">
wildcard
</Badge>
);
}
return (
<Wrap spacing={1}>
{names.map((name) => (
<WrapItem key={name}>
<Code fontSize="xs">{name}</Code>
</WrapItem>
))}
</Wrap>
);
},
}),
columnHelper.accessor('status', {
header: 'Status',
cell: (info) => (
<Badge colorScheme={statusColorScheme(info.getValue() as XdsAckStatus)}>{info.getValue()}</Badge>
),
}),
columnHelper.accessor('nackReason', {
header: 'NACK reason',
enableSorting: false,
cell: (info) =>
info.getValue() ? (
<Text color="red.500" fontSize="sm" maxW="md" whiteSpace="pre-wrap">
{info.getValue()}
</Text>
) : (
<Text color="gray.400" fontSize="sm">
-
</Text>
),
}),
columnHelper.accessor((row) => row.lastSeen, {
id: 'lastSeen',
header: 'Last seen',
cell: (info) => (
<Text fontSize="sm" color="gray.600">
{formatTime(info.row.original.lastSeen)}
</Text>
),
}),
];
return cols;
}, []);

const table = useReactTable({
data: rows,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
});

if (rows.length === 0) {
return (
<Box mt={4}>
<HStack color="gray.500">
<Text>No xDS clients are currently connected to this server.</Text>
</HStack>
</Box>
);
}

return <DataTable table={table} />;
};
Loading
Loading