mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-16 00:14:57 +00:00
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:
1 parent
e2c131c57d
commit
c50ce7c48e
13 files changed
+102
-63
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user