@startsimpli/ui 0.4.22 → 0.4.23

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.
@@ -0,0 +1,208 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { Pencil, Save, X, Settings2 } from 'lucide-react';
5
+ import { Button } from '../ui/button';
6
+ import { Input } from '../ui/input';
7
+ import { Label } from '../ui/label';
8
+ import {
9
+ Card,
10
+ CardContent,
11
+ CardDescription,
12
+ CardHeader,
13
+ CardTitle,
14
+ } from '../ui/card';
15
+
16
+ export interface WorkflowSettingsCardProps {
17
+ /** Workflow identifier — passed through to {@link onSave} so the consumer can persist. */
18
+ workflowId: string;
19
+ /** Current workflow-level settings (arbitrary key/value map). */
20
+ settings: Record<string, unknown>;
21
+ /**
22
+ * Persist edited settings. Supplied by the consumer (no API coupling here).
23
+ * Rejecting the promise surfaces its message inline. When omitted the card
24
+ * is read-only (the Edit affordance is hidden).
25
+ */
26
+ onSave?: (settings: Record<string, unknown>) => Promise<void>;
27
+ }
28
+
29
+ /** Well-known settings with labels and descriptions */
30
+ const KNOWN_FIELDS: {
31
+ key: string;
32
+ label: string;
33
+ hint: string;
34
+ type: 'number' | 'text';
35
+ }[] = [
36
+ { key: 'timeout', label: 'Timeout', hint: 'seconds', type: 'number' },
37
+ { key: 'maxRetries', label: 'Max Retries', hint: 'attempts', type: 'number' },
38
+ ];
39
+
40
+ export function WorkflowSettingsCard({
41
+ workflowId: _workflowId,
42
+ settings,
43
+ onSave,
44
+ }: WorkflowSettingsCardProps) {
45
+ const [editing, setEditing] = useState(false);
46
+ const [draft, setDraft] = useState<Record<string, unknown>>({ ...settings });
47
+ const [saving, setSaving] = useState(false);
48
+ const [error, setError] = useState<string | null>(null);
49
+
50
+ const knownKeys = new Set(KNOWN_FIELDS.map((f) => f.key));
51
+ const unknownKeys = Object.keys(settings).filter((k) => !knownKeys.has(k));
52
+
53
+ function handleEdit() {
54
+ setDraft({ ...settings });
55
+ setError(null);
56
+ setEditing(true);
57
+ }
58
+
59
+ function handleCancel() {
60
+ setDraft({ ...settings });
61
+ setError(null);
62
+ setEditing(false);
63
+ }
64
+
65
+ async function handleSave() {
66
+ if (!onSave) return;
67
+ setSaving(true);
68
+ setError(null);
69
+ try {
70
+ await onSave(draft);
71
+ setEditing(false);
72
+ } catch (err) {
73
+ setError(
74
+ err instanceof Error ? err.message : 'Failed to save settings'
75
+ );
76
+ } finally {
77
+ setSaving(false);
78
+ }
79
+ }
80
+
81
+ function setField(key: string, value: unknown) {
82
+ setDraft((prev) => ({ ...prev, [key]: value }));
83
+ }
84
+
85
+ const isEmpty = Object.keys(settings).length === 0;
86
+ const canEdit = !!onSave;
87
+
88
+ return (
89
+ <Card>
90
+ <CardHeader className="flex flex-row items-start justify-between space-y-0 pb-4">
91
+ <div>
92
+ <CardTitle className="flex items-center gap-2">
93
+ <Settings2 className="h-5 w-5" />
94
+ Settings
95
+ </CardTitle>
96
+ <CardDescription>Workflow-level configuration</CardDescription>
97
+ </div>
98
+ {!isEmpty &&
99
+ canEdit &&
100
+ (editing ? (
101
+ <div className="flex items-center gap-2">
102
+ <Button
103
+ variant="ghost"
104
+ size="sm"
105
+ onClick={handleCancel}
106
+ disabled={saving}
107
+ className="gap-1.5"
108
+ >
109
+ <X className="h-3.5 w-3.5" />
110
+ Cancel
111
+ </Button>
112
+ <Button
113
+ size="sm"
114
+ onClick={handleSave}
115
+ disabled={saving}
116
+ className="gap-1.5"
117
+ >
118
+ <Save className="h-3.5 w-3.5" />
119
+ {saving ? 'Saving…' : 'Save'}
120
+ </Button>
121
+ </div>
122
+ ) : (
123
+ <Button
124
+ variant="ghost"
125
+ size="sm"
126
+ onClick={handleEdit}
127
+ className="gap-1.5"
128
+ >
129
+ <Pencil className="h-3.5 w-3.5" />
130
+ Edit
131
+ </Button>
132
+ ))}
133
+ </CardHeader>
134
+
135
+ <CardContent className="space-y-4">
136
+ {isEmpty ? (
137
+ <p className="text-sm text-muted-foreground">
138
+ No settings configured.
139
+ </p>
140
+ ) : (
141
+ <>
142
+ {/* Known fields */}
143
+ {KNOWN_FIELDS.filter((f) => f.key in settings).map((field) => (
144
+ <div key={field.key} className="space-y-1.5">
145
+ <Label className="text-sm font-medium">{field.label}</Label>
146
+ {editing ? (
147
+ <div className="flex items-center gap-2">
148
+ <Input
149
+ type={field.type}
150
+ value={(draft[field.key] as string | number) ?? ''}
151
+ onChange={(e) =>
152
+ setField(
153
+ field.key,
154
+ field.type === 'number'
155
+ ? Number(e.target.value)
156
+ : e.target.value
157
+ )
158
+ }
159
+ className="max-w-[160px]"
160
+ />
161
+ <span className="text-sm text-muted-foreground">
162
+ {field.hint}
163
+ </span>
164
+ </div>
165
+ ) : (
166
+ <div className="flex items-center gap-2">
167
+ <span className="text-sm font-mono bg-muted px-2.5 py-1 rounded">
168
+ {String(settings[field.key])}
169
+ </span>
170
+ <span className="text-xs text-muted-foreground">
171
+ {field.hint}
172
+ </span>
173
+ </div>
174
+ )}
175
+ </div>
176
+ ))}
177
+
178
+ {/* Unknown / arbitrary keys */}
179
+ {unknownKeys.map((key) => (
180
+ <div key={key} className="space-y-1.5">
181
+ <Label className="text-sm font-medium font-mono">{key}</Label>
182
+ {editing ? (
183
+ <Input
184
+ value={
185
+ typeof draft[key] === 'object'
186
+ ? JSON.stringify(draft[key])
187
+ : String(draft[key] ?? '')
188
+ }
189
+ onChange={(e) => setField(key, e.target.value)}
190
+ className="font-mono text-sm"
191
+ />
192
+ ) : (
193
+ <span className="text-sm font-mono bg-muted px-2.5 py-1 rounded block w-fit">
194
+ {typeof settings[key] === 'object'
195
+ ? JSON.stringify(settings[key])
196
+ : String(settings[key])}
197
+ </span>
198
+ )}
199
+ </div>
200
+ ))}
201
+
202
+ {error && <p className="text-sm text-destructive">{error}</p>}
203
+ </>
204
+ )}
205
+ </CardContent>
206
+ </Card>
207
+ );
208
+ }
@@ -0,0 +1,175 @@
1
+ 'use client';
2
+
3
+ import { useState, type ReactNode } from 'react';
4
+ import { Activity, CheckCircle, Clock, XCircle } from 'lucide-react';
5
+ import { Button } from '../ui/button';
6
+ import { Card, CardContent } from '../ui/card';
7
+ import { Skeleton } from '../ui/skeleton';
8
+ import { cn } from '../../lib/utils';
9
+
10
+ /**
11
+ * Minimal, in-package shape of the workflow execution stats payload. Mirrors
12
+ * the subset of the backend stats record this overview actually reads — kept
13
+ * local so the component carries no app/Redux coupling.
14
+ */
15
+ export interface WorkflowStats {
16
+ totals: {
17
+ total: number;
18
+ completed: number;
19
+ failed: number;
20
+ };
21
+ rates: {
22
+ successRate: number;
23
+ failureRate: number;
24
+ };
25
+ duration: {
26
+ avgSeconds: number | null;
27
+ minSeconds: number | null;
28
+ maxSeconds: number | null;
29
+ };
30
+ }
31
+
32
+ export interface WorkflowStatsOverviewProps {
33
+ /** Execution stats to render. When undefined, cards show a skeleton/empty value. */
34
+ stats?: WorkflowStats | null;
35
+ /** Whether stats are currently loading (drives the per-card skeleton). */
36
+ isLoading?: boolean;
37
+ /**
38
+ * Optional refresh callback. When provided, changing the period selector
39
+ * invokes it with the selected period so the consumer can refetch.
40
+ */
41
+ onRefresh?: (period: string) => Promise<void>;
42
+ }
43
+
44
+ const PERIOD_OPTIONS = [
45
+ { label: '7d', value: '7d' },
46
+ { label: '30d', value: '30d' },
47
+ { label: '90d', value: '90d' },
48
+ ] as const;
49
+
50
+ function formatDuration(seconds: number | null | undefined): string {
51
+ if (seconds === null || seconds === undefined || seconds === 0) return '0s';
52
+ if (seconds < 60) return `${Math.round(seconds)}s`;
53
+ const minutes = Math.floor(seconds / 60);
54
+ const remainingSeconds = Math.round(seconds % 60);
55
+ if (remainingSeconds === 0) return `${minutes}m`;
56
+ return `${minutes}m ${remainingSeconds}s`;
57
+ }
58
+
59
+ /**
60
+ * Inline metric card — substitute for the app's `MetricsCard` primitive,
61
+ * which is not part of @startsimpli/ui. Renders a titled value with an icon,
62
+ * optional description, and a loading skeleton.
63
+ */
64
+ function MetricCard({
65
+ title,
66
+ value,
67
+ icon,
68
+ description,
69
+ loading,
70
+ }: {
71
+ title: string;
72
+ value: ReactNode;
73
+ icon: ReactNode;
74
+ description?: ReactNode;
75
+ loading?: boolean;
76
+ }) {
77
+ return (
78
+ <Card>
79
+ <CardContent className="pt-6">
80
+ <div className="flex items-center justify-between">
81
+ <span className="text-sm font-medium text-muted-foreground">
82
+ {title}
83
+ </span>
84
+ <span className="text-muted-foreground">{icon}</span>
85
+ </div>
86
+ {loading ? (
87
+ <Skeleton className="mt-2 h-7 w-20" />
88
+ ) : (
89
+ <div className="mt-2 text-2xl font-bold">{value ?? '—'}</div>
90
+ )}
91
+ {description && !loading && (
92
+ <p className="mt-1 text-xs text-muted-foreground">{description}</p>
93
+ )}
94
+ </CardContent>
95
+ </Card>
96
+ );
97
+ }
98
+
99
+ export function WorkflowStatsOverview({
100
+ stats,
101
+ isLoading = false,
102
+ onRefresh,
103
+ }: WorkflowStatsOverviewProps) {
104
+ const [period, setPeriod] = useState('30d');
105
+
106
+ const handlePeriodChange = (value: string) => {
107
+ setPeriod(value);
108
+ void onRefresh?.(value);
109
+ };
110
+
111
+ return (
112
+ <div className="space-y-4">
113
+ <div className="flex items-center justify-between">
114
+ <h3 className="text-sm font-medium text-muted-foreground">
115
+ Execution Metrics
116
+ </h3>
117
+ <div className="flex items-center gap-1 rounded-lg border bg-muted/50 p-0.5">
118
+ {PERIOD_OPTIONS.map((opt) => (
119
+ <Button
120
+ key={opt.value}
121
+ variant="ghost"
122
+ size="sm"
123
+ className={cn(
124
+ 'h-7 px-3 text-xs font-medium',
125
+ period === opt.value
126
+ ? 'bg-background shadow-sm text-foreground'
127
+ : 'text-muted-foreground hover:text-foreground'
128
+ )}
129
+ onClick={() => handlePeriodChange(opt.value)}
130
+ >
131
+ {opt.label}
132
+ </Button>
133
+ ))}
134
+ </div>
135
+ </div>
136
+
137
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
138
+ <MetricCard
139
+ title="Total Runs"
140
+ value={stats?.totals.total ?? null}
141
+ icon={<Activity className="h-4 w-4" />}
142
+ description={`Last ${period}`}
143
+ loading={isLoading}
144
+ />
145
+ <MetricCard
146
+ title="Success Rate"
147
+ value={stats ? `${stats.rates.successRate}%` : null}
148
+ icon={<CheckCircle className="h-4 w-4" />}
149
+ description={`${stats?.totals.completed ?? 0} completed`}
150
+ loading={isLoading}
151
+ />
152
+ <MetricCard
153
+ title="Avg Duration"
154
+ value={stats ? formatDuration(stats.duration.avgSeconds) : null}
155
+ icon={<Clock className="h-4 w-4" />}
156
+ description={
157
+ stats
158
+ ? `${formatDuration(stats.duration.minSeconds)} - ${formatDuration(stats.duration.maxSeconds)}`
159
+ : undefined
160
+ }
161
+ loading={isLoading}
162
+ />
163
+ <MetricCard
164
+ title="Failed Runs"
165
+ value={stats?.totals.failed ?? null}
166
+ icon={<XCircle className="h-4 w-4" />}
167
+ description={
168
+ stats ? `${stats.rates.failureRate}% failure rate` : undefined
169
+ }
170
+ loading={isLoading}
171
+ />
172
+ </div>
173
+ </div>
174
+ );
175
+ }