@echothink-ui/task 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/dist/components/BackendThinkingChain.d.ts +2 -0
- package/dist/components/BlockingReasonPanel.d.ts +2 -0
- package/dist/components/DAGEdge.d.ts +2 -0
- package/dist/components/DAGLegend.d.ts +2 -0
- package/dist/components/DAGNode.d.ts +4 -0
- package/dist/components/DecisionRequiredPanel.d.ts +2 -0
- package/dist/components/HumanInterventionPanel.d.ts +2 -0
- package/dist/components/MobileTaskShell.d.ts +12 -0
- package/dist/components/TaskApprovalPanel.d.ts +2 -0
- package/dist/components/TaskCard.d.ts +2 -0
- package/dist/components/TaskDependencyList.d.ts +2 -0
- package/dist/components/TaskDetailPanel.d.ts +2 -0
- package/dist/components/TaskHandoffPanel.d.ts +2 -0
- package/dist/components/TaskProgressIndicator.d.ts +2 -0
- package/dist/components/TaskRetryPanel.d.ts +2 -0
- package/dist/components/TaskRunLog.d.ts +2 -0
- package/dist/components/TaskStatusBadge.d.ts +2 -0
- package/dist/components/TaskTable.d.ts +5 -0
- package/dist/components/TaskTimeline.d.ts +2 -0
- package/dist/components/TaskWaveDAG.d.ts +2 -0
- package/dist/components/TaskWaveHeader.d.ts +2 -0
- package/dist/components/TaskWaveTable.d.ts +2 -0
- package/dist/components/utils.d.ts +13 -0
- package/dist/index.cjs +2434 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.css +2402 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +2388 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +249 -0
- package/package.json +45 -0
- package/src/components/BackendThinkingChain.tsx +129 -0
- package/src/components/BlockingReasonPanel.tsx +67 -0
- package/src/components/DAGEdge.tsx +97 -0
- package/src/components/DAGLegend.tsx +86 -0
- package/src/components/DAGNode.tsx +103 -0
- package/src/components/DecisionRequiredPanel.tsx +166 -0
- package/src/components/HumanInterventionPanel.tsx +82 -0
- package/src/components/MobileTaskShell.tsx +52 -0
- package/src/components/TaskApprovalPanel.tsx +159 -0
- package/src/components/TaskCard.tsx +71 -0
- package/src/components/TaskDependencyList.test.tsx +54 -0
- package/src/components/TaskDependencyList.tsx +105 -0
- package/src/components/TaskDetailPanel.test.tsx +49 -0
- package/src/components/TaskDetailPanel.tsx +139 -0
- package/src/components/TaskHandoffPanel.tsx +125 -0
- package/src/components/TaskProgressIndicator.tsx +70 -0
- package/src/components/TaskRetryPanel.test.tsx +29 -0
- package/src/components/TaskRetryPanel.tsx +103 -0
- package/src/components/TaskRunLog.tsx +156 -0
- package/src/components/TaskStatusBadge.tsx +29 -0
- package/src/components/TaskTable.tsx +294 -0
- package/src/components/TaskTimeline.tsx +98 -0
- package/src/components/TaskWaveDAG.tsx +202 -0
- package/src/components/TaskWaveHeader.tsx +82 -0
- package/src/components/TaskWaveTable.tsx +151 -0
- package/src/components/css.d.ts +1 -0
- package/src/components/utils.ts +116 -0
- package/src/index.test.tsx +316 -0
- package/src/index.tsx +90 -0
- package/src/styles.css +2889 -0
- package/src/types.ts +289 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import clsx from "clsx";
|
|
3
|
+
import { ActionGroup, Badge, EmptyState, Surface } from "@echothink-ui/core";
|
|
4
|
+
import type { TaskDetailPanelProps } from "../types";
|
|
5
|
+
import { TaskDependencyList } from "./TaskDependencyList";
|
|
6
|
+
import { TaskTimeline } from "./TaskTimeline";
|
|
7
|
+
import { TaskStatusBadge } from "./TaskStatusBadge";
|
|
8
|
+
import { formatDateTime, severityForPriority } from "./utils";
|
|
9
|
+
|
|
10
|
+
function formatCount(count: number, singular: string, plural = `${singular}s`) {
|
|
11
|
+
return `${count} ${count === 1 ? singular : plural}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function DetailSection({
|
|
15
|
+
title,
|
|
16
|
+
meta,
|
|
17
|
+
open,
|
|
18
|
+
children,
|
|
19
|
+
className
|
|
20
|
+
}: {
|
|
21
|
+
title: string;
|
|
22
|
+
meta?: React.ReactNode;
|
|
23
|
+
open?: boolean;
|
|
24
|
+
children: React.ReactNode;
|
|
25
|
+
className?: string;
|
|
26
|
+
}) {
|
|
27
|
+
return (
|
|
28
|
+
<details open={open} className={clsx("eth-task-detail-panel__section", className)}>
|
|
29
|
+
<summary className="eth-task-detail-panel__summary">
|
|
30
|
+
<span className="eth-task-detail-panel__summary-label">{title}</span>
|
|
31
|
+
{meta ? <span className="eth-task-detail-panel__summary-meta">{meta}</span> : null}
|
|
32
|
+
</summary>
|
|
33
|
+
<div className="eth-task-detail-panel__section-body">{children}</div>
|
|
34
|
+
</details>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function TaskDetailPanel({
|
|
39
|
+
task,
|
|
40
|
+
dependencies,
|
|
41
|
+
events,
|
|
42
|
+
actions,
|
|
43
|
+
className,
|
|
44
|
+
title,
|
|
45
|
+
description,
|
|
46
|
+
items,
|
|
47
|
+
...props
|
|
48
|
+
}: TaskDetailPanelProps) {
|
|
49
|
+
if (!task) {
|
|
50
|
+
return (
|
|
51
|
+
<Surface
|
|
52
|
+
{...props}
|
|
53
|
+
className={clsx("eth-task-detail-panel", className)}
|
|
54
|
+
title={title ?? "Task detail"}
|
|
55
|
+
description={description}
|
|
56
|
+
items={items}
|
|
57
|
+
actions={actions}
|
|
58
|
+
data-eth-component="TaskDetailPanel"
|
|
59
|
+
>
|
|
60
|
+
{!items?.length ? <EmptyState title="No task selected" /> : null}
|
|
61
|
+
</Surface>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const resolvedDependencies = dependencies ?? task.dependencies ?? [];
|
|
66
|
+
const resolvedEvents = events ?? task.history ?? [];
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<Surface
|
|
70
|
+
{...props}
|
|
71
|
+
className={clsx("eth-task-detail-panel", className)}
|
|
72
|
+
title={title ?? task.title}
|
|
73
|
+
subtitle={task.id}
|
|
74
|
+
status={task.status}
|
|
75
|
+
data-eth-component="TaskDetailPanel"
|
|
76
|
+
>
|
|
77
|
+
<div className="eth-task-detail-panel__stack">
|
|
78
|
+
<DetailSection title="Metadata" open>
|
|
79
|
+
<dl className="eth-meta-grid eth-task-detail-panel__metadata">
|
|
80
|
+
<div>
|
|
81
|
+
<dt>Status</dt>
|
|
82
|
+
<dd>
|
|
83
|
+
<TaskStatusBadge status={task.status} />
|
|
84
|
+
</dd>
|
|
85
|
+
</div>
|
|
86
|
+
<div>
|
|
87
|
+
<dt>Assignee</dt>
|
|
88
|
+
<dd>{task.assignee ?? "-"}</dd>
|
|
89
|
+
</div>
|
|
90
|
+
<div>
|
|
91
|
+
<dt>Due</dt>
|
|
92
|
+
<dd>{formatDateTime(task.dueAt)}</dd>
|
|
93
|
+
</div>
|
|
94
|
+
<div>
|
|
95
|
+
<dt>Priority</dt>
|
|
96
|
+
<dd>
|
|
97
|
+
{task.priority ? (
|
|
98
|
+
<Badge severity={severityForPriority(task.priority)}>{task.priority}</Badge>
|
|
99
|
+
) : (
|
|
100
|
+
"-"
|
|
101
|
+
)}
|
|
102
|
+
</dd>
|
|
103
|
+
</div>
|
|
104
|
+
</dl>
|
|
105
|
+
</DetailSection>
|
|
106
|
+
|
|
107
|
+
<DetailSection title="Description" open>
|
|
108
|
+
<div className="eth-task-detail-panel__description">
|
|
109
|
+
{task.description ?? description ?? "No description provided."}
|
|
110
|
+
</div>
|
|
111
|
+
</DetailSection>
|
|
112
|
+
|
|
113
|
+
<DetailSection
|
|
114
|
+
title="Dependencies"
|
|
115
|
+
meta={formatCount(resolvedDependencies.length, "dependency", "dependencies")}
|
|
116
|
+
open={resolvedDependencies.length > 0}
|
|
117
|
+
>
|
|
118
|
+
<TaskDependencyList dependencies={resolvedDependencies} />
|
|
119
|
+
</DetailSection>
|
|
120
|
+
|
|
121
|
+
<DetailSection
|
|
122
|
+
title="History"
|
|
123
|
+
meta={formatCount(resolvedEvents.length, "event")}
|
|
124
|
+
open={resolvedEvents.length > 0}
|
|
125
|
+
>
|
|
126
|
+
<TaskTimeline events={resolvedEvents} />
|
|
127
|
+
</DetailSection>
|
|
128
|
+
|
|
129
|
+
{actions?.length ? (
|
|
130
|
+
<DetailSection title="Actions" meta={formatCount(actions.length, "action")} open>
|
|
131
|
+
<div className="eth-task-detail-panel__actions">
|
|
132
|
+
<ActionGroup actions={actions} />
|
|
133
|
+
</div>
|
|
134
|
+
</DetailSection>
|
|
135
|
+
) : null}
|
|
136
|
+
</div>
|
|
137
|
+
</Surface>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import clsx from "clsx";
|
|
3
|
+
import {
|
|
4
|
+
Badge,
|
|
5
|
+
Button,
|
|
6
|
+
EmptyState,
|
|
7
|
+
FormField,
|
|
8
|
+
Surface,
|
|
9
|
+
Textarea
|
|
10
|
+
} from "@echothink-ui/core";
|
|
11
|
+
import type { TaskHandoffPanelProps } from "../types";
|
|
12
|
+
|
|
13
|
+
type TaskHandoffAssigneeKind =
|
|
14
|
+
NonNullable<TaskHandoffPanelProps["assigneeOptions"]>[number]["kind"];
|
|
15
|
+
|
|
16
|
+
export function TaskHandoffPanel({
|
|
17
|
+
assigneeOptions = [],
|
|
18
|
+
currentAssigneeId,
|
|
19
|
+
onHandoff,
|
|
20
|
+
reasonRequired,
|
|
21
|
+
className,
|
|
22
|
+
title = "Task handoff",
|
|
23
|
+
description,
|
|
24
|
+
...props
|
|
25
|
+
}: TaskHandoffPanelProps) {
|
|
26
|
+
const [selectedId, setSelectedId] = React.useState(
|
|
27
|
+
currentAssigneeId ?? assigneeOptions[0]?.id ?? ""
|
|
28
|
+
);
|
|
29
|
+
const [reason, setReason] = React.useState("");
|
|
30
|
+
const targetId = React.useId();
|
|
31
|
+
const reasonId = React.useId();
|
|
32
|
+
const trimmedReason = reason.trim();
|
|
33
|
+
const cannotSubmit = !selectedId || (reasonRequired && !trimmedReason);
|
|
34
|
+
|
|
35
|
+
const submitHandoff = (event?: React.FormEvent) => {
|
|
36
|
+
event?.preventDefault();
|
|
37
|
+
if (cannotSubmit) return;
|
|
38
|
+
onHandoff?.(selectedId, trimmedReason);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const handleReasonKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
42
|
+
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
|
|
43
|
+
event.preventDefault();
|
|
44
|
+
submitHandoff();
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<Surface
|
|
50
|
+
{...props}
|
|
51
|
+
className={clsx("eth-task-handoff-panel", className)}
|
|
52
|
+
title={title}
|
|
53
|
+
description={description}
|
|
54
|
+
data-eth-component="TaskHandoffPanel"
|
|
55
|
+
>
|
|
56
|
+
{assigneeOptions.length ? (
|
|
57
|
+
<form className="eth-task-handoff-panel__body" onSubmit={submitHandoff}>
|
|
58
|
+
<div className="eth-task-handoff-panel__target-field">
|
|
59
|
+
<p id={targetId} className="eth-task-handoff-panel__target-heading">
|
|
60
|
+
Handoff target
|
|
61
|
+
</p>
|
|
62
|
+
<div
|
|
63
|
+
className="eth-task-handoff-panel__list"
|
|
64
|
+
role="group"
|
|
65
|
+
aria-labelledby={targetId}
|
|
66
|
+
>
|
|
67
|
+
{assigneeOptions.map((option) => (
|
|
68
|
+
<button
|
|
69
|
+
key={option.id}
|
|
70
|
+
type="button"
|
|
71
|
+
aria-label={`Hand off to ${option.label} (${formatAssigneeKind(
|
|
72
|
+
option.kind
|
|
73
|
+
)})`}
|
|
74
|
+
aria-pressed={selectedId === option.id}
|
|
75
|
+
className={clsx(
|
|
76
|
+
"eth-task-handoff-panel__option",
|
|
77
|
+
selectedId === option.id && "eth-task-handoff-panel__option--selected"
|
|
78
|
+
)}
|
|
79
|
+
onClick={() => setSelectedId(option.id)}
|
|
80
|
+
>
|
|
81
|
+
<span className="eth-task-handoff-panel__option-copy">
|
|
82
|
+
<span className="eth-task-handoff-panel__option-label">{option.label}</span>
|
|
83
|
+
</span>
|
|
84
|
+
<span className="eth-task-handoff-panel__option-meta">
|
|
85
|
+
{selectedId === option.id ? <Badge severity="info">Selected</Badge> : null}
|
|
86
|
+
<Badge severity="neutral">{formatAssigneeKind(option.kind)}</Badge>
|
|
87
|
+
</span>
|
|
88
|
+
</button>
|
|
89
|
+
))}
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
<FormField
|
|
93
|
+
id={reasonId}
|
|
94
|
+
label="Handoff reason"
|
|
95
|
+
required={reasonRequired}
|
|
96
|
+
className="eth-task-handoff-panel__reason-field"
|
|
97
|
+
helperText={reasonRequired ? "A reason is required for this handoff." : undefined}
|
|
98
|
+
>
|
|
99
|
+
<Textarea
|
|
100
|
+
className="eth-task-handoff-panel__reason"
|
|
101
|
+
name="handoff-reason"
|
|
102
|
+
autoComplete="off"
|
|
103
|
+
placeholder="Summarize context, blockers, and the next action..."
|
|
104
|
+
value={reason}
|
|
105
|
+
rows={3}
|
|
106
|
+
onKeyDown={handleReasonKeyDown}
|
|
107
|
+
onChange={(event) => setReason(event.currentTarget.value)}
|
|
108
|
+
/>
|
|
109
|
+
</FormField>
|
|
110
|
+
<div className="eth-task-handoff-panel__actions">
|
|
111
|
+
<Button type="submit" disabled={cannotSubmit}>
|
|
112
|
+
Handoff
|
|
113
|
+
</Button>
|
|
114
|
+
</div>
|
|
115
|
+
</form>
|
|
116
|
+
) : (
|
|
117
|
+
<EmptyState title="No assignees available" />
|
|
118
|
+
)}
|
|
119
|
+
</Surface>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function formatAssigneeKind(kind: TaskHandoffAssigneeKind) {
|
|
124
|
+
return kind.charAt(0).toUpperCase() + kind.slice(1);
|
|
125
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import clsx from "clsx";
|
|
3
|
+
import type { TaskProgressIndicatorProps } from "../types";
|
|
4
|
+
import { clampProgress, labelForStatus } from "./utils";
|
|
5
|
+
|
|
6
|
+
const currentStepStatuses = new Set(["active", "in-progress", "running"]);
|
|
7
|
+
|
|
8
|
+
export function TaskProgressIndicator({
|
|
9
|
+
value,
|
|
10
|
+
total = 100,
|
|
11
|
+
steps,
|
|
12
|
+
label = "Task progress",
|
|
13
|
+
className,
|
|
14
|
+
...props
|
|
15
|
+
}: TaskProgressIndicatorProps) {
|
|
16
|
+
if (steps?.length) {
|
|
17
|
+
return (
|
|
18
|
+
<div
|
|
19
|
+
{...props}
|
|
20
|
+
className={clsx("eth-task-progress eth-task-progress--steps", className)}
|
|
21
|
+
data-eth-component="TaskProgressIndicator"
|
|
22
|
+
>
|
|
23
|
+
<ol className="eth-task-progress__steps" aria-label={label}>
|
|
24
|
+
{steps.map((step, index) => {
|
|
25
|
+
const statusLabel = labelForStatus(step.status);
|
|
26
|
+
const isCurrentStep = currentStepStatuses.has(step.status);
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<li
|
|
30
|
+
key={step.id}
|
|
31
|
+
className={clsx(
|
|
32
|
+
"eth-task-progress__step",
|
|
33
|
+
`eth-task-progress__step--${step.status}`
|
|
34
|
+
)}
|
|
35
|
+
aria-current={isCurrentStep ? "step" : undefined}
|
|
36
|
+
data-status={step.status}
|
|
37
|
+
>
|
|
38
|
+
<span className="eth-task-progress__step-track" aria-hidden="true">
|
|
39
|
+
<span className="eth-task-progress__circle" />
|
|
40
|
+
{index < steps.length - 1 ? (
|
|
41
|
+
<span className="eth-task-progress__connector" />
|
|
42
|
+
) : null}
|
|
43
|
+
</span>
|
|
44
|
+
<span className="eth-task-progress__step-copy">
|
|
45
|
+
<span className="eth-task-progress__step-label">{step.label}</span>
|
|
46
|
+
<span className="eth-task-progress__step-status">{statusLabel}</span>
|
|
47
|
+
</span>
|
|
48
|
+
</li>
|
|
49
|
+
);
|
|
50
|
+
})}
|
|
51
|
+
</ol>
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const progressValue = clampProgress(value, total);
|
|
57
|
+
return (
|
|
58
|
+
<div
|
|
59
|
+
{...props}
|
|
60
|
+
className={clsx("eth-task-progress eth-task-progress--bar", className)}
|
|
61
|
+
data-eth-component="TaskProgressIndicator"
|
|
62
|
+
>
|
|
63
|
+
<div className="eth-task-progress__label">
|
|
64
|
+
<span>{label}</span>
|
|
65
|
+
<strong>{progressValue}%</strong>
|
|
66
|
+
</div>
|
|
67
|
+
<progress value={progressValue} max={100} aria-label={label} />
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { TaskRetryPanel } from "./TaskRetryPanel";
|
|
4
|
+
|
|
5
|
+
describe("TaskRetryPanel", () => {
|
|
6
|
+
it("surfaces exhausted retry policy and submits the captured reason", () => {
|
|
7
|
+
const onRetry = vi.fn();
|
|
8
|
+
|
|
9
|
+
render(
|
|
10
|
+
<TaskRetryPanel
|
|
11
|
+
title="Retry failed task"
|
|
12
|
+
taskRef="Create landing page hero"
|
|
13
|
+
failureSummary="Upstream 503"
|
|
14
|
+
retryPolicy={{ maxRetries: 3, backoff: "exponential", intervalMs: 5000 }}
|
|
15
|
+
attemptCount={3}
|
|
16
|
+
onRetry={onRetry}
|
|
17
|
+
/>
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
expect(screen.getByText("Automatic retries exhausted")).toBeTruthy();
|
|
21
|
+
|
|
22
|
+
fireEvent.change(screen.getByLabelText("Retry reason"), {
|
|
23
|
+
target: { value: "Renderer recovered after queue drain." }
|
|
24
|
+
});
|
|
25
|
+
fireEvent.click(screen.getByRole("button", { name: "Retry task" }));
|
|
26
|
+
|
|
27
|
+
expect(onRetry).toHaveBeenCalledWith("Renderer recovered after queue drain.");
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import clsx from "clsx";
|
|
3
|
+
import { Button, FormField, InlineNotification, Surface, Textarea } from "@echothink-ui/core";
|
|
4
|
+
import { RetryIcon } from "@echothink-ui/icons";
|
|
5
|
+
import type { TaskRetryPanelProps } from "../types";
|
|
6
|
+
import { formatDuration } from "./utils";
|
|
7
|
+
|
|
8
|
+
const backoffLabels = {
|
|
9
|
+
exponential: "Exponential",
|
|
10
|
+
linear: "Linear"
|
|
11
|
+
} as const;
|
|
12
|
+
|
|
13
|
+
export function TaskRetryPanel({
|
|
14
|
+
taskRef,
|
|
15
|
+
failureSummary,
|
|
16
|
+
retryPolicy,
|
|
17
|
+
attemptCount = 0,
|
|
18
|
+
onRetry,
|
|
19
|
+
className,
|
|
20
|
+
title = "Retry failed task",
|
|
21
|
+
description,
|
|
22
|
+
...props
|
|
23
|
+
}: TaskRetryPanelProps) {
|
|
24
|
+
const [reason, setReason] = React.useState("");
|
|
25
|
+
const reasonId = React.useId();
|
|
26
|
+
const retriesExhausted = retryPolicy ? attemptCount >= retryPolicy.maxRetries : false;
|
|
27
|
+
const attemptLabel = retryPolicy ? `${attemptCount} / ${retryPolicy.maxRetries}` : attemptCount;
|
|
28
|
+
const backoffLabel = retryPolicy ? backoffLabels[retryPolicy.backoff] : "-";
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<Surface
|
|
32
|
+
{...props}
|
|
33
|
+
className={clsx("eth-task-retry-panel", className)}
|
|
34
|
+
title={title}
|
|
35
|
+
description={description}
|
|
36
|
+
severity="danger"
|
|
37
|
+
data-eth-component="TaskRetryPanel"
|
|
38
|
+
>
|
|
39
|
+
<InlineNotification severity="danger" title="Task failed">
|
|
40
|
+
{failureSummary ?? "The task did not complete successfully."}
|
|
41
|
+
</InlineNotification>
|
|
42
|
+
<dl className="eth-task-retry-panel__policy" aria-label="Retry policy">
|
|
43
|
+
<div>
|
|
44
|
+
<dt>Task</dt>
|
|
45
|
+
<dd>{taskRef ?? "-"}</dd>
|
|
46
|
+
</div>
|
|
47
|
+
<div
|
|
48
|
+
className={clsx(
|
|
49
|
+
"eth-task-retry-panel__attempts",
|
|
50
|
+
retriesExhausted && "eth-task-retry-panel__attempts--exhausted"
|
|
51
|
+
)}
|
|
52
|
+
>
|
|
53
|
+
<dt>Attempts</dt>
|
|
54
|
+
<dd>
|
|
55
|
+
<span>{attemptLabel}</span>
|
|
56
|
+
{retriesExhausted ? (
|
|
57
|
+
<span className="eth-task-retry-panel__policy-note">
|
|
58
|
+
Automatic retries exhausted
|
|
59
|
+
</span>
|
|
60
|
+
) : null}
|
|
61
|
+
</dd>
|
|
62
|
+
</div>
|
|
63
|
+
{retryPolicy ? (
|
|
64
|
+
<>
|
|
65
|
+
<div>
|
|
66
|
+
<dt>Backoff</dt>
|
|
67
|
+
<dd>{backoffLabel}</dd>
|
|
68
|
+
</div>
|
|
69
|
+
<div>
|
|
70
|
+
<dt>Interval</dt>
|
|
71
|
+
<dd>{formatDuration(retryPolicy.intervalMs)}</dd>
|
|
72
|
+
</div>
|
|
73
|
+
</>
|
|
74
|
+
) : null}
|
|
75
|
+
</dl>
|
|
76
|
+
<div className="eth-task-retry-panel__controls">
|
|
77
|
+
<FormField
|
|
78
|
+
id={reasonId}
|
|
79
|
+
label="Retry reason"
|
|
80
|
+
helperText="Stored with retry audit metadata."
|
|
81
|
+
className="eth-task-retry-panel__reason-field"
|
|
82
|
+
>
|
|
83
|
+
<Textarea
|
|
84
|
+
value={reason}
|
|
85
|
+
rows={3}
|
|
86
|
+
placeholder="Summarize what changed before retrying."
|
|
87
|
+
onChange={(event) => setReason(event.currentTarget.value)}
|
|
88
|
+
/>
|
|
89
|
+
</FormField>
|
|
90
|
+
<div className="eth-task-retry-panel__actions">
|
|
91
|
+
<Button
|
|
92
|
+
icon={<RetryIcon size={16} />}
|
|
93
|
+
intent="primary"
|
|
94
|
+
onClick={() => onRetry?.(reason)}
|
|
95
|
+
>
|
|
96
|
+
Retry task
|
|
97
|
+
</Button>
|
|
98
|
+
<p>Creates a new attempt and records this reason in the task audit trail.</p>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
</Surface>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import clsx from "clsx";
|
|
3
|
+
import { Badge, Checkbox, EmptyState, IconButton, Surface } from "@echothink-ui/core";
|
|
4
|
+
import { PauseIcon, PlayIcon } from "@echothink-ui/icons";
|
|
5
|
+
import type { TaskRunLogEntry, TaskRunLogProps } from "../types";
|
|
6
|
+
import { formatDateTime } from "./utils";
|
|
7
|
+
|
|
8
|
+
const logLevels: TaskRunLogEntry["level"][] = ["error", "warn", "info", "debug"];
|
|
9
|
+
|
|
10
|
+
export function TaskRunLog({
|
|
11
|
+
entries = [],
|
|
12
|
+
streaming,
|
|
13
|
+
onPauseToggle,
|
|
14
|
+
onFilter,
|
|
15
|
+
levelFilter,
|
|
16
|
+
windowSize = 200,
|
|
17
|
+
className,
|
|
18
|
+
title = "Task run log",
|
|
19
|
+
description,
|
|
20
|
+
...props
|
|
21
|
+
}: TaskRunLogProps) {
|
|
22
|
+
const [internalFilter, setInternalFilter] = React.useState<TaskRunLogEntry["level"][]>(logLevels);
|
|
23
|
+
const activeFilter = (levelFilter as TaskRunLogEntry["level"][] | undefined) ?? internalFilter;
|
|
24
|
+
const counts = React.useMemo(
|
|
25
|
+
() =>
|
|
26
|
+
logLevels.reduce(
|
|
27
|
+
(summary, level) => ({
|
|
28
|
+
...summary,
|
|
29
|
+
[level]: entries.filter((entry) => entry.level === level).length
|
|
30
|
+
}),
|
|
31
|
+
{} as Record<TaskRunLogEntry["level"], number>
|
|
32
|
+
),
|
|
33
|
+
[entries]
|
|
34
|
+
);
|
|
35
|
+
const visibleEntries = React.useMemo(
|
|
36
|
+
() =>
|
|
37
|
+
entries
|
|
38
|
+
.filter((entry) => activeFilter.includes(entry.level))
|
|
39
|
+
.slice(-windowSize),
|
|
40
|
+
[activeFilter, entries, windowSize]
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const toggleLevel = (level: TaskRunLogEntry["level"]) => {
|
|
44
|
+
onFilter?.(level);
|
|
45
|
+
if (!levelFilter) {
|
|
46
|
+
setInternalFilter((current) =>
|
|
47
|
+
current.includes(level)
|
|
48
|
+
? current.filter((candidate) => candidate !== level)
|
|
49
|
+
: [...current, level]
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
const streamLabel = streaming ? "Streaming" : "Paused";
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<Surface
|
|
57
|
+
{...props}
|
|
58
|
+
className={clsx("eth-task-run-log", className)}
|
|
59
|
+
title={title}
|
|
60
|
+
description={description}
|
|
61
|
+
data-eth-component="TaskRunLog"
|
|
62
|
+
>
|
|
63
|
+
<div className="eth-task-run-log__toolbar" role="toolbar" aria-label="Task log controls">
|
|
64
|
+
<div className="eth-task-run-log__stream-state" aria-live="polite">
|
|
65
|
+
{onPauseToggle ? (
|
|
66
|
+
<IconButton
|
|
67
|
+
label={streaming ? "Pause log stream" : "Resume log stream"}
|
|
68
|
+
icon={streaming ? <PauseIcon size={16} /> : <PlayIcon size={16} />}
|
|
69
|
+
intent="ghost"
|
|
70
|
+
density="compact"
|
|
71
|
+
onClick={onPauseToggle}
|
|
72
|
+
/>
|
|
73
|
+
) : null}
|
|
74
|
+
<Badge severity={streaming ? "success" : "neutral"}>{streamLabel}</Badge>
|
|
75
|
+
<span className="eth-task-run-log__buffer-count">
|
|
76
|
+
{visibleEntries.length} of {entries.length} entries
|
|
77
|
+
</span>
|
|
78
|
+
</div>
|
|
79
|
+
<fieldset className="eth-task-run-log__filters">
|
|
80
|
+
<legend>Levels</legend>
|
|
81
|
+
<div className="eth-task-run-log__filter-options">
|
|
82
|
+
{logLevels.map((level) => (
|
|
83
|
+
<Checkbox
|
|
84
|
+
key={level}
|
|
85
|
+
label={level}
|
|
86
|
+
checked={activeFilter.includes(level)}
|
|
87
|
+
onChange={() => toggleLevel(level)}
|
|
88
|
+
/>
|
|
89
|
+
))}
|
|
90
|
+
</div>
|
|
91
|
+
</fieldset>
|
|
92
|
+
</div>
|
|
93
|
+
<dl className="eth-task-run-log__summary" aria-label="Task log summary">
|
|
94
|
+
<div>
|
|
95
|
+
<dt>Buffered</dt>
|
|
96
|
+
<dd>{entries.length}</dd>
|
|
97
|
+
</div>
|
|
98
|
+
<div>
|
|
99
|
+
<dt>Visible</dt>
|
|
100
|
+
<dd>{visibleEntries.length}</dd>
|
|
101
|
+
</div>
|
|
102
|
+
<div>
|
|
103
|
+
<dt>Warnings</dt>
|
|
104
|
+
<dd>{counts.warn}</dd>
|
|
105
|
+
</div>
|
|
106
|
+
<div>
|
|
107
|
+
<dt>Errors</dt>
|
|
108
|
+
<dd>{counts.error}</dd>
|
|
109
|
+
</div>
|
|
110
|
+
</dl>
|
|
111
|
+
{visibleEntries.length ? (
|
|
112
|
+
<div
|
|
113
|
+
className="eth-task-run-log__viewport"
|
|
114
|
+
role="log"
|
|
115
|
+
aria-label="Task execution log entries"
|
|
116
|
+
aria-live={streaming ? "polite" : "off"}
|
|
117
|
+
aria-relevant="additions text"
|
|
118
|
+
>
|
|
119
|
+
<div className="eth-task-run-log__columns" aria-hidden="true">
|
|
120
|
+
<span>Time</span>
|
|
121
|
+
<span>Level</span>
|
|
122
|
+
<span>Message</span>
|
|
123
|
+
</div>
|
|
124
|
+
{visibleEntries.map((entry) => (
|
|
125
|
+
<div
|
|
126
|
+
key={entry.id}
|
|
127
|
+
className={clsx("eth-task-run-log__entry", `eth-task-run-log__entry--${entry.level}`)}
|
|
128
|
+
>
|
|
129
|
+
<time className="eth-task-run-log__time" dateTime={String(entry.timestamp)}>
|
|
130
|
+
{formatDateTime(entry.timestamp)}
|
|
131
|
+
</time>
|
|
132
|
+
<span
|
|
133
|
+
className={clsx(
|
|
134
|
+
"eth-task-run-log__level",
|
|
135
|
+
`eth-task-run-log__level--${entry.level}`
|
|
136
|
+
)}
|
|
137
|
+
>
|
|
138
|
+
{entry.level.toUpperCase()}
|
|
139
|
+
</span>
|
|
140
|
+
<span
|
|
141
|
+
className={clsx(
|
|
142
|
+
"eth-task-run-log__message",
|
|
143
|
+
entry.redacted && "eth-task-run-log__message--redacted"
|
|
144
|
+
)}
|
|
145
|
+
>
|
|
146
|
+
{entry.redacted ? "[redacted]" : entry.message}
|
|
147
|
+
</span>
|
|
148
|
+
</div>
|
|
149
|
+
))}
|
|
150
|
+
</div>
|
|
151
|
+
) : (
|
|
152
|
+
<EmptyState title="No log entries" />
|
|
153
|
+
)}
|
|
154
|
+
</Surface>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import clsx from "clsx";
|
|
3
|
+
import { Badge } from "@echothink-ui/core";
|
|
4
|
+
import type { TaskStatusBadgeProps } from "../types";
|
|
5
|
+
import { labelForStatus, severityForStatus } from "./utils";
|
|
6
|
+
|
|
7
|
+
export function TaskStatusBadge({
|
|
8
|
+
status,
|
|
9
|
+
label,
|
|
10
|
+
className,
|
|
11
|
+
title,
|
|
12
|
+
...props
|
|
13
|
+
}: TaskStatusBadgeProps) {
|
|
14
|
+
const statusLabel = label ?? labelForStatus(status);
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<Badge
|
|
18
|
+
{...props}
|
|
19
|
+
className={clsx("eth-task-status-badge", `eth-task-status-badge--${status}`, className)}
|
|
20
|
+
data-eth-component="TaskStatusBadge"
|
|
21
|
+
data-status={status}
|
|
22
|
+
severity={severityForStatus(status)}
|
|
23
|
+
title={title ?? statusLabel}
|
|
24
|
+
>
|
|
25
|
+
<span className="eth-task-status-badge__indicator" aria-hidden="true" />
|
|
26
|
+
<span className="eth-task-status-badge__label">{statusLabel}</span>
|
|
27
|
+
</Badge>
|
|
28
|
+
);
|
|
29
|
+
}
|