feat(admin): dashboard home, telemetry section, enrollment ops screen
- mission-control dashboard: now strip, tournament quick actions, usage sparklines, error feed, live activity, tools list - /admin/telemetry with functions/events/errors/players/usage child routes, hand-rolled svg chart primitives, decomposed activities table - error browser with grouping and mark-resolved - consolidated per-tournament enrollment screen; hub simplified with run tournament promoted - /admin/activities redirects to /admin/telemetry/functions
This commit is contained in:
@@ -1,268 +1,32 @@
|
||||
import { useState, useMemo, memo } from "react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { Trans, Plural, useLingui } from "@lingui/react/macro";
|
||||
import {
|
||||
Text,
|
||||
TextInput,
|
||||
Stack,
|
||||
Group,
|
||||
Box,
|
||||
Container,
|
||||
Divider,
|
||||
UnstyledButton,
|
||||
Select,
|
||||
Pagination,
|
||||
Code,
|
||||
Alert,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
MagnifyingGlassIcon,
|
||||
CaretUpIcon,
|
||||
CaretDownIcon,
|
||||
CheckIcon,
|
||||
XIcon,
|
||||
ChecksIcon,
|
||||
PulseIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { Alert, Code, Container, Group, Stack, Text } from "@mantine/core";
|
||||
import { CheckIcon, XIcon, PulseIcon } from "@phosphor-icons/react";
|
||||
import { Activity, ActivitySearchParams } from "../types";
|
||||
import { useActivities } from "../queries";
|
||||
import Sheet from "@/components/sheet/sheet";
|
||||
import { usePlayers } from "@/features/players/queries";
|
||||
import { useSheet } from "@/hooks/use-sheet";
|
||||
import {
|
||||
DetailSheet,
|
||||
PaginatedList,
|
||||
SearchFilterBar,
|
||||
TelemetryListItem,
|
||||
} from "@/features/telemetry/components/shared";
|
||||
|
||||
interface ActivityListItemProps {
|
||||
activity: Activity;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const ActivityListItem = memo(({ activity, onClick }: ActivityListItemProps) => {
|
||||
const { t, i18n } = useLingui();
|
||||
const playerName = typeof activity.player === "object" && activity.player
|
||||
? `${activity.player.first_name} ${activity.player.last_name}`
|
||||
: t`System`;
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString(i18n.locale);
|
||||
};
|
||||
|
||||
return (
|
||||
<UnstyledButton
|
||||
w="100%"
|
||||
p="md"
|
||||
onClick={onClick}
|
||||
style={{
|
||||
borderRadius: 0,
|
||||
transition: "background-color 0.15s ease",
|
||||
}}
|
||||
styles={{
|
||||
root: {
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--mantine-color-gray-0)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" w="100%">
|
||||
<Stack gap={4} flex={1}>
|
||||
<Group gap="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
{activity.name}
|
||||
</Text>
|
||||
{activity.success ? (
|
||||
<CheckIcon size={16} color="var(--mantine-color-green-6)" />
|
||||
) : (
|
||||
<XIcon size={16} color="var(--mantine-color-red-6)" />
|
||||
)}
|
||||
</Group>
|
||||
<Group gap="md">
|
||||
<Text size="xs" c="dimmed">
|
||||
{playerName}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
<Trans>{activity.duration}ms</Trans>
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatDate(activity.created)}
|
||||
</Text>
|
||||
</Group>
|
||||
{activity.error && (
|
||||
<Text size="xs" c="red" lineClamp={1}>
|
||||
{activity.error}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
});
|
||||
|
||||
interface ActivityDetailsSheetProps {
|
||||
activity: Activity | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ActivityDetailsSheet = memo(({ activity, isOpen, onClose }: ActivityDetailsSheetProps) => {
|
||||
const { t, i18n } = useLingui();
|
||||
if (!activity) return null;
|
||||
|
||||
const playerName = typeof activity.player === "object" && activity.player
|
||||
? `${activity.player.first_name} ${activity.player.last_name}`
|
||||
: t`System`;
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString(i18n.locale);
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet title={t`Activity Details`} opened={isOpen} onChange={onClose}>
|
||||
<Stack gap="md" p="md">
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" fw={700} c="dimmed">
|
||||
<Trans>Function Name</Trans>
|
||||
</Text>
|
||||
<Text size="sm">{activity.name}</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" fw={700} c="dimmed">
|
||||
<Trans>Status</Trans>
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
{activity.success ? (
|
||||
<>
|
||||
<CheckIcon size={16} color="var(--mantine-color-green-6)" />
|
||||
<Text size="sm" c="green">
|
||||
<Trans>Success</Trans>
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XIcon size={16} color="var(--mantine-color-red-6)" />
|
||||
<Text size="sm" c="red">
|
||||
<Trans>Failed</Trans>
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" fw={700} c="dimmed">
|
||||
<Trans>Player</Trans>
|
||||
</Text>
|
||||
<Text size="sm">{playerName}</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" fw={700} c="dimmed">
|
||||
<Trans>Duration</Trans>
|
||||
</Text>
|
||||
<Text size="sm"><Trans>{activity.duration}ms</Trans></Text>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" fw={700} c="dimmed">
|
||||
<Trans>Created</Trans>
|
||||
</Text>
|
||||
<Text size="sm">{formatDate(activity.created)}</Text>
|
||||
</Stack>
|
||||
|
||||
{activity.user_agent && (
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" fw={700} c="dimmed">
|
||||
<Trans>User Agent</Trans>
|
||||
</Text>
|
||||
<Text size="xs" style={{ wordBreak: "break-word" }}>
|
||||
{activity.user_agent}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{activity.error && (
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" fw={700} c="dimmed">
|
||||
<Trans>Error Message</Trans>
|
||||
</Text>
|
||||
<Alert color="red" variant="light">
|
||||
<Text size="sm" style={{ wordBreak: "break-word" }}>
|
||||
{activity.error}
|
||||
</Text>
|
||||
</Alert>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{activity.arguments && (
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" fw={700} c="dimmed">
|
||||
<Trans>Arguments</Trans>
|
||||
</Text>
|
||||
<Code block style={{ fontSize: "11px" }}>
|
||||
{JSON.stringify(activity.arguments, null, 2)}
|
||||
</Code>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Sheet>
|
||||
);
|
||||
});
|
||||
|
||||
const ActivitiesResults = ({ searchParams, page, setPage, onActivityClick }: any) => {
|
||||
const { data: result } = useActivities(searchParams);
|
||||
return (
|
||||
<>
|
||||
<Stack gap={0}>
|
||||
{result.items.map((activity: Activity, index: number) => (
|
||||
<Box key={activity.id}>
|
||||
<ActivityListItem
|
||||
activity={activity}
|
||||
onClick={() => onActivityClick(activity)}
|
||||
/>
|
||||
{index < result.items.length - 1 && <Divider />}
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{result.items.length === 0 && (
|
||||
<Stack align="center" gap="md" py="xl">
|
||||
<ThemeIcon size="xl" variant="light" radius="md">
|
||||
<PulseIcon size={32} />
|
||||
</ThemeIcon>
|
||||
<Title order={3} c="dimmed">
|
||||
<Trans>No Activities Found</Trans>
|
||||
</Title>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{result.totalPages > 1 && (
|
||||
<Group justify="center" py="md">
|
||||
<Pagination
|
||||
total={result.totalPages}
|
||||
value={page}
|
||||
onChange={setPage}
|
||||
size="sm"
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
const SLOW_THRESHOLD_MS = 1000;
|
||||
|
||||
export const ActivitiesTable = () => {
|
||||
const { t } = useLingui();
|
||||
const { t, i18n } = useLingui();
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState("");
|
||||
const [successFilter, setSuccessFilter] = useState<string | null>(null);
|
||||
const [playerFilter, setPlayerFilter] = useState<string | null>(null);
|
||||
const [slowOnly, setSlowOnly] = useState(false);
|
||||
const [sortBy, setSortBy] = useState("-created");
|
||||
const [selectedActivity, setSelectedActivity] = useState<Activity | null>(null);
|
||||
const { isOpen: detailsOpened, open: openDetails, close: closeDetails } = useSheet();
|
||||
|
||||
const {
|
||||
isOpen: detailsOpened,
|
||||
open: openDetails,
|
||||
close: closeDetails,
|
||||
} = useSheet();
|
||||
const { data: players } = usePlayers();
|
||||
|
||||
const searchParams: ActivitySearchParams = useMemo(
|
||||
() => ({
|
||||
@@ -270,13 +34,24 @@ export const ActivitiesTable = () => {
|
||||
perPage: 100,
|
||||
name: search || undefined,
|
||||
success: successFilter === "success" ? true : successFilter === "failure" ? false : undefined,
|
||||
player: playerFilter || undefined,
|
||||
sortBy,
|
||||
}),
|
||||
[page, search, successFilter, sortBy]
|
||||
[page, search, successFilter, playerFilter, sortBy]
|
||||
);
|
||||
|
||||
const { data: result } = useActivities(searchParams);
|
||||
|
||||
const items = useMemo(
|
||||
() => (slowOnly ? result.items.filter((a) => a.duration > SLOW_THRESHOLD_MS) : result.items),
|
||||
[result.items, slowOnly]
|
||||
);
|
||||
|
||||
const playerName = (activity: Activity) =>
|
||||
typeof activity.player === "object" && activity.player
|
||||
? `${activity.player.first_name} ${activity.player.last_name}`
|
||||
: t`System`;
|
||||
|
||||
const handleSort = (field: string) => {
|
||||
setSortBy((prev) => {
|
||||
if (prev === field) return `-${field}`;
|
||||
@@ -285,12 +60,6 @@ export const ActivitiesTable = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const getSortIcon = (field: string) => {
|
||||
if (sortBy === field) return <CaretUpIcon size={14} />;
|
||||
if (sortBy === `-${field}`) return <CaretDownIcon size={14} />;
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleActivityClick = (activity: Activity) => {
|
||||
setSelectedActivity(activity);
|
||||
openDetails();
|
||||
@@ -304,90 +73,163 @@ export const ActivitiesTable = () => {
|
||||
return (
|
||||
<Container size="100%" px={0}>
|
||||
<Stack gap="xs">
|
||||
<Stack gap="xs" px="md">
|
||||
<TextInput
|
||||
placeholder={t`serverFn name`}
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.currentTarget.value);
|
||||
setPage(1);
|
||||
}}
|
||||
leftSection={<MagnifyingGlassIcon size={16} />}
|
||||
size="md"
|
||||
/>
|
||||
|
||||
<Group>
|
||||
<Select
|
||||
placeholder={t`Status`}
|
||||
value={successFilter}
|
||||
onChange={(value) => {
|
||||
<SearchFilterBar
|
||||
searchValue={search}
|
||||
onSearchChange={(value) => {
|
||||
setSearch(value);
|
||||
setPage(1);
|
||||
}}
|
||||
searchPlaceholder={t`serverFn name`}
|
||||
filters={[
|
||||
{
|
||||
placeholder: t`Status`,
|
||||
value: successFilter,
|
||||
onChange: (value) => {
|
||||
setSuccessFilter(value);
|
||||
setPage(1);
|
||||
}}
|
||||
data={[
|
||||
},
|
||||
data: [
|
||||
{ value: "all", label: t`All` },
|
||||
{ value: "success", label: t`Success` },
|
||||
{ value: "failure", label: t`Failure` },
|
||||
]}
|
||||
clearable
|
||||
size="sm"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Group px="md" justify="space-between" align="center">
|
||||
<Text size="10px" lh={0} c="dimmed">
|
||||
],
|
||||
},
|
||||
{
|
||||
placeholder: t`Player`,
|
||||
value: playerFilter,
|
||||
onChange: (value) => {
|
||||
setPlayerFilter(value);
|
||||
setPage(1);
|
||||
},
|
||||
data: players.map((player) => ({
|
||||
value: player.id,
|
||||
label: `${player.first_name} ${player.last_name}`,
|
||||
})),
|
||||
searchable: true,
|
||||
},
|
||||
]}
|
||||
chips={[
|
||||
{
|
||||
label: t`Slow >1s (this page)`,
|
||||
checked: slowOnly,
|
||||
onChange: setSlowOnly,
|
||||
},
|
||||
]}
|
||||
sortFields={[
|
||||
{ field: "created", label: <Trans>Date</Trans> },
|
||||
{ field: "duration", label: <Trans>Duration</Trans> },
|
||||
]}
|
||||
sortBy={sortBy}
|
||||
onSort={handleSort}
|
||||
summary={
|
||||
<Plural value={result.totalItems} one="# total activity" other="# total activities" />
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
<Trans>Sort:</Trans>
|
||||
</Text>
|
||||
<UnstyledButton
|
||||
onClick={() => handleSort("created")}
|
||||
style={{ display: "flex", alignItems: "center", gap: 4 }}
|
||||
>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={sortBy.includes("created") ? 600 : 400}
|
||||
c={sortBy.includes("created") ? "var(--mantine-color-text)" : "dimmed"}
|
||||
>
|
||||
<Trans>Date</Trans>
|
||||
</Text>
|
||||
{getSortIcon("created")}
|
||||
</UnstyledButton>
|
||||
<Text size="xs" c="dimmed">
|
||||
•
|
||||
</Text>
|
||||
<UnstyledButton
|
||||
onClick={() => handleSort("duration")}
|
||||
style={{ display: "flex", alignItems: "center", gap: 4 }}
|
||||
>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={sortBy.includes("duration") ? 600 : 400}
|
||||
c={sortBy.includes("duration") ? "var(--mantine-color-text)" : "dimmed"}
|
||||
>
|
||||
<Trans>Duration</Trans>
|
||||
</Text>
|
||||
{getSortIcon("duration")}
|
||||
</UnstyledButton>
|
||||
</Group>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
|
||||
<ActivitiesResults
|
||||
searchParams={searchParams}
|
||||
<PaginatedList
|
||||
items={items}
|
||||
keyOf={(activity) => activity.id}
|
||||
empty={{ icon: <PulseIcon size={32} />, title: t`No Activities Found` }}
|
||||
page={page}
|
||||
setPage={setPage}
|
||||
onActivityClick={handleActivityClick}
|
||||
totalPages={result.totalPages}
|
||||
onPageChange={setPage}
|
||||
renderItem={(activity) => (
|
||||
<TelemetryListItem
|
||||
title={activity.name}
|
||||
icon={
|
||||
activity.success ? (
|
||||
<CheckIcon size={16} color="var(--mantine-color-green-6)" />
|
||||
) : (
|
||||
<XIcon size={16} color="var(--mantine-color-red-6)" />
|
||||
)
|
||||
}
|
||||
meta={[
|
||||
playerName(activity),
|
||||
<Trans>{activity.duration}ms</Trans>,
|
||||
new Date(activity.created).toLocaleString(i18n.locale),
|
||||
]}
|
||||
error={activity.error}
|
||||
onClick={() => handleActivityClick(activity)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<ActivityDetailsSheet
|
||||
activity={selectedActivity}
|
||||
isOpen={detailsOpened}
|
||||
<DetailSheet
|
||||
title={t`Activity Details`}
|
||||
opened={detailsOpened}
|
||||
onClose={handleCloseDetails}
|
||||
fields={
|
||||
selectedActivity
|
||||
? [
|
||||
{ label: <Trans>Function Name</Trans>, value: selectedActivity.name },
|
||||
{
|
||||
label: <Trans>Status</Trans>,
|
||||
value: (
|
||||
<Group gap="xs">
|
||||
{selectedActivity.success ? (
|
||||
<>
|
||||
<CheckIcon size={16} color="var(--mantine-color-green-6)" />
|
||||
<Text size="sm" c="green">
|
||||
<Trans>Success</Trans>
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XIcon size={16} color="var(--mantine-color-red-6)" />
|
||||
<Text size="sm" c="red">
|
||||
<Trans>Failed</Trans>
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
{ label: <Trans>Player</Trans>, value: playerName(selectedActivity) },
|
||||
{
|
||||
label: <Trans>Duration</Trans>,
|
||||
value: (
|
||||
<Text size="sm">
|
||||
<Trans>{selectedActivity.duration}ms</Trans>
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: <Trans>Created</Trans>,
|
||||
value: new Date(selectedActivity.created).toLocaleString(i18n.locale),
|
||||
},
|
||||
...(selectedActivity.user_agent
|
||||
? [{ label: <Trans>User Agent</Trans>, value: selectedActivity.user_agent }]
|
||||
: []),
|
||||
...(selectedActivity.error
|
||||
? [
|
||||
{
|
||||
label: <Trans>Error Message</Trans>,
|
||||
value: (
|
||||
<Alert color="red" variant="light">
|
||||
<Text size="sm" style={{ wordBreak: "break-word" }}>
|
||||
{selectedActivity.error}
|
||||
</Text>
|
||||
</Alert>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(selectedActivity.arguments
|
||||
? [
|
||||
{
|
||||
label: <Trans>Arguments</Trans>,
|
||||
value: (
|
||||
<Code block style={{ fontSize: "11px" }}>
|
||||
{JSON.stringify(selectedActivity.arguments, null, 2)}
|
||||
</Code>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
: []
|
||||
}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user