@wopr-network/platform-ui-core 1.12.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/platform-ui-core",
3
- "version": "1.12.1",
3
+ "version": "1.13.0",
4
4
  "description": "Brand-agnostic AI agent platform UI — deploy as any brand via env vars",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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">&gt;</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
+ &gt; 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
+ }
@@ -0,0 +1,5 @@
1
+ import { FleetUpdatesClient } from "./fleet-updates-client";
2
+
3
+ export default function AdminFleetUpdatesPage() {
4
+ return <FleetUpdatesClient />;
5
+ }
@@ -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