@wopr-network/platform-ui-core 1.12.0 → 1.13.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/package.json +1 -1
- package/src/app/(dashboard)/settings/notifications/page.tsx +1 -1
- package/src/app/admin/fleet-updates/error.tsx +72 -0
- package/src/app/admin/fleet-updates/fleet-updates-client.tsx +308 -0
- package/src/app/admin/fleet-updates/loading.tsx +41 -0
- package/src/app/admin/fleet-updates/page.tsx +5 -0
- package/src/components/admin/admin-nav.tsx +1 -0
package/package.json
CHANGED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { AlertTriangleIcon, HomeIcon, RefreshCwIcon } from "lucide-react";
|
|
4
|
+
import { useEffect, useState } from "react";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
|
7
|
+
import { logger } from "@/lib/logger";
|
|
8
|
+
|
|
9
|
+
const log = logger("error-boundary:fleet-updates");
|
|
10
|
+
|
|
11
|
+
export default function FleetUpdatesError({
|
|
12
|
+
error,
|
|
13
|
+
reset,
|
|
14
|
+
}: {
|
|
15
|
+
error: Error & { digest?: string };
|
|
16
|
+
reset: () => void;
|
|
17
|
+
}) {
|
|
18
|
+
const [showDetails, setShowDetails] = useState(false);
|
|
19
|
+
const isDev = process.env.NODE_ENV === "development";
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
log.error("Fleet updates error", error);
|
|
23
|
+
}, [error]);
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div className="flex h-full items-center justify-center p-6">
|
|
27
|
+
<Card className="w-full max-w-lg">
|
|
28
|
+
<CardHeader>
|
|
29
|
+
<div className="flex items-center gap-3">
|
|
30
|
+
<AlertTriangleIcon className="size-6 text-destructive" />
|
|
31
|
+
<CardTitle className="text-xl">Fleet Updates Error</CardTitle>
|
|
32
|
+
</div>
|
|
33
|
+
</CardHeader>
|
|
34
|
+
<CardContent className="space-y-4">
|
|
35
|
+
<p className="text-muted-foreground">
|
|
36
|
+
Something went wrong loading the fleet updates panel. This may be a temporary issue.
|
|
37
|
+
</p>
|
|
38
|
+
{isDev && (
|
|
39
|
+
<Button
|
|
40
|
+
type="button"
|
|
41
|
+
variant="link"
|
|
42
|
+
size="sm"
|
|
43
|
+
onClick={() => setShowDetails((v) => !v)}
|
|
44
|
+
className="h-auto p-0 text-muted-foreground"
|
|
45
|
+
>
|
|
46
|
+
{showDetails ? "Hide" : "Show"} error details
|
|
47
|
+
</Button>
|
|
48
|
+
)}
|
|
49
|
+
{isDev && showDetails && (
|
|
50
|
+
<pre className="max-h-48 overflow-auto rounded-md border bg-muted p-3 text-xs">
|
|
51
|
+
{error.message}
|
|
52
|
+
{error.stack && `\n\n${error.stack}`}
|
|
53
|
+
{error.digest && `\n\nDigest: ${error.digest}`}
|
|
54
|
+
</pre>
|
|
55
|
+
)}
|
|
56
|
+
</CardContent>
|
|
57
|
+
<CardFooter className="gap-3">
|
|
58
|
+
<Button onClick={reset}>
|
|
59
|
+
<RefreshCwIcon />
|
|
60
|
+
Try Again
|
|
61
|
+
</Button>
|
|
62
|
+
<Button variant="outline" asChild>
|
|
63
|
+
<a href="/admin">
|
|
64
|
+
<HomeIcon />
|
|
65
|
+
Admin Home
|
|
66
|
+
</a>
|
|
67
|
+
</Button>
|
|
68
|
+
</CardFooter>
|
|
69
|
+
</Card>
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Loader2, RefreshCw, Rocket } from "lucide-react";
|
|
4
|
+
import { toast } from "sonner";
|
|
5
|
+
import { Badge } from "@/components/ui/badge";
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
8
|
+
import {
|
|
9
|
+
Select,
|
|
10
|
+
SelectContent,
|
|
11
|
+
SelectItem,
|
|
12
|
+
SelectTrigger,
|
|
13
|
+
SelectValue,
|
|
14
|
+
} from "@/components/ui/select";
|
|
15
|
+
import { Skeleton } from "@/components/ui/skeleton";
|
|
16
|
+
import {
|
|
17
|
+
Table,
|
|
18
|
+
TableBody,
|
|
19
|
+
TableCell,
|
|
20
|
+
TableHead,
|
|
21
|
+
TableHeader,
|
|
22
|
+
TableRow,
|
|
23
|
+
} from "@/components/ui/table";
|
|
24
|
+
import { trpc } from "@/lib/trpc";
|
|
25
|
+
|
|
26
|
+
/* ------------------------------------------------------------------ */
|
|
27
|
+
/* Types (cast from AnyTRPCQueryProcedure stubs) */
|
|
28
|
+
/* ------------------------------------------------------------------ */
|
|
29
|
+
|
|
30
|
+
interface RolloutStatus {
|
|
31
|
+
isRolling: boolean;
|
|
32
|
+
startedAt?: string;
|
|
33
|
+
progress?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface TenantConfig {
|
|
37
|
+
tenantId: string;
|
|
38
|
+
mode: "auto" | "manual";
|
|
39
|
+
preferredHourUtc: number;
|
|
40
|
+
updatedAt: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/* ------------------------------------------------------------------ */
|
|
44
|
+
/* Rollout Status Card */
|
|
45
|
+
/* ------------------------------------------------------------------ */
|
|
46
|
+
|
|
47
|
+
function RolloutStatusCard() {
|
|
48
|
+
const utils = trpc.useUtils();
|
|
49
|
+
|
|
50
|
+
const statusQuery = trpc.adminFleetUpdate.rolloutStatus.useQuery(undefined, {
|
|
51
|
+
refetchInterval: 10_000,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const forceRolloutMutation = trpc.adminFleetUpdate.forceRollout.useMutation({
|
|
55
|
+
onSuccess: () => {
|
|
56
|
+
toast.success("Force rollout initiated.");
|
|
57
|
+
utils.adminFleetUpdate.rolloutStatus.invalidate();
|
|
58
|
+
},
|
|
59
|
+
onError: (err) => {
|
|
60
|
+
toast.error(`Force rollout failed: ${err.message}`);
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// NOTE: Cast needed because trpc-types.ts uses AnyTRPCQueryProcedure stubs
|
|
65
|
+
// that erase return types. Remove when @wopr-network/sdk is published.
|
|
66
|
+
const status = statusQuery.data as RolloutStatus | undefined;
|
|
67
|
+
const isRolling = status?.isRolling ?? false;
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<Card className="border-border">
|
|
71
|
+
<CardHeader className="pb-3">
|
|
72
|
+
<div className="flex items-center justify-between">
|
|
73
|
+
<div>
|
|
74
|
+
<CardTitle className="text-base font-mono">Rollout Status</CardTitle>
|
|
75
|
+
<CardDescription className="text-xs">
|
|
76
|
+
Current fleet update rollout state. Auto-refreshes every 10s.
|
|
77
|
+
</CardDescription>
|
|
78
|
+
</div>
|
|
79
|
+
<Button
|
|
80
|
+
type="button"
|
|
81
|
+
variant="ghost"
|
|
82
|
+
size="sm"
|
|
83
|
+
onClick={() => statusQuery.refetch()}
|
|
84
|
+
disabled={statusQuery.isFetching}
|
|
85
|
+
className="font-mono text-xs"
|
|
86
|
+
>
|
|
87
|
+
<RefreshCw size={12} className={statusQuery.isFetching ? "animate-spin" : ""} />
|
|
88
|
+
Refresh
|
|
89
|
+
</Button>
|
|
90
|
+
</div>
|
|
91
|
+
</CardHeader>
|
|
92
|
+
<CardContent>
|
|
93
|
+
{statusQuery.isLoading ? (
|
|
94
|
+
<div className="flex items-center gap-4">
|
|
95
|
+
<Skeleton className="h-6 w-20" />
|
|
96
|
+
<Skeleton className="h-8 w-32" />
|
|
97
|
+
</div>
|
|
98
|
+
) : statusQuery.isError ? (
|
|
99
|
+
<p className="text-sm text-destructive font-mono">Failed to load rollout status.</p>
|
|
100
|
+
) : (
|
|
101
|
+
<div className="flex items-center gap-4">
|
|
102
|
+
<Badge
|
|
103
|
+
variant="outline"
|
|
104
|
+
className={
|
|
105
|
+
isRolling
|
|
106
|
+
? "border-amber-500/30 bg-amber-500/10 text-amber-400"
|
|
107
|
+
: "border-terminal/30 bg-terminal/10 text-terminal"
|
|
108
|
+
}
|
|
109
|
+
>
|
|
110
|
+
{isRolling ? "Rolling" : "Idle"}
|
|
111
|
+
</Badge>
|
|
112
|
+
{isRolling && status?.progress != null && (
|
|
113
|
+
<span className="text-xs text-muted-foreground font-mono tabular-nums">
|
|
114
|
+
{Math.round(status.progress * 100)}% complete
|
|
115
|
+
</span>
|
|
116
|
+
)}
|
|
117
|
+
<Button
|
|
118
|
+
type="button"
|
|
119
|
+
size="sm"
|
|
120
|
+
onClick={() => forceRolloutMutation.mutate(undefined)}
|
|
121
|
+
disabled={forceRolloutMutation.isPending || isRolling}
|
|
122
|
+
className="font-mono text-xs ml-auto"
|
|
123
|
+
>
|
|
124
|
+
{forceRolloutMutation.isPending ? (
|
|
125
|
+
<Loader2 size={12} className="animate-spin" />
|
|
126
|
+
) : (
|
|
127
|
+
<Rocket size={12} />
|
|
128
|
+
)}
|
|
129
|
+
Force Rollout
|
|
130
|
+
</Button>
|
|
131
|
+
</div>
|
|
132
|
+
)}
|
|
133
|
+
</CardContent>
|
|
134
|
+
</Card>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/* ------------------------------------------------------------------ */
|
|
139
|
+
/* Tenant Config Row */
|
|
140
|
+
/* ------------------------------------------------------------------ */
|
|
141
|
+
|
|
142
|
+
function TenantConfigRow({
|
|
143
|
+
config,
|
|
144
|
+
onModeChanged,
|
|
145
|
+
}: {
|
|
146
|
+
config: TenantConfig;
|
|
147
|
+
onModeChanged: () => void;
|
|
148
|
+
}) {
|
|
149
|
+
const setConfigMutation = trpc.adminFleetUpdate.setTenantConfig.useMutation({
|
|
150
|
+
onSuccess: () => {
|
|
151
|
+
toast.success(`Tenant ${config.tenantId} config updated.`);
|
|
152
|
+
onModeChanged();
|
|
153
|
+
},
|
|
154
|
+
onError: (err) => {
|
|
155
|
+
toast.error(`Failed to update config: ${err.message}`);
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
function handleModeChange(newMode: string) {
|
|
160
|
+
setConfigMutation.mutate({
|
|
161
|
+
tenantId: config.tenantId,
|
|
162
|
+
mode: newMode as "auto" | "manual",
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
<TableRow>
|
|
168
|
+
<TableCell>
|
|
169
|
+
<code className="text-xs text-muted-foreground">{config.tenantId}</code>
|
|
170
|
+
</TableCell>
|
|
171
|
+
<TableCell>
|
|
172
|
+
<Select
|
|
173
|
+
value={config.mode}
|
|
174
|
+
onValueChange={handleModeChange}
|
|
175
|
+
disabled={setConfigMutation.isPending}
|
|
176
|
+
>
|
|
177
|
+
<SelectTrigger className="h-7 w-28 text-xs">
|
|
178
|
+
<SelectValue />
|
|
179
|
+
</SelectTrigger>
|
|
180
|
+
<SelectContent>
|
|
181
|
+
<SelectItem value="auto" className="text-xs">
|
|
182
|
+
auto
|
|
183
|
+
</SelectItem>
|
|
184
|
+
<SelectItem value="manual" className="text-xs">
|
|
185
|
+
manual
|
|
186
|
+
</SelectItem>
|
|
187
|
+
</SelectContent>
|
|
188
|
+
</Select>
|
|
189
|
+
</TableCell>
|
|
190
|
+
<TableCell>
|
|
191
|
+
<span className="text-sm text-muted-foreground font-mono tabular-nums">
|
|
192
|
+
{String(config.preferredHourUtc).padStart(2, "0")}:00 UTC
|
|
193
|
+
</span>
|
|
194
|
+
</TableCell>
|
|
195
|
+
<TableCell>
|
|
196
|
+
<span className="text-xs text-muted-foreground">
|
|
197
|
+
{new Date(config.updatedAt).toLocaleDateString()}
|
|
198
|
+
</span>
|
|
199
|
+
</TableCell>
|
|
200
|
+
</TableRow>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/* ------------------------------------------------------------------ */
|
|
205
|
+
/* Main Component */
|
|
206
|
+
/* ------------------------------------------------------------------ */
|
|
207
|
+
|
|
208
|
+
export function FleetUpdatesClient() {
|
|
209
|
+
const utils = trpc.useUtils();
|
|
210
|
+
|
|
211
|
+
const configsQuery = trpc.adminFleetUpdate.listTenantConfigs.useQuery(undefined, {
|
|
212
|
+
refetchOnWindowFocus: false,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// NOTE: Cast needed because trpc-types.ts uses AnyTRPCQueryProcedure stubs
|
|
216
|
+
// that erase return types. Remove when @wopr-network/sdk is published.
|
|
217
|
+
const configs = (configsQuery.data ?? []) as TenantConfig[];
|
|
218
|
+
|
|
219
|
+
function handleConfigChanged() {
|
|
220
|
+
utils.adminFleetUpdate.listTenantConfigs.invalidate();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return (
|
|
224
|
+
<div className="space-y-4 max-w-5xl mx-auto">
|
|
225
|
+
{/* Header */}
|
|
226
|
+
<div className="flex items-center justify-between px-6 pt-6 pb-2">
|
|
227
|
+
<h1 className="text-lg font-bold text-terminal">
|
|
228
|
+
<span className="text-muted-foreground">></span> Fleet Updates
|
|
229
|
+
</h1>
|
|
230
|
+
</div>
|
|
231
|
+
|
|
232
|
+
{/* Rollout Status */}
|
|
233
|
+
<div className="px-6">
|
|
234
|
+
<RolloutStatusCard />
|
|
235
|
+
</div>
|
|
236
|
+
|
|
237
|
+
{/* Tenant Configs Table */}
|
|
238
|
+
<div className="mx-6 bg-card border border-border rounded-sm">
|
|
239
|
+
<div className="px-4 py-3 border-b border-border flex items-center justify-between">
|
|
240
|
+
<div className="text-xs uppercase tracking-widest text-muted-foreground">
|
|
241
|
+
Tenant Update Configs
|
|
242
|
+
</div>
|
|
243
|
+
<Button
|
|
244
|
+
type="button"
|
|
245
|
+
variant="ghost"
|
|
246
|
+
size="sm"
|
|
247
|
+
onClick={() => configsQuery.refetch()}
|
|
248
|
+
disabled={configsQuery.isFetching}
|
|
249
|
+
className="font-mono text-xs"
|
|
250
|
+
>
|
|
251
|
+
<RefreshCw size={12} className={configsQuery.isFetching ? "animate-spin" : ""} />
|
|
252
|
+
Refresh
|
|
253
|
+
</Button>
|
|
254
|
+
</div>
|
|
255
|
+
|
|
256
|
+
{configsQuery.isLoading ? (
|
|
257
|
+
<div className="space-y-2 p-4">
|
|
258
|
+
{Array.from({ length: 5 }, (_, i) => `sk-cfg-${i}`).map((k) => (
|
|
259
|
+
<Skeleton key={k} className="h-12 rounded-md" />
|
|
260
|
+
))}
|
|
261
|
+
</div>
|
|
262
|
+
) : configsQuery.isError ? (
|
|
263
|
+
<div className="flex h-40 flex-col items-center justify-center gap-3">
|
|
264
|
+
<p className="text-sm text-destructive font-mono">Failed to load tenant configs.</p>
|
|
265
|
+
<Button variant="outline" size="sm" onClick={() => configsQuery.refetch()}>
|
|
266
|
+
Retry
|
|
267
|
+
</Button>
|
|
268
|
+
</div>
|
|
269
|
+
) : (
|
|
270
|
+
<div className="rounded-md overflow-hidden">
|
|
271
|
+
<Table>
|
|
272
|
+
<TableHeader>
|
|
273
|
+
<TableRow className="bg-muted/40">
|
|
274
|
+
<TableHead className="text-xs uppercase tracking-wider">Tenant ID</TableHead>
|
|
275
|
+
<TableHead className="text-xs uppercase tracking-wider">Mode</TableHead>
|
|
276
|
+
<TableHead className="text-xs uppercase tracking-wider">
|
|
277
|
+
Preferred Hour (UTC)
|
|
278
|
+
</TableHead>
|
|
279
|
+
<TableHead className="text-xs uppercase tracking-wider">Last Updated</TableHead>
|
|
280
|
+
</TableRow>
|
|
281
|
+
</TableHeader>
|
|
282
|
+
<TableBody>
|
|
283
|
+
{configs.length === 0 ? (
|
|
284
|
+
<TableRow>
|
|
285
|
+
<TableCell
|
|
286
|
+
colSpan={4}
|
|
287
|
+
className="text-center text-muted-foreground py-8 text-sm font-mono"
|
|
288
|
+
>
|
|
289
|
+
> No tenant configs found.
|
|
290
|
+
</TableCell>
|
|
291
|
+
</TableRow>
|
|
292
|
+
) : (
|
|
293
|
+
configs.map((cfg) => (
|
|
294
|
+
<TenantConfigRow
|
|
295
|
+
key={cfg.tenantId}
|
|
296
|
+
config={cfg}
|
|
297
|
+
onModeChanged={handleConfigChanged}
|
|
298
|
+
/>
|
|
299
|
+
))
|
|
300
|
+
)}
|
|
301
|
+
</TableBody>
|
|
302
|
+
</Table>
|
|
303
|
+
</div>
|
|
304
|
+
)}
|
|
305
|
+
</div>
|
|
306
|
+
</div>
|
|
307
|
+
);
|
|
308
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Skeleton } from "@/components/ui/skeleton";
|
|
2
|
+
|
|
3
|
+
export default function FleetUpdatesLoading() {
|
|
4
|
+
return (
|
|
5
|
+
<div className="space-y-6 p-6">
|
|
6
|
+
<div className="space-y-2">
|
|
7
|
+
<Skeleton className="h-8 w-48" />
|
|
8
|
+
<Skeleton className="h-4 w-64" />
|
|
9
|
+
</div>
|
|
10
|
+
{/* Rollout status card skeleton */}
|
|
11
|
+
<div className="rounded-md border p-4 space-y-3">
|
|
12
|
+
<Skeleton className="h-5 w-32" />
|
|
13
|
+
<div className="flex items-center gap-4">
|
|
14
|
+
<Skeleton className="h-6 w-16" />
|
|
15
|
+
<Skeleton className="h-8 w-28" />
|
|
16
|
+
</div>
|
|
17
|
+
</div>
|
|
18
|
+
{/* Table skeleton */}
|
|
19
|
+
<div className="rounded-md border">
|
|
20
|
+
<div className="border-b px-4 py-3">
|
|
21
|
+
<div className="flex items-center gap-4">
|
|
22
|
+
<Skeleton className="h-4 w-32" />
|
|
23
|
+
<Skeleton className="h-4 w-20" />
|
|
24
|
+
<Skeleton className="h-4 w-28" />
|
|
25
|
+
<Skeleton className="h-4 w-24" />
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
<div className="space-y-0">
|
|
29
|
+
{Array.from({ length: 6 }, (_, n) => `sk-${n}`).map((skId) => (
|
|
30
|
+
<div key={skId} className="flex items-center gap-4 border-b px-4 py-3 last:border-b-0">
|
|
31
|
+
<Skeleton className="h-4 w-40" />
|
|
32
|
+
<Skeleton className="h-5 w-16" />
|
|
33
|
+
<Skeleton className="h-4 w-20" />
|
|
34
|
+
<Skeleton className="h-4 w-28" />
|
|
35
|
+
</div>
|
|
36
|
+
))}
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -22,6 +22,7 @@ const adminNavItems = [
|
|
|
22
22
|
{ label: "Roles", href: "/admin/roles" },
|
|
23
23
|
{ label: "Migrations", href: "/admin/migrations" },
|
|
24
24
|
{ label: "GPU", href: "/admin/gpu" },
|
|
25
|
+
{ label: "Fleet Updates", href: "/admin/fleet-updates" },
|
|
25
26
|
{ label: "Incidents", href: "/admin/incidents" },
|
|
26
27
|
];
|
|
27
28
|
|