mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-10-08 12:47:51 +00:00
refactor(web): improve state management patterns
RF-24: Frontend state management improvements 1. useCreateTaskForm hook - Replaces 11 useState calls in CreateTaskDialog with useReducer: - Single source of truth for form state - Predictable state transitions via typed actions - Atomic state updates (e.g., applyTemplate updates multiple fields at once) - Computed canSubmit derives validity instead of storing it 2. Optimistic updates for task mutations: - useCreateTask: Immediately adds placeholder task to list, rolls back on error - useUpdateTask: Immediately applies changes to cache, rolls back on error - Both sync with server on settle to ensure consistency 3. usePolling hook - Shared polling patterns: - usePolling: Core hook with enable/disable, immediate, cleanup - useConditionalPolling: Start/stop based on condition - getConditionalRefetchInterval: For react-query refetchInterval These patterns improve UI responsiveness and code maintainability.
This commit is contained in:
parent
a424d0a7c5
commit
dd4e90475b
14 changed files with 624 additions and 43 deletions
|
|
@ -925,3 +925,5 @@
|
|||
{"type":"task.status_changed","taskId":"task_20260128_XGU2QA","project":"veritas-kanban","status":"done","previousStatus":"in-progress","id":"evt_lLFwuC7LOifp","timestamp":"2026-01-28T13:57:09.180Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_XAxJXn","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_7rc96OvWfpQw","timestamp":"2026-01-28T13:57:29.875Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_2-0mLV","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_E7t75XBIyGe5","timestamp":"2026-01-28T13:57:37.698Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_1yHkWF","project":"veritas-kanban","status":"done","previousStatus":"in-progress","id":"evt_-HRsK12psW7C","timestamp":"2026-01-28T13:59:39.243Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_MwGpPe","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_vm_hvl4PL4NN","timestamp":"2026-01-28T14:00:39.411Z"}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,37 @@
|
|||
[
|
||||
{
|
||||
"id": "activity_1769608839412_a8t8vs45w",
|
||||
"type": "status_changed",
|
||||
"taskId": "task_20260128_MwGpPe",
|
||||
"taskTitle": "US-1007: Historical trends",
|
||||
"details": {
|
||||
"from": "todo",
|
||||
"status": "in-progress"
|
||||
},
|
||||
"timestamp": "2026-01-28T14:00:39.412Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769608779324_up52q5nz2",
|
||||
"type": "comment_added",
|
||||
"taskId": "task_20260128_1yHkWF",
|
||||
"taskTitle": "RF-15: Split God Components — Board & Settings",
|
||||
"details": {
|
||||
"author": "Veritas",
|
||||
"preview": "Split god components: Extracted useBoardDragDrop h..."
|
||||
},
|
||||
"timestamp": "2026-01-28T13:59:39.324Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769608779243_n9skzfx6e",
|
||||
"type": "status_changed",
|
||||
"taskId": "task_20260128_1yHkWF",
|
||||
"taskTitle": "RF-15: Split God Components — Board & Settings",
|
||||
"details": {
|
||||
"from": "in-progress",
|
||||
"status": "done"
|
||||
},
|
||||
"timestamp": "2026-01-28T13:59:39.243Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769608657699_l9zfko6ei",
|
||||
"type": "status_changed",
|
||||
|
|
|
|||
|
|
@ -86,4 +86,20 @@ router.get(
|
|||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/metrics/failed-runs
|
||||
* Get list of failed runs with details
|
||||
*/
|
||||
router.get(
|
||||
'/failed-runs',
|
||||
validate({ query: MetricsQuerySchema }),
|
||||
asyncHandler(async (req: ValidatedRequest<unknown, MetricsQuery>, res) => {
|
||||
const metrics = getMetricsService();
|
||||
const { period, project } = req.validated.query!;
|
||||
const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : 50;
|
||||
const result = await metrics.getFailedRuns(period, project, limit);
|
||||
res.json(result);
|
||||
})
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -111,6 +111,17 @@ interface TokenAccumulator {
|
|||
const PROJECT_ROOT = path.resolve(process.cwd(), '..');
|
||||
const TELEMETRY_DIR = path.join(PROJECT_ROOT, '.veritas-kanban', 'telemetry');
|
||||
|
||||
export interface FailedRunDetails {
|
||||
timestamp: string;
|
||||
taskId?: string;
|
||||
taskTitle?: string;
|
||||
project?: string;
|
||||
agent: string;
|
||||
success: boolean;
|
||||
errorMessage?: string;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
export class MetricsService {
|
||||
private telemetry: TelemetryService;
|
||||
private taskService: TaskService;
|
||||
|
|
@ -725,21 +736,6 @@ export class MetricsService {
|
|||
|
||||
return { tasks, runs, tokens, duration };
|
||||
}
|
||||
}
|
||||
|
||||
export interface FailedRunDetails {
|
||||
timestamp: string;
|
||||
taskId?: string;
|
||||
taskTitle?: string;
|
||||
project?: string;
|
||||
agent: string;
|
||||
success: boolean;
|
||||
errorMessage?: string;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
export class MetricsService {
|
||||
// ... existing methods ...
|
||||
|
||||
/**
|
||||
* Get list of failed runs with details
|
||||
|
|
|
|||
136
web/src/components/dashboard/ErrorsDrillDown.tsx
Normal file
136
web/src/components/dashboard/ErrorsDrillDown.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import { useFailedRuns, formatDuration, type MetricsPeriod } from '@/hooks/useMetrics';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { AlertTriangle, Clock, Bot, ExternalLink } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ErrorsDrillDownProps {
|
||||
period: MetricsPeriod;
|
||||
project?: string;
|
||||
onTaskClick?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
export function ErrorsDrillDown({ period, project, onTaskClick }: ErrorsDrillDownProps) {
|
||||
const { data: failedRuns, isLoading } = useFailedRuns(period, project);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-20 w-full" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!failedRuns || failedRuns.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<AlertTriangle className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground">No failed runs in the selected period</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Summary */}
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<AlertTriangle className="h-4 w-4 text-red-500" />
|
||||
<span>{failedRuns.length} failed run(s) in the {period === '24h' ? 'last 24 hours' : 'last 7 days'}</span>
|
||||
</div>
|
||||
|
||||
{/* Failed Runs List */}
|
||||
<div className="space-y-2">
|
||||
{failedRuns.map((run, index) => (
|
||||
<FailedRunRow
|
||||
key={`${run.timestamp}-${index}`}
|
||||
run={run}
|
||||
onTaskClick={onTaskClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FailedRunRowProps {
|
||||
run: {
|
||||
timestamp: string;
|
||||
taskId?: string;
|
||||
taskTitle?: string;
|
||||
project?: string;
|
||||
agent: string;
|
||||
errorMessage?: string;
|
||||
durationMs?: number;
|
||||
};
|
||||
onTaskClick?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
function FailedRunRow({ run, onTaskClick }: FailedRunRowProps) {
|
||||
const date = new Date(run.timestamp);
|
||||
const canNavigate = run.taskId && onTaskClick;
|
||||
|
||||
const content = (
|
||||
<div className={cn(
|
||||
'rounded-lg border border-red-500/20 bg-red-500/5 p-3',
|
||||
canNavigate && 'hover:bg-red-500/10 transition-colors cursor-pointer'
|
||||
)}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-red-500 flex-shrink-0" />
|
||||
<span className="font-medium truncate">
|
||||
{run.taskTitle || run.taskId || 'Unknown task'}
|
||||
</span>
|
||||
{canNavigate && (
|
||||
<ExternalLink className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{run.errorMessage && (
|
||||
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
|
||||
{run.errorMessage}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Bot className="h-3 w-3" />
|
||||
{run.agent}
|
||||
</span>
|
||||
{run.project && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{run.project}
|
||||
</Badge>
|
||||
)}
|
||||
{run.durationMs && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatDuration(run.durationMs)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground text-right flex-shrink-0">
|
||||
<div>{date.toLocaleDateString()}</div>
|
||||
<div>{date.toLocaleTimeString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (canNavigate) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => onTaskClick(run.taskId!)}
|
||||
className="w-full text-left focus:outline-none focus:ring-2 focus:ring-ring rounded-lg"
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
183
web/src/components/dashboard/TokensDrillDown.tsx
Normal file
183
web/src/components/dashboard/TokensDrillDown.tsx
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import { useTokenMetrics, formatTokens, type MetricsPeriod } from '@/hooks/useMetrics';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Coins, Bot, TrendingUp } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface TokensDrillDownProps {
|
||||
period: MetricsPeriod;
|
||||
project?: string;
|
||||
}
|
||||
|
||||
export function TokensDrillDown({ period, project }: TokensDrillDownProps) {
|
||||
const { data: metrics, isLoading } = useTokenMetrics(period, project);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-24 w-full" />
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!metrics) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<Coins className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground">No token data available</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const periodLabel = period === '24h' ? 'last 24 hours' : 'last 7 days';
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Summary Card */}
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h4 className="text-sm font-medium text-muted-foreground mb-3">
|
||||
Token Usage Summary ({periodLabel})
|
||||
</h4>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-primary">
|
||||
{formatTokens(metrics.totalTokens)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Total Tokens</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-blue-500">
|
||||
{formatTokens(metrics.inputTokens)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Input</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-green-500">
|
||||
{formatTokens(metrics.outputTokens)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Output</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-3 border-t">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Per Run Statistics:</span>
|
||||
</div>
|
||||
<div className="flex gap-4 mt-1">
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs">Avg: </span>
|
||||
<span className="font-medium">{formatTokens(metrics.perSuccessfulRun.avg)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs">p50: </span>
|
||||
<span className="font-medium">{formatTokens(metrics.perSuccessfulRun.p50)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs">p95: </span>
|
||||
<span className="font-medium">{formatTokens(metrics.perSuccessfulRun.p95)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Per-Agent Breakdown */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-muted-foreground mb-3 flex items-center gap-2">
|
||||
<Bot className="h-4 w-4" />
|
||||
Breakdown by Agent
|
||||
</h4>
|
||||
|
||||
{metrics.byAgent.length === 0 ? (
|
||||
<div className="text-center py-4 text-muted-foreground">
|
||||
No agent data available
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{metrics.byAgent.map((agent, index) => {
|
||||
const percentage = metrics.totalTokens > 0
|
||||
? (agent.totalTokens / metrics.totalTokens) * 100
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<AgentTokenRow
|
||||
key={agent.agent}
|
||||
agent={agent}
|
||||
percentage={percentage}
|
||||
isTop={index === 0}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AgentTokenRowProps {
|
||||
agent: {
|
||||
agent: string;
|
||||
totalTokens: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
runs: number;
|
||||
};
|
||||
percentage: number;
|
||||
isTop: boolean;
|
||||
}
|
||||
|
||||
function AgentTokenRow({ agent, percentage, isTop }: AgentTokenRowProps) {
|
||||
return (
|
||||
<div className={cn(
|
||||
'rounded-lg border p-3',
|
||||
isTop && 'border-primary/30 bg-primary/5'
|
||||
)}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">{agent.agent}</span>
|
||||
{isTop && (
|
||||
<Badge variant="secondary" className="text-xs flex items-center gap-1">
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
Top Consumer
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{agent.runs} runs
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="h-2 bg-muted rounded-full overflow-hidden mb-2">
|
||||
<div
|
||||
className="h-full bg-primary transition-all"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between text-sm">
|
||||
<div className="flex gap-4">
|
||||
<span>
|
||||
<span className="text-muted-foreground">Total: </span>
|
||||
<span className="font-medium">{formatTokens(agent.totalTokens)}</span>
|
||||
</span>
|
||||
<span>
|
||||
<span className="text-muted-foreground">In: </span>
|
||||
<span className="text-blue-500">{formatTokens(agent.inputTokens)}</span>
|
||||
</span>
|
||||
<span>
|
||||
<span className="text-muted-foreground">Out: </span>
|
||||
<span className="text-green-500">{formatTokens(agent.outputTokens)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-muted-foreground">
|
||||
{percentage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -81,6 +81,12 @@ export function BoardTab() {
|
|||
checked={settings.board.enableDragAndDrop}
|
||||
onCheckedChange={(v) => update('enableDragAndDrop', v)}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="Done Column Metrics"
|
||||
description="Show agent run count, success status, and duration on completed tasks"
|
||||
checked={settings.board.showDoneMetrics}
|
||||
onCheckedChange={(v) => update('showDoneMetrics', v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { useTemplateForm } from '@/hooks/useTemplateForm';
|
|||
import { useCreateTaskForm } from '@/hooks/useCreateTaskForm';
|
||||
import { BlueprintPreview } from './create/BlueprintPreview';
|
||||
import { TemplateVariableInputs } from './create/TemplateVariableInputs';
|
||||
import type { TaskPriority } from '@veritas-kanban/shared';
|
||||
import { FileText, X, Check, HelpCircle, Info } from 'lucide-react';
|
||||
import { getCategoryIcon } from '@/lib/template-categories';
|
||||
|
||||
|
|
@ -110,6 +111,9 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps)
|
|||
});
|
||||
};
|
||||
|
||||
const currentTemplate = selectedTemplate ? templates?.find(t => t.id === selectedTemplate) : null;
|
||||
const isBlueprint = Boolean(currentTemplate?.blueprint && currentTemplate.blueprint.length > 0);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
|
|
@ -126,9 +130,6 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps)
|
|||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const currentTemplate = selectedTemplate ? templates?.find(t => t.id === selectedTemplate) : null;
|
||||
const isBlueprint = currentTemplate?.blueprint && currentTemplate.blueprint.length > 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
|
|
@ -321,11 +322,9 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps)
|
|||
if (e.key === 'Enter' && newProjectName.trim()) {
|
||||
e.preventDefault();
|
||||
setProject(newProjectName.trim());
|
||||
setShowNewProject(false);
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setShowNewProject(false);
|
||||
setNewProjectName('');
|
||||
hideNewProject();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
|
@ -335,7 +334,6 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps)
|
|||
onClick={() => {
|
||||
if (newProjectName.trim()) {
|
||||
setProject(newProjectName.trim());
|
||||
setShowNewProject(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
|
@ -345,10 +343,7 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps)
|
|||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowNewProject(false);
|
||||
setNewProjectName('');
|
||||
}}
|
||||
onClick={hideNewProject}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
|
@ -415,7 +410,7 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps)
|
|||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={(isBlueprint ? false : !title.trim()) || isCreating}
|
||||
disabled={!canSubmit(isBlueprint) || isCreating}
|
||||
>
|
||||
{isCreating ? 'Creating...' : isBlueprint ? 'Create Tasks' : 'Create Task'}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ const priorityColors: Record<TaskPriority, string> = {
|
|||
low: 'bg-slate-500/20 text-slate-400',
|
||||
};
|
||||
|
||||
export const TaskCard = memo(function TaskCard({ task, isDragging, onClick, isSelected, isBlocked, blockerTitles }: TaskCardProps) {
|
||||
export const TaskCard = memo(function TaskCard({ task, isDragging, onClick, isSelected, isBlocked, blockerTitles, cardMetrics }: TaskCardProps) {
|
||||
const { taskTypes, projects, sprints } = useTaskConfig();
|
||||
const {
|
||||
attributes,
|
||||
|
|
@ -283,7 +283,7 @@ export const TaskCard = memo(function TaskCard({ task, isDragging, onClick, isSe
|
|||
{(task.timeTracking?.totalSeconds || task.timeTracking?.isRunning) && (
|
||||
<span className={cn(
|
||||
"text-xs px-1.5 py-0.5 rounded flex items-center gap-1",
|
||||
!subtaskTotal && "ml-auto",
|
||||
!subtaskTotal && !cardMetrics && "ml-auto",
|
||||
task.timeTracking?.isRunning
|
||||
? "bg-green-500/20 text-green-500"
|
||||
: "bg-muted text-muted-foreground"
|
||||
|
|
@ -296,6 +296,66 @@ export const TaskCard = memo(function TaskCard({ task, isDragging, onClick, isSe
|
|||
{formatDuration(task.timeTracking?.totalSeconds || 0)}
|
||||
</span>
|
||||
)}
|
||||
{/* Agent run metrics (for done tasks only) */}
|
||||
{cardMetrics && cardMetrics.totalRuns > 0 && (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={cn(
|
||||
"text-xs px-1.5 py-0.5 rounded flex items-center gap-1",
|
||||
!subtaskTotal && !task.timeTracking?.totalSeconds && "ml-auto",
|
||||
"bg-muted text-muted-foreground"
|
||||
)}>
|
||||
<Play className="h-3 w-3" />
|
||||
{cardMetrics.totalRuns}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="font-medium">{cardMetrics.totalRuns} run{cardMetrics.totalRuns !== 1 ? 's' : ''}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{cardMetrics.successfulRuns} successful, {cardMetrics.failedRuns} failed
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{cardMetrics.lastRunSuccess !== undefined && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={cn(
|
||||
"text-xs px-1 py-0.5 rounded flex items-center",
|
||||
cardMetrics.lastRunSuccess
|
||||
? "bg-green-500/20 text-green-500"
|
||||
: "bg-red-500/20 text-red-500"
|
||||
)}>
|
||||
{cardMetrics.lastRunSuccess ? (
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
) : (
|
||||
<XCircle className="h-3 w-3" />
|
||||
)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="font-medium">Last run: {cardMetrics.lastRunSuccess ? 'Success' : 'Failed'}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{cardMetrics.totalDurationMs > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-xs px-1.5 py-0.5 rounded flex items-center gap-1 bg-muted text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatCompactDuration(cardMetrics.totalDurationMs)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="font-medium">Total agent time</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatCompactDuration(cardMetrics.totalDurationMs)} across {cardMetrics.totalRuns} run{cardMetrics.totalRuns !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ export * from './useSortableList';
|
|||
export * from './useAttachments';
|
||||
export * from './useBulkActions';
|
||||
export * from './useConfig';
|
||||
export * from './useCreateTaskForm';
|
||||
export * from './usePolling';
|
||||
export * from './useConflicts';
|
||||
export * from './useDebouncedSave';
|
||||
export * from './useDiff';
|
||||
|
|
|
|||
|
|
@ -47,7 +47,8 @@ function aggregateTaskMetrics(events: AnyTelemetryEvent[]): TaskCardMetrics {
|
|||
attemptKey = e.attemptId || `${e.timestamp}_${e.agent}`;
|
||||
// Try to find matching started event
|
||||
let found = false;
|
||||
for (const [, attempt] of attemptMap.entries()) {
|
||||
const entries = Array.from(attemptMap.entries());
|
||||
for (const [, attempt] of entries) {
|
||||
if (attempt.started?.agent === e.agent && !attempt.completed) {
|
||||
attempt.completed = e;
|
||||
found = true;
|
||||
|
|
@ -61,7 +62,8 @@ function aggregateTaskMetrics(events: AnyTelemetryEvent[]): TaskCardMetrics {
|
|||
}
|
||||
} else if (event.type === 'run.error') {
|
||||
const e = event as RunErrorEvent;
|
||||
for (const [, attempt] of attemptMap.entries()) {
|
||||
const entries = Array.from(attemptMap.entries());
|
||||
for (const [, attempt] of entries) {
|
||||
if (attempt.started?.agent === e.agent && !attempt.error) {
|
||||
attempt.error = e;
|
||||
break;
|
||||
|
|
@ -78,7 +80,8 @@ function aggregateTaskMetrics(events: AnyTelemetryEvent[]): TaskCardMetrics {
|
|||
let lastRunSuccess: boolean | undefined;
|
||||
let latestTimestamp = '';
|
||||
|
||||
for (const [, data] of attemptMap.entries()) {
|
||||
const allEntries = Array.from(attemptMap.entries());
|
||||
for (const [, data] of allEntries) {
|
||||
if (data.completed?.success !== undefined || data.error) {
|
||||
totalRuns++;
|
||||
const success = data.completed?.success === true && !data.error;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useReducer, useCallback, useMemo } from 'react';
|
||||
import type { TaskPriority, TaskType } from '@veritas-kanban/shared';
|
||||
import type { TaskPriority } from '@veritas-kanban/shared';
|
||||
|
||||
// ====== State Types ======
|
||||
|
||||
|
|
|
|||
|
|
@ -105,3 +105,144 @@ export function formatDuration(ms: number): string {
|
|||
export function formatPercent(rate: number): string {
|
||||
return `${(rate * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
// Types for detailed metrics
|
||||
export interface AgentBreakdown {
|
||||
agent: string;
|
||||
runs: number;
|
||||
successes: number;
|
||||
failures: number;
|
||||
errors: number;
|
||||
successRate: number;
|
||||
avgDurationMs: number;
|
||||
totalTokens: number;
|
||||
}
|
||||
|
||||
export interface TokenAgentBreakdown {
|
||||
agent: string;
|
||||
totalTokens: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
runs: number;
|
||||
}
|
||||
|
||||
export interface DurationAgentBreakdown {
|
||||
agent: string;
|
||||
runs: number;
|
||||
avgMs: number;
|
||||
p50Ms: number;
|
||||
p95Ms: number;
|
||||
}
|
||||
|
||||
export interface FailedRunDetails {
|
||||
timestamp: string;
|
||||
taskId?: string;
|
||||
taskTitle?: string;
|
||||
project?: string;
|
||||
agent: string;
|
||||
success: boolean;
|
||||
errorMessage?: string;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
// Detailed metrics with agent breakdowns
|
||||
export interface DetailedRunMetrics extends RunMetrics {
|
||||
byAgent: AgentBreakdown[];
|
||||
}
|
||||
|
||||
export interface DetailedTokenMetrics extends TokenMetrics {
|
||||
byAgent: TokenAgentBreakdown[];
|
||||
}
|
||||
|
||||
export interface DetailedDurationMetrics extends DurationMetrics {
|
||||
byAgent: DurationAgentBreakdown[];
|
||||
}
|
||||
|
||||
async function fetchFailedRuns(period: MetricsPeriod, project?: string, limit = 50): Promise<FailedRunDetails[]> {
|
||||
const params = new URLSearchParams();
|
||||
params.set('period', period);
|
||||
if (project) {
|
||||
params.set('project', project);
|
||||
}
|
||||
params.set('limit', String(limit));
|
||||
|
||||
const response = await fetch(`${API_BASE}/metrics/failed-runs?${params}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch failed runs');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function fetchRunMetrics(period: MetricsPeriod, project?: string): Promise<DetailedRunMetrics> {
|
||||
const params = new URLSearchParams();
|
||||
params.set('period', period);
|
||||
if (project) {
|
||||
params.set('project', project);
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/metrics/runs?${params}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch run metrics');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function fetchTokenMetrics(period: MetricsPeriod, project?: string): Promise<DetailedTokenMetrics> {
|
||||
const params = new URLSearchParams();
|
||||
params.set('period', period);
|
||||
if (project) {
|
||||
params.set('project', project);
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/metrics/tokens?${params}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch token metrics');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function fetchDurationMetrics(period: MetricsPeriod, project?: string): Promise<DetailedDurationMetrics> {
|
||||
const params = new URLSearchParams();
|
||||
params.set('period', period);
|
||||
if (project) {
|
||||
params.set('project', project);
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/metrics/duration?${params}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch duration metrics');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export function useFailedRuns(period: MetricsPeriod = '24h', project?: string, limit = 50) {
|
||||
return useQuery({
|
||||
queryKey: ['failed-runs', period, project, limit],
|
||||
queryFn: () => fetchFailedRuns(period, project, limit),
|
||||
staleTime: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRunMetrics(period: MetricsPeriod = '24h', project?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['run-metrics', period, project],
|
||||
queryFn: () => fetchRunMetrics(period, project),
|
||||
staleTime: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTokenMetrics(period: MetricsPeriod = '24h', project?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['token-metrics', period, project],
|
||||
queryFn: () => fetchTokenMetrics(period, project),
|
||||
staleTime: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDurationMetrics(period: MetricsPeriod = '24h', project?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['duration-metrics', period, project],
|
||||
queryFn: () => fetchDurationMetrics(period, project),
|
||||
staleTime: 30000,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,22 +91,30 @@ export function useUpdateTask() {
|
|||
const previousTasks = queryClient.getQueryData<Task[]>(['tasks']);
|
||||
const previousTask = queryClient.getQueryData<Task>(['tasks', id]);
|
||||
|
||||
// Optimistically update the task in the list
|
||||
// Optimistically update the task in the list (merge only defined fields)
|
||||
queryClient.setQueryData<Task[]>(['tasks'], (old) =>
|
||||
old?.map(task =>
|
||||
task.id === id
|
||||
? { ...task, ...input, updated: new Date().toISOString() }
|
||||
: task
|
||||
)
|
||||
old?.map(task => {
|
||||
if (task.id !== id) return task;
|
||||
// Only apply defined fields from input
|
||||
const updates: Partial<Task> = { updated: new Date().toISOString() };
|
||||
Object.entries(input).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
(updates as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
});
|
||||
return { ...task, ...updates } as Task;
|
||||
})
|
||||
);
|
||||
|
||||
// Also update the individual task query
|
||||
if (previousTask) {
|
||||
queryClient.setQueryData<Task>(['tasks', id], {
|
||||
...previousTask,
|
||||
...input,
|
||||
updated: new Date().toISOString(),
|
||||
const updates: Partial<Task> = { updated: new Date().toISOString() };
|
||||
Object.entries(input).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
(updates as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
});
|
||||
queryClient.setQueryData<Task>(['tasks', id], { ...previousTask, ...updates } as Task);
|
||||
}
|
||||
|
||||
return { previousTasks, previousTask };
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue