fix(ui): usage tab crash, chat layout overflow, marker color

- Fix ErrorBoundary retry to remount children via Fragment key counter
- Guard PieChart against zero-total slices and single-slice paddingAngle
- Disable Recharts animations to prevent state corruption on rapid filter
- Add per-section ErrorBoundary in usage page and per-donut isolation
- Fix Brush key to be content-aware for proper reset on filter change
- Fix chat page flex containment chain (overflow-hidden, min-h-0, shrink-0)
- Fix list marker color: use currentColor instead of hardcoded foreground
This commit is contained in:
viettranx committed 2026-03-21 12:55:16 +07:00
1 parent e2c131c57d
commit c50ce7c48e
13 files changed
+102 -63

No files matched your search

+2 -2
View File
@@ -97,10 +97,10 @@ export function MediaGallery({ items }: MediaGalleryProps) {
href={item.path}
download={item.fileName ?? "image"}
onClick={(e) => e.stopPropagation()}
className="shrink-0 rounded-md bg-black/60 p-1 text-white shadow-md hover:bg-black/80 transition-colors cursor-pointer"
className="shrink-0 rounded-lg bg-white/90 dark:bg-neutral-800/90 p-1.5 text-neutral-700 dark:text-neutral-200 shadow-md ring-1 ring-black/10 dark:ring-white/10 hover:bg-white dark:hover:bg-neutral-700 transition-colors cursor-pointer"
title="Download"
>
<Download className="h-4 w-4" />
<Download className="h-4.5 w-4.5" />
</a>
</div>
</div>
@@ -1,4 +1,4 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
import { Component, Fragment, type ErrorInfo, type ReactNode } from "react";
import { AlertTriangle, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import i18n from "@/i18n";
@@ -10,12 +10,13 @@ interface Props {
interface State {
hasError: boolean;
retryKey: number;
}
export class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false };
state: State = { hasError: false, retryKey: 0 };
static getDerivedStateFromError(): State {
static getDerivedStateFromError(): Partial<State> {
return { hasError: true };
}
@@ -24,11 +25,13 @@ export class ErrorBoundary extends Component<Props, State> {
}
private handleRetry = () => {
this.setState({ hasError: false });
this.setState((prev) => ({ hasError: false, retryKey: prev.retryKey + 1 }));
};
render() {
if (!this.state.hasError) return this.props.children;
if (!this.state.hasError) {
return <Fragment key={this.state.retryKey}>{this.props.children}</Fragment>;
}
if (this.props.fallback) return this.props.fallback;
@@ -35,7 +35,7 @@ export function ImageLightbox({ src, alt, fileName, size, onClose }: ImageLightb
href={src}
download={displayName}
onClick={(e) => e.stopPropagation()}
className="rounded-full bg-black/50 p-2 text-white hover:bg-black/70 transition-colors cursor-pointer"
className="rounded-full bg-white/90 dark:bg-neutral-800/90 p-2.5 text-neutral-700 dark:text-neutral-200 shadow-md ring-1 ring-black/10 dark:ring-white/10 hover:bg-white dark:hover:bg-neutral-700 transition-colors cursor-pointer"
title="Download"
>
<Download className="h-5 w-5" />
@@ -43,7 +43,7 @@ export function ImageLightbox({ src, alt, fileName, size, onClose }: ImageLightb
<button
type="button"
onClick={onClose}
className="rounded-full bg-black/50 p-2 text-white hover:bg-black/70 transition-colors cursor-pointer"
className="rounded-full bg-white/90 dark:bg-neutral-800/90 p-2.5 text-neutral-700 dark:text-neutral-200 shadow-md ring-1 ring-black/10 dark:ring-white/10 hover:bg-white dark:hover:bg-neutral-700 transition-colors cursor-pointer"
>
<X className="h-5 w-5" />
</button>
@@ -185,10 +185,10 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps)
href={resolvedSrc}
download={displayName}
onClick={(e) => e.stopPropagation()}
className="absolute top-1.5 right-1.5 flex items-center justify-center rounded-md bg-black/60 p-1 text-white shadow-md opacity-0 transition-opacity group-hover/img:opacity-100 hover:bg-black/80 cursor-pointer"
className="absolute top-2 right-2 flex items-center justify-center rounded-lg bg-white/90 dark:bg-neutral-800/90 p-1.5 text-neutral-700 dark:text-neutral-200 shadow-md ring-1 ring-black/10 dark:ring-white/10 opacity-0 transition-opacity group-hover/img:opacity-100 hover:bg-white dark:hover:bg-neutral-700 cursor-pointer"
title="Download"
>
<Download className="h-4 w-4" />
<Download className="h-4.5 w-4.5" />
</a>
)}
</span>
+1 -1
View File
@@ -253,7 +253,7 @@
margin: 4px 0;
}
.md-render li::marker {
color: var(--foreground) !important;
color: currentColor;
}
.md-render :where(li > p):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
margin: 0;
+7 -5
View File
@@ -158,7 +158,7 @@ export function ChatPage() {
}, [handleNewChat]);
return (
<div className="relative flex h-full">
<div className="relative flex h-full overflow-hidden">
{/* Chat Sidebar */}
{isMobile ? (
<>
@@ -200,9 +200,9 @@ export function ChatPage() {
)}
{/* Main chat area */}
<div className="flex flex-1 flex-col">
<div className="flex flex-1 min-h-0 flex-col">
{isMobile && (
<div className="flex items-center border-b px-3 py-2 landscape-compact">
<div className="flex shrink-0 items-center border-b px-3 py-2 landscape-compact">
<button
onClick={() => setChatSidebarOpen(true)}
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
@@ -213,10 +213,12 @@ export function ChatPage() {
</div>
)}
<ChatTopBar agentId={agentId} isRunning={isRunning} isBusy={isBusy} activity={activity} teamTasks={teamTasks} />
<div className="shrink-0">
<ChatTopBar agentId={agentId} isRunning={isRunning} isBusy={isBusy} activity={activity} teamTasks={teamTasks} />
</div>
{sendError && (
<div className="border-b bg-destructive/10 px-4 py-2 text-sm text-destructive">
<div className="shrink-0 border-b bg-destructive/10 px-4 py-2 text-sm text-destructive">
{sendError}
</div>
)}
@@ -35,7 +35,6 @@ export function DistributionDonut({
metric = "request_count",
}: DistributionDonutProps) {
const { t } = useTranslation("usage");
const isEmpty = !loading && data.length === 0;
const sorted = [...data].sort((a, b) => b[metric] - a[metric]);
const top = sorted.slice(0, MAX_SLICES);
@@ -49,8 +48,10 @@ export function DistributionDonut({
}
const total = slices.reduce((sum, s) => sum + s.value, 0);
const isEmpty = !loading && (data.length === 0 || total === 0);
const handleClick = (entry: PieSectorDataItem) => {
const handleClick = (entry: PieSectorDataItem | null | undefined) => {
if (!entry) return;
const name = entry.name as string | undefined;
if (!name || name === t("analytics.distribution.other")) return;
onSliceClick?.(name);
@@ -66,10 +67,11 @@ export function DistributionDonut({
cy="45%"
innerRadius={55}
outerRadius={85}
paddingAngle={2}
paddingAngle={slices.length > 1 ? 2 : 0}
dataKey="value"
onClick={handleClick}
style={{ cursor: onSliceClick ? "pointer" : "default" }}
isAnimationActive={false}
label={false}
>
{slices.map((entry, idx) => (
@@ -90,9 +92,13 @@ export function DistributionDonut({
</text>
<Tooltip
formatter={(value, name) => {
const v = typeof value === "number" ? value : Number(value) || 0;
const pct = total > 0 ? ((v / total) * 100).toFixed(1) : "0";
return [`${v.toLocaleString()} (${pct}%)`, String(name)];
try {
const v = typeof value === "number" ? value : Number(value) || 0;
const pct = total > 0 ? ((v / total) * 100).toFixed(1) : "0";
return [`${v.toLocaleString()} (${pct}%)`, String(name)];
} catch {
return [String(value), String(name)];
}
}}
/>
<Legend
@@ -1,4 +1,5 @@
import { useTranslation } from "react-i18next";
import { ErrorBoundary } from "@/components/shared/error-boundary";
import { DistributionDonut } from "./distribution-donut";
import { useUsageFilterContext } from "../context/usage-filter-context";
import type { SnapshotBreakdown } from "../hooks/use-usage-analytics";
@@ -21,29 +22,35 @@ export function DistributionRow({
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<DistributionDonut
title={t("analytics.distribution.provider")}
data={providerBreakdown}
loading={loading}
activeValue={filters.provider}
onSliceClick={(dim) => toggleFilter("provider", dim)}
metric="llm_call_count"
/>
<DistributionDonut
title={t("analytics.distribution.model")}
data={modelBreakdown}
loading={loading}
activeValue={filters.model}
onSliceClick={(dim) => toggleFilter("model", dim)}
metric="llm_call_count"
/>
<DistributionDonut
title={t("analytics.distribution.channel")}
data={channelBreakdown}
loading={loading}
activeValue={filters.channel}
onSliceClick={(dim) => toggleFilter("channel", dim)}
/>
<ErrorBoundary>
<DistributionDonut
title={t("analytics.distribution.provider")}
data={providerBreakdown}
loading={loading}
activeValue={filters.provider}
onSliceClick={(dim) => toggleFilter("provider", dim)}
metric="llm_call_count"
/>
</ErrorBoundary>
<ErrorBoundary>
<DistributionDonut
title={t("analytics.distribution.model")}
data={modelBreakdown}
loading={loading}
activeValue={filters.model}
onSliceClick={(dim) => toggleFilter("model", dim)}
metric="llm_call_count"
/>
</ErrorBoundary>
<ErrorBoundary>
<DistributionDonut
title={t("analytics.distribution.channel")}
data={channelBreakdown}
loading={loading}
activeValue={filters.channel}
onSliceClick={(dim) => toggleFilter("channel", dim)}
/>
</ErrorBoundary>
</div>
);
}
@@ -54,6 +54,7 @@ export function DurationChart({ data, loading, granularity }: DurationChartProps
name={t("analytics.durationChart.avgDuration")}
fill="#8b5cf6"
radius={[2, 2, 0, 0]}
isAnimationActive={false}
/>
<Line
yAxisId="right"
@@ -63,6 +64,7 @@ export function DurationChart({ data, loading, granularity }: DurationChartProps
stroke="#ef4444"
strokeWidth={2}
dot={{ r: 3, fill: "#ef4444" }}
isAnimationActive={false}
/>
</ComposedChart>
</ResponsiveContainer>
@@ -47,10 +47,10 @@ export function KnowledgeChart({ data, loading, granularity }: KnowledgeChartPro
<YAxis tick={{ fontSize: 11 }} width={40} />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="memory_docs" name={t("analytics.knowledgeChart.memoryDocs")} stroke="#3b82f6" strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="memory_chunks" name={t("analytics.knowledgeChart.memoryChunks")} stroke="#93c5fd" strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="kg_entities" name={t("analytics.knowledgeChart.kgEntities")} stroke="#8b5cf6" strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="kg_relations" name={t("analytics.knowledgeChart.kgRelations")} stroke="#c4b5fd" strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="memory_docs" name={t("analytics.knowledgeChart.memoryDocs")} stroke="#3b82f6" strokeWidth={2} dot={false} isAnimationActive={false} />
<Line type="monotone" dataKey="memory_chunks" name={t("analytics.knowledgeChart.memoryChunks")} stroke="#93c5fd" strokeWidth={2} dot={false} isAnimationActive={false} />
<Line type="monotone" dataKey="kg_entities" name={t("analytics.knowledgeChart.kgEntities")} stroke="#8b5cf6" strokeWidth={2} dot={false} isAnimationActive={false} />
<Line type="monotone" dataKey="kg_relations" name={t("analytics.knowledgeChart.kgRelations")} stroke="#c4b5fd" strokeWidth={2} dot={false} isAnimationActive={false} />
</LineChart>
</ResponsiveContainer>
</ChartWrapper>
@@ -57,6 +57,7 @@ export function RequestVolumeChart({ data, loading, granularity }: RequestVolume
name={t("analytics.requestChart.requests")}
fill="#3b82f6"
radius={[2, 2, 0, 0]}
isAnimationActive={false}
/>
<Line
yAxisId="right"
@@ -66,6 +67,7 @@ export function RequestVolumeChart({ data, loading, granularity }: RequestVolume
stroke="#ef4444"
strokeWidth={2}
dot={{ r: 3, fill: "#ef4444" }}
isAnimationActive={false}
/>
</ComposedChart>
</ResponsiveContainer>
@@ -38,7 +38,7 @@ export function TokenAreaChart({ data, loading, granularity }: TokenAreaChartPro
emptyText={t("analytics.noData")}
>
<ResponsiveContainer width="100%" height={300}>
<AreaChart key={chartData.length} data={chartData} margin={{ top: 4, right: 16, left: 0, bottom: 0 }}>
<AreaChart key={chartData[0]?.bucket_time ?? chartData.length} data={chartData} margin={{ top: 4, right: 16, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="inputGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
@@ -64,6 +64,7 @@ export function TokenAreaChart({ data, loading, granularity }: TokenAreaChartPro
stroke="#3b82f6"
fill="url(#inputGrad)"
strokeWidth={2}
isAnimationActive={false}
stackId="tokens"
/>
<Area
@@ -73,6 +74,7 @@ export function TokenAreaChart({ data, loading, granularity }: TokenAreaChartPro
stroke="#10b981"
fill="url(#outputGrad)"
strokeWidth={2}
isAnimationActive={false}
stackId="tokens"
/>
{hasCache && (
@@ -83,6 +85,7 @@ export function TokenAreaChart({ data, loading, granularity }: TokenAreaChartPro
stroke="#06b6d4"
fill="none"
strokeWidth={1.5}
isAnimationActive={false}
strokeDasharray="4 2"
/>
)}
+26 -12
View File
@@ -100,22 +100,36 @@ function AnalyticsDashboard() {
</div>
)}
<SummaryCards current={current} previous={previous} loading={loading} />
<ErrorBoundary>
<SummaryCards current={current} previous={previous} loading={loading} />
</ErrorBoundary>
<TokenAreaChart data={timeseries} loading={loading} granularity={filters.granularity} />
<RequestVolumeChart data={timeseries} loading={loading} granularity={filters.granularity} />
<ErrorBoundary>
<TokenAreaChart data={timeseries} loading={loading} granularity={filters.granularity} />
</ErrorBoundary>
<ErrorBoundary>
<RequestVolumeChart data={timeseries} loading={loading} granularity={filters.granularity} />
</ErrorBoundary>
<DistributionRow
providerBreakdown={providerBreakdown}
modelBreakdown={modelBreakdown}
channelBreakdown={channelBreakdown}
loading={loading}
/>
<ErrorBoundary>
<DistributionRow
providerBreakdown={providerBreakdown}
modelBreakdown={modelBreakdown}
channelBreakdown={channelBreakdown}
loading={loading}
/>
</ErrorBoundary>
<DurationChart data={timeseries} loading={loading} granularity={filters.granularity} />
<KnowledgeChart data={timeseries} loading={loading} granularity={filters.granularity} />
<ErrorBoundary>
<DurationChart data={timeseries} loading={loading} granularity={filters.granularity} />
</ErrorBoundary>
<ErrorBoundary>
<KnowledgeChart data={timeseries} loading={loading} granularity={filters.granularity} />
</ErrorBoundary>
<TopModelsTable data={modelBreakdown} loading={loading} />
<ErrorBoundary>
<TopModelsTable data={modelBreakdown} loading={loading} />
</ErrorBoundary>
{/* Legacy records table */}
<div>