fix(ui): heartbeat logs crash + header/card improvements

- Fix HeartbeatLog interface field names (snake_case → camelCase) to match
  backend JSON tags — fixes white screen crash on logs dialog open
- Format duration with formatDuration(), show token usage per log entry
- Fix logs dialog padding to match standard pattern + close button overlap
- Show model override and last error on heartbeat card
- Replace status badge with colored dot in agent header
- Compact header badges on mobile (icon-only for evolving/static)
- Switch sidebar to tablet breakpoint (1024px) for earlier collapse
This commit is contained in:
viettranx committed 2026-03-19 13:35:57 +07:00
1 parent ef1a1463ac
commit b6ff57ddab
5 files changed
+55 -35

No files matched your search

+2 -2
View File
@@ -6,7 +6,7 @@ import { Topbar } from "./topbar";
import { ErrorBoundary } from "@/components/shared/error-boundary";
import { useUiStore } from "@/stores/use-ui-store";
import { useAuthStore } from "@/stores/use-auth-store";
import { useIsMobile } from "@/hooks/use-media-query";
import { useIsTablet } from "@/hooks/use-media-query";
import { cn } from "@/lib/utils";
export function AppLayout() {
@@ -16,7 +16,7 @@ export function AppLayout() {
const mobileSidebarOpen = useUiStore((s) => s.mobileSidebarOpen);
const setMobileSidebarOpen = useUiStore((s) => s.setMobileSidebarOpen);
const connected = useAuthStore((s) => s.connected);
const isMobile = useIsMobile();
const isMobile = useIsTablet();
return (
<div className="flex h-dvh overflow-hidden safe-top">
@@ -7,6 +7,7 @@ import type { AgentData } from "@/types/agent";
import type { HeartbeatConfig } from "@/pages/agents/hooks/use-agent-heartbeat";
import { useCountdown } from "@/hooks/use-countdown";
import { agentDisplayName, agentKeyDisplay } from "./agent-display-utils";
import { cn } from "@/lib/utils";
interface AgentHeaderProps {
agent: AgentData;
@@ -51,21 +52,29 @@ export function AgentHeader({ agent, heartbeat, onBack, onDelete, onAdvanced, on
{agent.is_default && (
<Star className="h-3.5 w-3.5 shrink-0 fill-amber-400 text-amber-400" />
)}
<Badge
variant={
agent.status === "active"
? "success"
: agent.status === "summon_failed"
? "destructive"
: "secondary"
}
className="text-[10px]"
>
{agent.status === "summon_failed" ? t("detail.summonFailed") : agent.status}
</Badge>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="text-[10px]">{agent.agent_type}</Badge>
<span
className={cn(
"inline-block h-2.5 w-2.5 shrink-0 rounded-full",
agent.status === "active"
? "bg-emerald-500"
: agent.status === "summon_failed"
? "bg-destructive"
: "bg-muted-foreground/50",
)}
/>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{agent.status === "summon_failed" ? t("detail.summonFailed") : agent.status}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="text-[10px]">
<span className="hidden sm:inline">{agent.agent_type}</span>
<span className="sm:hidden">{agent.agent_type === "predefined" ? "P" : "O"}</span>
</Badge>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[260px] text-xs">
{agent.agent_type === "predefined" ? t("card.predefinedTooltip") : t("card.openTooltip")}
@@ -78,8 +87,8 @@ export function AgentHeader({ agent, heartbeat, onBack, onDelete, onAdvanced, on
variant={selfEvolve ? "default" : "outline"}
className={`text-[10px] ${selfEvolve ? "bg-violet-100 text-violet-700 hover:bg-violet-100 dark:bg-violet-900/30 dark:text-violet-300" : "text-muted-foreground"}`}
>
<Sparkles className="mr-0.5 h-2.5 w-2.5" />
{selfEvolve ? t("detail.evolving") : t("detail.static")}
<Sparkles className="h-2.5 w-2.5 sm:mr-0.5" />
<span className="hidden sm:inline">{selfEvolve ? t("detail.evolving") : t("detail.static")}</span>
</Badge>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[240px] text-xs">
@@ -8,7 +8,7 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { useMinLoading } from "@/hooks/use-min-loading";
import type { HeartbeatLog } from "@/pages/agents/hooks/use-agent-heartbeat";
import { formatRelativeTime, formatDate } from "@/lib/format";
import { formatRelativeTime, formatDate, formatDuration } from "@/lib/format";
const PAGE_SIZE = 20;
@@ -69,8 +69,8 @@ export function HeartbeatLogsDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] w-[95vw] sm:max-w-3xl flex flex-col gap-0 p-0">
<DialogHeader className="px-4 pt-4 pb-3 sm:px-6 sm:pt-6 flex-row items-center justify-between gap-2">
<DialogContent className="max-h-[90vh] w-[95vw] flex flex-col sm:max-w-3xl">
<DialogHeader className="flex-row items-center justify-between gap-2 pr-8">
<DialogTitle>{t("heartbeat.logsTitle")}</DialogTitle>
<Button
variant="outline"
@@ -84,7 +84,7 @@ export function HeartbeatLogsDialog({
</Button>
</DialogHeader>
<div className="overflow-y-auto min-h-0 max-h-[60vh] -mx-0 px-4 sm:px-6 overscroll-contain">
<div className="overflow-y-auto min-h-0 max-h-[60vh] -mx-4 px-4 sm:-mx-6 sm:px-6 overscroll-contain">
{fetching && logs.length === 0 ? (
<div className="flex items-center justify-center py-12">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
@@ -98,13 +98,18 @@ export function HeartbeatLogsDialog({
{logs.map((log) => (
<div key={log.id} className="rounded-md border p-3 text-sm space-y-1">
<div className="flex items-center justify-between gap-2 flex-wrap">
<span className="text-xs text-muted-foreground" title={formatDate(log.ran_at)}>
{formatRelativeTime(log.ran_at)}
<span className="text-xs text-muted-foreground" title={formatDate(log.ranAt)}>
{formatRelativeTime(log.ranAt)}
</span>
<div className="flex items-center gap-2">
{log.duration_ms != null && (
<span className="text-xs text-muted-foreground">
{log.duration_ms}ms
<div className="flex items-center gap-1.5">
{(log.inputTokens != null || log.outputTokens != null) && (
<span className="text-[10px] text-muted-foreground">
{log.inputTokens ?? 0}↓ {log.outputTokens ?? 0}↑
</span>
)}
{log.durationMs != null && (
<span className="text-[10px] text-muted-foreground">
{formatDuration(log.durationMs)}
</span>
)}
<Badge variant={statusVariant(log.status)} className="text-[10px]">
@@ -115,8 +120,8 @@ export function HeartbeatLogsDialog({
{log.summary && (
<p className="text-xs text-muted-foreground line-clamp-2">{log.summary}</p>
)}
{log.skip_reason && (
<p className="text-xs text-muted-foreground italic">{log.skip_reason}</p>
{log.skipReason && (
<p className="text-xs text-muted-foreground italic">{log.skipReason}</p>
)}
{log.error && (
<p className="text-xs text-destructive">{log.error}</p>
@@ -128,7 +133,7 @@ export function HeartbeatLogsDialog({
</div>
{/* Pagination */}
<div className="flex items-center justify-between gap-2 px-4 py-3 sm:px-6 border-t text-xs text-muted-foreground">
<div className="flex items-center justify-between gap-2 border-t pt-3 text-xs text-muted-foreground">
<span>
{total > 0
? t("heartbeat.logsPagination", {
@@ -88,6 +88,9 @@ export function HeartbeatCard({ heartbeat }: HeartbeatCardProps) {
{config.channel && (
<span>{config.channel}{config.chatId ? `/${config.chatId}` : ""}</span>
)}
{config.model && (
<span className="text-violet-600 dark:text-violet-400">{config.model}</span>
)}
</div>
<div className="flex flex-wrap gap-x-3 gap-y-0.5">
@@ -106,6 +109,9 @@ export function HeartbeatCard({ heartbeat }: HeartbeatCardProps) {
<span>{t("heartbeat.runs")}: {config.runCount}</span>
<span>{t("heartbeat.suppressed")}: {config.suppressCount}</span>
</div>
{config.lastError && config.lastStatus === "error" && (
<p className="text-xs text-destructive truncate">{config.lastError}</p>
)}
</div>
<div className="flex gap-2 pt-1">
@@ -40,11 +40,11 @@ export interface HeartbeatLog {
status: string;
summary?: string;
error?: string;
duration_ms?: number;
input_tokens?: number;
output_tokens?: number;
skip_reason?: string;
ran_at: string;
durationMs?: number;
inputTokens?: number;
outputTokens?: number;
skipReason?: string;
ranAt: string;
}
/** Return type of useAgentHeartbeat, for passing as props. */