mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-31 19:45:15 +02:00
feat(run-details-panel): enhance run details display and error handling
- Improved the layout and readability of the run details panel by restructuring sections and adding collapsible error views. - Introduced a new `RunErrorSection` component to present run-level errors more clearly, allowing users to toggle raw error details. - Updated the handling of run outputs, step results, and artifacts for better user experience. - Refactored duration calculation in `RunRow` to utilize a dedicated `formatDuration` function for consistency. - Added a new `RunStepResult` interface to improve type safety and clarity in handling step results.
This commit is contained in:
parent
cb2e33e083
commit
fade9d1b9d
5 changed files with 268 additions and 44 deletions
|
|
@ -1,8 +1,24 @@
|
||||||
"use client";
|
"use client";
|
||||||
import { AlertCircle, FileOutput, GitCommitHorizontal, Package, Settings2 } from "lucide-react";
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
ChevronDown,
|
||||||
|
FileOutput,
|
||||||
|
GitCommitHorizontal,
|
||||||
|
Package,
|
||||||
|
Settings2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
import { JsonView } from "@/components/json-view";
|
import { JsonView } from "@/components/json-view";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import type { RunStepResult } from "@/contracts/types/automation.types";
|
||||||
import { useAutomationRun } from "@/hooks/use-automation-runs";
|
import { useAutomationRun } from "@/hooks/use-automation-runs";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { RunStepResultCard } from "./run-step-result-card";
|
||||||
|
|
||||||
interface RunDetailsPanelProps {
|
interface RunDetailsPanelProps {
|
||||||
automationId: number;
|
automationId: number;
|
||||||
|
|
@ -13,10 +29,11 @@ interface RunDetailsPanelProps {
|
||||||
* Expanded view of a single run. Fetches lazily — the parent only renders
|
* Expanded view of a single run. Fetches lazily — the parent only renders
|
||||||
* this once the row is opened, so the list view stays cheap.
|
* this once the row is opened, so the list view stays cheap.
|
||||||
*
|
*
|
||||||
* We surface the four most actionable sections (error first when present,
|
* We surface the run outcome readably: a run-level error first (when
|
||||||
* then output, step results, artifacts, inputs). The full
|
* present), then per-step cards that render the agent's markdown
|
||||||
* ``definition_snapshot`` is omitted because it usually mirrors the live
|
* ``final_message`` directly, and finally the structural artifacts/inputs.
|
||||||
* definition — surfacing it would dominate the panel without informing
|
* The full ``definition_snapshot`` is omitted because it usually mirrors the
|
||||||
|
* live definition — surfacing it would dominate the panel without informing
|
||||||
* what the user is trying to learn ("did this work? what did it do?").
|
* what the user is trying to learn ("did this work? what did it do?").
|
||||||
*/
|
*/
|
||||||
export function RunDetailsPanel({ automationId, runId }: RunDetailsPanelProps) {
|
export function RunDetailsPanel({ automationId, runId }: RunDetailsPanelProps) {
|
||||||
|
|
@ -24,7 +41,7 @@ export function RunDetailsPanel({ automationId, runId }: RunDetailsPanelProps) {
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3 p-4 bg-muted/20 border-t border-border/60">
|
<div className="flex flex-col gap-3 border-t border-border/60 bg-muted/20 p-4">
|
||||||
<Skeleton className="h-3 w-32" />
|
<Skeleton className="h-3 w-32" />
|
||||||
<Skeleton className="h-24 w-full" />
|
<Skeleton className="h-24 w-full" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -33,53 +50,104 @@ export function RunDetailsPanel({ automationId, runId }: RunDetailsPanelProps) {
|
||||||
|
|
||||||
if (error || !run) {
|
if (error || !run) {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 bg-muted/20 border-t border-border/60 text-xs text-muted-foreground">
|
<div className="border-t border-border/60 bg-muted/20 p-4 text-xs text-muted-foreground">
|
||||||
Couldn't load run details{error?.message ? `: ${error.message}` : "."}
|
Couldn't load run details{error?.message ? `: ${error.message}` : "."}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasError = run.error && Object.keys(run.error).length > 0;
|
const runError = run.error && Object.keys(run.error).length > 0 ? run.error : null;
|
||||||
const hasOutput = run.output && Object.keys(run.output).length > 0;
|
const hasOutput = run.output && Object.keys(run.output).length > 0;
|
||||||
const hasInputs = Object.keys(run.inputs ?? {}).length > 0;
|
const hasInputs = Object.keys(run.inputs ?? {}).length > 0;
|
||||||
|
const steps = run.step_results as RunStepResult[];
|
||||||
|
const hasDiagnostics = run.artifacts.length > 0 || hasInputs;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 p-4 bg-muted/20 border-t border-border/60">
|
<div className="flex flex-col gap-4 border-t border-border/60 bg-muted/20 p-4">
|
||||||
{hasError && (
|
{runError ? <RunErrorSection error={runError} /> : null}
|
||||||
<Section icon={AlertCircle} label="Error" tone="destructive">
|
|
||||||
<JsonBlock value={run.error} />
|
|
||||||
</Section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasOutput && (
|
{hasOutput ? (
|
||||||
<Section icon={FileOutput} label="Output">
|
<Section icon={FileOutput} label="Output">
|
||||||
<JsonBlock value={run.output} />
|
<JsonBlock value={run.output} />
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
<Section icon={GitCommitHorizontal} label={`Step results · ${run.step_results.length}`}>
|
<Section icon={GitCommitHorizontal} label={`Step results · ${steps.length}`}>
|
||||||
{run.step_results.length === 0 ? (
|
{steps.length === 0 ? (
|
||||||
<p className="text-xs text-muted-foreground">No steps recorded.</p>
|
<p className="text-xs text-muted-foreground">No steps recorded.</p>
|
||||||
) : (
|
) : (
|
||||||
<JsonBlock value={run.step_results} />
|
<div className="flex flex-col gap-2">
|
||||||
|
{steps.map((step, index) => (
|
||||||
|
<RunStepResultCard key={step.step_id ?? index} step={step} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{run.artifacts.length > 0 && (
|
{hasDiagnostics ? <Separator className="bg-border/60" /> : null}
|
||||||
|
|
||||||
|
{run.artifacts.length > 0 ? (
|
||||||
<Section icon={Package} label={`Artifacts · ${run.artifacts.length}`}>
|
<Section icon={Package} label={`Artifacts · ${run.artifacts.length}`}>
|
||||||
<JsonBlock value={run.artifacts} />
|
<JsonBlock value={run.artifacts} />
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
{hasInputs && (
|
{hasInputs ? (
|
||||||
<Section icon={Settings2} label="Resolved inputs">
|
<Section icon={Settings2} label="Resolved inputs">
|
||||||
<JsonBlock value={run.inputs} />
|
<JsonBlock value={run.inputs} />
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run-level error: a readable destructive alert when a message is present,
|
||||||
|
* with the full structured error available behind a raw toggle.
|
||||||
|
*/
|
||||||
|
function RunErrorSection({ error }: { error: Record<string, unknown> }) {
|
||||||
|
const [rawOpen, setRawOpen] = useState(false);
|
||||||
|
const message = typeof error.message === "string" ? error.message : null;
|
||||||
|
const type = typeof error.type === "string" ? error.type : "Run failed";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section icon={AlertCircle} label="Error" tone="destructive">
|
||||||
|
{message ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle aria-hidden />
|
||||||
|
<AlertTitle>{type}</AlertTitle>
|
||||||
|
<AlertDescription className="wrap-break-word">{message}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
<Collapsible open={rawOpen} onOpenChange={setRawOpen} className="mt-2">
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-fit px-2 text-xs text-muted-foreground"
|
||||||
|
aria-expanded={rawOpen}
|
||||||
|
>
|
||||||
|
<ChevronDown
|
||||||
|
className={cn(
|
||||||
|
"transition-transform motion-reduce:transition-none",
|
||||||
|
rawOpen && "rotate-180"
|
||||||
|
)}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
{rawOpen ? "Hide raw" : "View raw"}
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<ScrollArea className="mt-2 max-h-64 rounded-md bg-muted/40 px-3 py-2">
|
||||||
|
<JsonView src={error} collapsed={1} />
|
||||||
|
</ScrollArea>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Section({
|
function Section({
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
label,
|
label,
|
||||||
|
|
@ -92,15 +160,14 @@ function Section({
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<div
|
<div
|
||||||
className={
|
className={cn(
|
||||||
tone === "destructive"
|
"flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wider",
|
||||||
? "flex items-center gap-1.5 text-[11px] font-medium text-destructive uppercase tracking-wider"
|
tone === "destructive" ? "text-destructive" : "text-muted-foreground"
|
||||||
: "flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground uppercase tracking-wider"
|
)}
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Icon className="h-3 w-3" aria-hidden />
|
<Icon className="size-3" aria-hidden />
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
|
|
@ -110,8 +177,8 @@ function Section({
|
||||||
|
|
||||||
function JsonBlock({ value }: { value: unknown }) {
|
function JsonBlock({ value }: { value: unknown }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md bg-muted/40 px-3 py-2 max-h-64 overflow-auto">
|
<ScrollArea className="max-h-64 rounded-md bg-muted/40 px-3 py-2">
|
||||||
<JsonView src={value} collapsed={1} />
|
<JsonView src={value} collapsed={1} />
|
||||||
</div>
|
</ScrollArea>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import { ChevronDown, ChevronRight, Hand } from "lucide-react";
|
import { ChevronDown, ChevronRight, Hand } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { RunSummary } from "@/contracts/types/automation.types";
|
import type { RunSummary } from "@/contracts/types/automation.types";
|
||||||
|
import { formatDuration } from "@/lib/automations/run-duration";
|
||||||
import { formatRelativeDate } from "@/lib/format-date";
|
import { formatRelativeDate } from "@/lib/format-date";
|
||||||
import { RunDetailsPanel } from "./run-details-panel";
|
import { RunDetailsPanel } from "./run-details-panel";
|
||||||
import { RunStatusBadge } from "./run-status-badge";
|
import { RunStatusBadge } from "./run-status-badge";
|
||||||
|
|
@ -18,7 +19,7 @@ interface RunRowProps {
|
||||||
*/
|
*/
|
||||||
export function RunRow({ run, automationId }: RunRowProps) {
|
export function RunRow({ run, automationId }: RunRowProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const duration = computeDuration(run.started_at, run.finished_at);
|
const duration = formatDuration(run.started_at, run.finished_at);
|
||||||
const startedLabel = run.started_at
|
const startedLabel = run.started_at
|
||||||
? formatRelativeDate(run.started_at)
|
? formatRelativeDate(run.started_at)
|
||||||
: formatRelativeDate(run.created_at);
|
: formatRelativeDate(run.created_at);
|
||||||
|
|
@ -62,14 +63,3 @@ function TriggerSource({ triggerId }: { triggerId: number | null }) {
|
||||||
}
|
}
|
||||||
return <span>via trigger #{triggerId}</span>;
|
return <span>via trigger #{triggerId}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeDuration(started: string | null | undefined, finished: string | null | undefined) {
|
|
||||||
if (!started || !finished) return null;
|
|
||||||
const ms = new Date(finished).getTime() - new Date(started).getTime();
|
|
||||||
if (!Number.isFinite(ms) || ms < 0) return null;
|
|
||||||
if (ms < 1000) return `${ms}ms`;
|
|
||||||
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
|
||||||
const minutes = Math.floor(ms / 60_000);
|
|
||||||
const seconds = Math.floor((ms % 60_000) / 1000);
|
|
||||||
return `${minutes}m ${seconds}s`;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
"use client";
|
||||||
|
import { CheckCircle2, ChevronDown, MinusCircle, XCircle } from "lucide-react";
|
||||||
|
import { memo, useState } from "react";
|
||||||
|
import { JsonView } from "@/components/json-view";
|
||||||
|
import { MarkdownViewer } from "@/components/markdown-viewer";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import type { RunStepResult } from "@/contracts/types/automation.types";
|
||||||
|
import { formatDuration } from "@/lib/automations/run-duration";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type BadgeVariant = React.ComponentProps<typeof Badge>["variant"];
|
||||||
|
|
||||||
|
const STATUS_BADGE: Record<
|
||||||
|
string,
|
||||||
|
{ label: string; variant: BadgeVariant; icon: typeof CheckCircle2 }
|
||||||
|
> = {
|
||||||
|
succeeded: { label: "Succeeded", variant: "outline", icon: CheckCircle2 },
|
||||||
|
failed: { label: "Failed", variant: "destructive", icon: XCircle },
|
||||||
|
skipped: { label: "Skipped", variant: "secondary", icon: MinusCircle },
|
||||||
|
};
|
||||||
|
|
||||||
|
function StepStatusBadge({ status }: { status: string }) {
|
||||||
|
const meta = STATUS_BADGE[status] ?? {
|
||||||
|
label: status,
|
||||||
|
variant: "outline" as const,
|
||||||
|
icon: MinusCircle,
|
||||||
|
};
|
||||||
|
const Icon = meta.icon;
|
||||||
|
return (
|
||||||
|
<Badge variant={meta.variant} className="shrink-0">
|
||||||
|
<Icon aria-hidden />
|
||||||
|
{meta.label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One step from a run's ``step_results``. Surfaces the agent's markdown
|
||||||
|
* ``final_message`` first-class (rendered, not raw), shows step errors as a
|
||||||
|
* readable alert, and keeps the full structured payload behind a "View raw"
|
||||||
|
* collapsible escape hatch.
|
||||||
|
*/
|
||||||
|
export const RunStepResultCard = memo(function RunStepResultCard({
|
||||||
|
step,
|
||||||
|
}: {
|
||||||
|
step: RunStepResult;
|
||||||
|
}) {
|
||||||
|
const [rawOpen, setRawOpen] = useState(false);
|
||||||
|
|
||||||
|
const duration = formatDuration(step.started_at, step.finished_at);
|
||||||
|
const attempts = step.attempts ?? 0;
|
||||||
|
const finalMessage =
|
||||||
|
typeof step.result?.final_message === "string" ? step.result.final_message : null;
|
||||||
|
const errorMessage = step.error?.message;
|
||||||
|
const hasMeta = Boolean(duration) || attempts > 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-border/60 shadow-none">
|
||||||
|
<CardHeader className="gap-2 space-y-0 p-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<span className="truncate font-mono text-xs font-medium">{step.action}</span>
|
||||||
|
<span className="truncate text-xs text-muted-foreground">{step.step_id}</span>
|
||||||
|
</div>
|
||||||
|
<StepStatusBadge status={step.status} />
|
||||||
|
</div>
|
||||||
|
{hasMeta ? (
|
||||||
|
<div className="flex items-center gap-3 text-[11px] text-muted-foreground tabular-nums">
|
||||||
|
{duration ? <span>{duration}</span> : null}
|
||||||
|
{attempts > 1 ? <span>{attempts} attempts</span> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="flex flex-col gap-3 p-3 pt-0">
|
||||||
|
{errorMessage ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<XCircle aria-hidden />
|
||||||
|
<AlertTitle>{step.error?.type ?? "Error"}</AlertTitle>
|
||||||
|
<AlertDescription className="wrap-break-word">{errorMessage}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{finalMessage ? (
|
||||||
|
<div className="min-w-0 wrap-break-word rounded-md border border-border/60 bg-background px-3 py-2">
|
||||||
|
<MarkdownViewer content={finalMessage} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Collapsible open={rawOpen} onOpenChange={setRawOpen}>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-fit px-2 text-xs text-muted-foreground"
|
||||||
|
aria-expanded={rawOpen}
|
||||||
|
>
|
||||||
|
<ChevronDown
|
||||||
|
className={cn(
|
||||||
|
"transition-transform motion-reduce:transition-none",
|
||||||
|
rawOpen && "rotate-180"
|
||||||
|
)}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
{rawOpen ? "Hide raw" : "View raw"}
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<ScrollArea className="mt-2 max-h-64 rounded-md bg-muted/40 px-3 py-2">
|
||||||
|
<JsonView src={step} collapsed={1} />
|
||||||
|
</ScrollArea>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -190,6 +190,31 @@ export const run = runSummary.extend({
|
||||||
});
|
});
|
||||||
export type Run = z.infer<typeof run>;
|
export type Run = z.infer<typeof run>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typed view over a single entry in {@link Run.step_results}. The Zod schema
|
||||||
|
* keeps step results as opaque records (the backend emits action-specific
|
||||||
|
* payloads), so this interface exists purely for safe field access in the UI
|
||||||
|
* and does not perform runtime validation.
|
||||||
|
*
|
||||||
|
* Mirrors `_result()` in
|
||||||
|
* `surfsense_backend/app/automations/runtime/step.py`. For the `agent_task`
|
||||||
|
* action, `result` carries the markdown `final_message` produced by the agent.
|
||||||
|
*/
|
||||||
|
export interface RunStepResult {
|
||||||
|
step_id: string;
|
||||||
|
action: string;
|
||||||
|
status: "succeeded" | "failed" | "skipped" | string;
|
||||||
|
started_at?: string;
|
||||||
|
finished_at?: string;
|
||||||
|
attempts?: number;
|
||||||
|
result?: {
|
||||||
|
final_message?: string;
|
||||||
|
agent_session_id?: string;
|
||||||
|
resumes?: unknown;
|
||||||
|
} & Record<string, unknown>;
|
||||||
|
error?: { message?: string; type?: string };
|
||||||
|
}
|
||||||
|
|
||||||
export const runListResponse = z.object({
|
export const runListResponse = z.object({
|
||||||
items: z.array(runSummary),
|
items: z.array(runSummary),
|
||||||
total: z.number(),
|
total: z.number(),
|
||||||
|
|
|
||||||
19
surfsense_web/lib/automations/run-duration.ts
Normal file
19
surfsense_web/lib/automations/run-duration.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
/**
|
||||||
|
* Format the wall-clock duration between a run/step's start and finish
|
||||||
|
* timestamps into a compact, human-readable label (e.g. `850ms`, `4.2s`,
|
||||||
|
* `1m 30s`). Returns `null` when either bound is missing or the delta is
|
||||||
|
* negative/non-finite, so callers can simply omit the label.
|
||||||
|
*/
|
||||||
|
export function formatDuration(
|
||||||
|
started: string | null | undefined,
|
||||||
|
finished: string | null | undefined
|
||||||
|
): string | null {
|
||||||
|
if (!started || !finished) return null;
|
||||||
|
const ms = new Date(finished).getTime() - new Date(started).getTime();
|
||||||
|
if (!Number.isFinite(ms) || ms < 0) return null;
|
||||||
|
if (ms < 1000) return `${ms}ms`;
|
||||||
|
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
||||||
|
const minutes = Math.floor(ms / 60_000);
|
||||||
|
const seconds = Math.floor((ms % 60_000) / 1000);
|
||||||
|
return `${minutes}m ${seconds}s`;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue