chore(admin-web): reduce high-signal lint warnings

This commit is contained in:
Saravana Achu Mac 2026-04-04 17:56:53 -07:00
parent 631784e551
commit f8949d230f
7 changed files with 251 additions and 215 deletions

View File

@ -1,11 +1,10 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
import { import {
Dialog, Dialog,
@ -92,16 +91,12 @@ export default function AIDiagnosticsPage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedProduct, setSelectedProduct] = useState<string>('all'); const [selectedProduct, setSelectedProduct] = useState<string>('all');
const [selectedCluster, setSelectedCluster] = useState<ErrorCluster | null>(null); const [activeTab, setActiveTab] = useState<'overview' | 'clusters' | 'alerts' | 'query'>(
const [activeTab, setActiveTab] = useState<'overview' | 'clusters' | 'alerts' | 'query'>('overview'); 'overview'
);
// Fetch clusters on mount // Fetch clusters on mount
useEffect(() => { const fetchClusters = useCallback(async () => {
fetchClusters();
fetchAlerts();
}, [selectedProduct]);
const fetchClusters = async () => {
try { try {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (selectedProduct !== 'all') params.set('productId', selectedProduct); if (selectedProduct !== 'all') params.set('productId', selectedProduct);
@ -115,9 +110,9 @@ export default function AIDiagnosticsPage() {
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch clusters'); setError(err instanceof Error ? err.message : 'Failed to fetch clusters');
} }
}; }, [selectedProduct]);
const fetchAlerts = async () => { const fetchAlerts = useCallback(async () => {
try { try {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (selectedProduct !== 'all') params.set('productId', selectedProduct); if (selectedProduct !== 'all') params.set('productId', selectedProduct);
@ -129,7 +124,12 @@ export default function AIDiagnosticsPage() {
} catch (err) { } catch (err) {
console.error('Failed to fetch alerts:', err); console.error('Failed to fetch alerts:', err);
} }
}; }, [selectedProduct]);
useEffect(() => {
void fetchClusters();
void fetchAlerts();
}, [fetchAlerts, fetchClusters]);
const handleQuery = async () => { const handleQuery = async () => {
if (!query.trim()) return; if (!query.trim()) return;
@ -175,11 +175,7 @@ export default function AIDiagnosticsPage() {
medium: 'bg-yellow-500', medium: 'bg-yellow-500',
low: 'bg-blue-500', low: 'bg-blue-500',
}; };
return ( return <Badge className={colors[severity] || 'bg-gray-500'}>{severity}</Badge>;
<Badge className={colors[severity] || 'bg-gray-500'}>
{severity}
</Badge>
);
}; };
// Overview stats // Overview stats
@ -236,8 +232,8 @@ export default function AIDiagnosticsPage() {
<Input <Input
placeholder="Ask AI about errors (e.g., 'Why did iOS keyboard crash yesterday?')" placeholder="Ask AI about errors (e.g., 'Why did iOS keyboard crash yesterday?')"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={e => setQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleQuery()} onKeyDown={e => e.key === 'Enter' && handleQuery()}
className="pl-9" className="pl-9"
/> />
</div> </div>
@ -261,7 +257,7 @@ export default function AIDiagnosticsPage() {
'Compare iOS vs Android crash rates', 'Compare iOS vs Android crash rates',
'Show new error types today', 'Show new error types today',
'Why did auth failures spike?', 'Why did auth failures spike?',
].map((suggestion) => ( ].map(suggestion => (
<button <button
key={suggestion} key={suggestion}
onClick={() => setQuery(suggestion)} onClick={() => setQuery(suggestion)}
@ -310,9 +306,7 @@ export default function AIDiagnosticsPage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-3xl font-bold text-red-600">{activeClusters}</div> <div className="text-3xl font-bold text-red-600">{activeClusters}</div>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">Errors requiring attention</p>
Errors requiring attention
</p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
@ -323,9 +317,7 @@ export default function AIDiagnosticsPage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-3xl font-bold text-yellow-600">{investigatingClusters}</div> <div className="text-3xl font-bold text-yellow-600">{investigatingClusters}</div>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">Under investigation</p>
Under investigation
</p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
@ -336,9 +328,7 @@ export default function AIDiagnosticsPage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-3xl font-bold text-green-600">{resolvedClusters}</div> <div className="text-3xl font-bold text-green-600">{resolvedClusters}</div>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">Issues resolved this week</p>
Issues resolved this week
</p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
@ -351,9 +341,7 @@ export default function AIDiagnosticsPage() {
<div className="text-3xl font-bold text-purple-600"> <div className="text-3xl font-bold text-purple-600">
{clusters.filter(c => c.latestInsight).length} {clusters.filter(c => c.latestInsight).length}
</div> </div>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">With AI analysis</p>
With AI analysis
</p>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@ -368,12 +356,11 @@ export default function AIDiagnosticsPage() {
{clusters {clusters
.filter(c => c.status === 'active') .filter(c => c.status === 'active')
.slice(0, 5) .slice(0, 5)
.map((cluster) => ( .map(cluster => (
<div <div
key={cluster.id} key={cluster.id}
className="flex items-center justify-between p-3 border rounded-lg hover:bg-gray-50 cursor-pointer" className="flex items-center justify-between p-3 border rounded-lg hover:bg-gray-50 cursor-pointer"
onClick={() => { onClick={() => {
setSelectedCluster(cluster);
setActiveTab('clusters'); setActiveTab('clusters');
}} }}
> >
@ -419,7 +406,7 @@ export default function AIDiagnosticsPage() {
{alerts {alerts
.filter(a => !a.acknowledged) .filter(a => !a.acknowledged)
.slice(0, 3) .slice(0, 3)
.map((alert) => ( .map(alert => (
<div <div
key={alert.id} key={alert.id}
className="flex items-center justify-between p-3 border rounded-lg hover:bg-gray-50" className="flex items-center justify-between p-3 border rounded-lg hover:bg-gray-50"
@ -428,9 +415,7 @@ export default function AIDiagnosticsPage() {
{getSeverityBadge(alert.severity)} {getSeverityBadge(alert.severity)}
<div> <div>
<div className="font-medium">{alert.title}</div> <div className="font-medium">{alert.title}</div>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">{alert.description}</div>
{alert.description}
</div>
</div> </div>
</div> </div>
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">
@ -453,7 +438,7 @@ export default function AIDiagnosticsPage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-2"> <div className="space-y-2">
{clusters.map((cluster) => ( {clusters.map(cluster => (
<Dialog key={cluster.id}> <Dialog key={cluster.id}>
<DialogTrigger asChild> <DialogTrigger asChild>
<div className="flex items-center justify-between p-3 border rounded-lg hover:bg-gray-50 cursor-pointer"> <div className="flex items-center justify-between p-3 border rounded-lg hover:bg-gray-50 cursor-pointer">
@ -592,7 +577,7 @@ export default function AIDiagnosticsPage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-2"> <div className="space-y-2">
{alerts.map((alert) => ( {alerts.map(alert => (
<div <div
key={alert.id} key={alert.id}
className="flex items-center justify-between p-3 border rounded-lg hover:bg-gray-50" className="flex items-center justify-between p-3 border rounded-lg hover:bg-gray-50"
@ -601,9 +586,7 @@ export default function AIDiagnosticsPage() {
{getSeverityBadge(alert.severity)} {getSeverityBadge(alert.severity)}
<div> <div>
<div className="font-medium">{alert.title}</div> <div className="font-medium">{alert.title}</div>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">{alert.description}</div>
{alert.description}
</div>
<div className="flex gap-2 mt-1 text-xs text-muted-foreground"> <div className="flex gap-2 mt-1 text-xs text-muted-foreground">
<span>{alert.alertType}</span> <span>{alert.alertType}</span>
<span></span> <span></span>
@ -666,15 +649,21 @@ export default function AIDiagnosticsPage() {
<div> <div>
<h4 className="font-medium mb-2">Supporting Data</h4> <h4 className="font-medium mb-2">Supporting Data</h4>
<div className="space-y-2"> <div className="space-y-2">
{queryResult.supportingData.map((item) => ( {queryResult.supportingData.map(item => (
<div <div
key={item.id} key={item.id}
className="flex items-center justify-between p-3 border rounded-lg" className="flex items-center justify-between p-3 border rounded-lg"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{item.type === 'cluster' && <AlertTriangle className="h-4 w-4 text-red-500" />} {item.type === 'cluster' && (
{item.type === 'insight' && <Sparkles className="h-4 w-4 text-purple-500" />} <AlertTriangle className="h-4 w-4 text-red-500" />
{item.type === 'trend' && <TrendingUp className="h-4 w-4 text-blue-500" />} )}
{item.type === 'insight' && (
<Sparkles className="h-4 w-4 text-purple-500" />
)}
{item.type === 'trend' && (
<TrendingUp className="h-4 w-4 text-blue-500" />
)}
<span className="font-medium">{item.title}</span> <span className="font-medium">{item.title}</span>
</div> </div>
<Badge variant="outline"> <Badge variant="outline">

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { import {
apiCreateBroadcast, apiCreateBroadcast,
@ -65,13 +65,8 @@ export default function BroadcastEditorPage() {
scheduledAt: '', scheduledAt: '',
}); });
useEffect(() => { const loadBroadcast = useCallback(async () => {
if (broadcastId) { if (!broadcastId) return;
loadBroadcast();
}
}, [broadcastId]);
async function loadBroadcast() {
try { try {
const { data, error } = await apiGetBroadcast(broadcastId!); const { data, error } = await apiGetBroadcast(broadcastId!);
if (error) throw new Error(error); if (error) throw new Error(error);
@ -105,7 +100,11 @@ export default function BroadcastEditorPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }, [broadcastId, toast]);
useEffect(() => {
void loadBroadcast();
}, [loadBroadcast]);
async function handleEstimateReach() { async function handleEstimateReach() {
setEstimating(true); setEstimating(true);
@ -115,12 +114,12 @@ export default function BroadcastEditorPage() {
userSegments: form.target.userSegments as ApiBroadcast['target']['userSegments'], userSegments: form.target.userSegments as ApiBroadcast['target']['userSegments'],
countryCodes: form.target.countryCodes countryCodes: form.target.countryCodes
.split(',') .split(',')
.map((c) => c.trim()) .map(c => c.trim())
.filter(Boolean), .filter(Boolean),
percentageRollout: form.target.percentageRollout, percentageRollout: form.target.percentageRollout,
specificUserIds: form.target.specificUserIds specificUserIds: form.target.specificUserIds
.split(',') .split(',')
.map((id) => id.trim()) .map(id => id.trim())
.filter(Boolean), .filter(Boolean),
}); });
if (error) throw new Error(error); if (error) throw new Error(error);
@ -146,22 +145,30 @@ export default function BroadcastEditorPage() {
...form.target, ...form.target,
countryCodes: form.target.countryCodes countryCodes: form.target.countryCodes
.split(',') .split(',')
.map((c) => c.trim()) .map(c => c.trim())
.filter(Boolean), .filter(Boolean),
specificUserIds: form.target.specificUserIds specificUserIds: form.target.specificUserIds
.split(',') .split(',')
.map((id) => id.trim()) .map(id => id.trim())
.filter(Boolean), .filter(Boolean),
}, },
status: asDraft ? 'draft' : undefined, status: asDraft ? 'draft' : undefined,
}; };
if (isEdit) { if (isEdit) {
const { error } = await apiUpdateBroadcast(broadcastId!, payload as unknown as Partial<ApiBroadcast>); const { error } = await apiUpdateBroadcast(
broadcastId!,
payload as unknown as Partial<ApiBroadcast>
);
if (error) throw new Error(error); if (error) throw new Error(error);
toast({ title: 'Success', description: 'Broadcast updated', variant: 'success' }); toast({ title: 'Success', description: 'Broadcast updated', variant: 'success' });
} else { } else {
const { error } = await apiCreateBroadcast(payload as unknown as Omit<ApiBroadcast, 'id' | 'metrics' | 'createdAt' | 'updatedAt' | 'createdBy'>); const { error } = await apiCreateBroadcast(
payload as unknown as Omit<
ApiBroadcast,
'id' | 'metrics' | 'createdAt' | 'updatedAt' | 'createdBy'
>
);
if (error) throw new Error(error); if (error) throw new Error(error);
toast({ title: 'Success', description: 'Broadcast created', variant: 'success' }); toast({ title: 'Success', description: 'Broadcast created', variant: 'success' });
} }
@ -197,9 +204,7 @@ export default function BroadcastEditorPage() {
</Button> </Button>
</Link> </Link>
<div> <div>
<h1 className="text-3xl font-bold"> <h1 className="text-3xl font-bold">{isEdit ? 'Edit Broadcast' : 'New Broadcast'}</h1>
{isEdit ? 'Edit Broadcast' : 'New Broadcast'}
</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{isEdit ? 'Update your broadcast details' : 'Create a targeted message campaign'} {isEdit ? 'Update your broadcast details' : 'Create a targeted message campaign'}
</p> </p>
@ -217,9 +222,7 @@ export default function BroadcastEditorPage() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Message Content</CardTitle> <CardTitle>Message Content</CardTitle>
<CardDescription> <CardDescription>Craft your broadcast message</CardDescription>
Craft your broadcast message
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
@ -227,7 +230,7 @@ export default function BroadcastEditorPage() {
<Input <Input
id="title" id="title"
value={form.title} value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })} onChange={e => setForm({ ...form, title: e.target.value })}
placeholder="Enter broadcast title" placeholder="Enter broadcast title"
/> />
</div> </div>
@ -237,7 +240,7 @@ export default function BroadcastEditorPage() {
<Textarea <Textarea
id="body" id="body"
value={form.body} value={form.body}
onChange={(e) => setForm({ ...form, body: e.target.value })} onChange={e => setForm({ ...form, body: e.target.value })}
placeholder="Enter broadcast message" placeholder="Enter broadcast message"
rows={4} rows={4}
/> />
@ -248,7 +251,7 @@ export default function BroadcastEditorPage() {
<Textarea <Textarea
id="bodyMarkdown" id="bodyMarkdown"
value={form.bodyMarkdown} value={form.bodyMarkdown}
onChange={(e) => setForm({ ...form, bodyMarkdown: e.target.value })} onChange={e => setForm({ ...form, bodyMarkdown: e.target.value })}
placeholder="Rich text version (supports Markdown)" placeholder="Rich text version (supports Markdown)"
rows={4} rows={4}
/> />
@ -260,7 +263,7 @@ export default function BroadcastEditorPage() {
<Input <Input
id="ctaText" id="ctaText"
value={form.ctaText} value={form.ctaText}
onChange={(e) => setForm({ ...form, ctaText: e.target.value })} onChange={e => setForm({ ...form, ctaText: e.target.value })}
placeholder="Learn More" placeholder="Learn More"
/> />
</div> </div>
@ -269,7 +272,7 @@ export default function BroadcastEditorPage() {
<Input <Input
id="ctaUrl" id="ctaUrl"
value={form.ctaUrl} value={form.ctaUrl}
onChange={(e) => setForm({ ...form, ctaUrl: e.target.value })} onChange={e => setForm({ ...form, ctaUrl: e.target.value })}
placeholder="https://..." placeholder="https://..."
/> />
</div> </div>
@ -280,7 +283,7 @@ export default function BroadcastEditorPage() {
<Input <Input
id="imageUrl" id="imageUrl"
value={form.imageUrl} value={form.imageUrl}
onChange={(e) => setForm({ ...form, imageUrl: e.target.value })} onChange={e => setForm({ ...form, imageUrl: e.target.value })}
placeholder="https://..." placeholder="https://..."
/> />
</div> </div>
@ -292,15 +295,13 @@ export default function BroadcastEditorPage() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Audience Targeting</CardTitle> <CardTitle>Audience Targeting</CardTitle>
<CardDescription> <CardDescription>Define who should receive this broadcast</CardDescription>
Define who should receive this broadcast
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div className="space-y-2"> <div className="space-y-2">
<Label>Platforms</Label> <Label>Platforms</Label>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{PLATFORMS.map((platform) => ( {PLATFORMS.map(platform => (
<Badge <Badge
key={platform} key={platform}
variant={form.target.platforms.includes(platform) ? 'default' : 'outline'} variant={form.target.platforms.includes(platform) ? 'default' : 'outline'}
@ -311,7 +312,7 @@ export default function BroadcastEditorPage() {
target: { target: {
...form.target, ...form.target,
platforms: form.target.platforms.includes(platform) platforms: form.target.platforms.includes(platform)
? form.target.platforms.filter((p) => p !== platform) ? form.target.platforms.filter(p => p !== platform)
: [...form.target.platforms, platform], : [...form.target.platforms, platform],
}, },
}) })
@ -326,7 +327,7 @@ export default function BroadcastEditorPage() {
<div className="space-y-2"> <div className="space-y-2">
<Label>User Segments</Label> <Label>User Segments</Label>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{SEGMENTS.map((segment) => ( {SEGMENTS.map(segment => (
<Badge <Badge
key={segment} key={segment}
variant={form.target.userSegments.includes(segment) ? 'default' : 'outline'} variant={form.target.userSegments.includes(segment) ? 'default' : 'outline'}
@ -337,8 +338,8 @@ export default function BroadcastEditorPage() {
target: { target: {
...form.target, ...form.target,
userSegments: form.target.userSegments.includes(segment) userSegments: form.target.userSegments.includes(segment)
? form.target.userSegments.filter((s) => s !== segment) ? form.target.userSegments.filter(s => s !== segment)
: [...form.target.userSegments, segment], : [...form.target.userSegments, segment],
}, },
}) })
} }
@ -355,7 +356,7 @@ export default function BroadcastEditorPage() {
<Input <Input
id="countryCodes" id="countryCodes"
value={form.target.countryCodes} value={form.target.countryCodes}
onChange={(e) => onChange={e =>
setForm({ setForm({
...form, ...form,
target: { ...form.target, countryCodes: e.target.value }, target: { ...form.target, countryCodes: e.target.value },
@ -372,7 +373,7 @@ export default function BroadcastEditorPage() {
min={1} min={1}
max={100} max={100}
value={form.target.percentageRollout} value={form.target.percentageRollout}
onChange={(e) => onChange={e =>
setForm({ setForm({
...form, ...form,
target: { target: {
@ -391,7 +392,7 @@ export default function BroadcastEditorPage() {
<Input <Input
id="appVersionMin" id="appVersionMin"
value={form.target.appVersionMin} value={form.target.appVersionMin}
onChange={(e) => onChange={e =>
setForm({ setForm({
...form, ...form,
target: { ...form.target, appVersionMin: e.target.value }, target: { ...form.target, appVersionMin: e.target.value },
@ -405,7 +406,7 @@ export default function BroadcastEditorPage() {
<Input <Input
id="appVersionMax" id="appVersionMax"
value={form.target.appVersionMax} value={form.target.appVersionMax}
onChange={(e) => onChange={e =>
setForm({ setForm({
...form, ...form,
target: { ...form.target, appVersionMax: e.target.value }, target: { ...form.target, appVersionMax: e.target.value },
@ -421,7 +422,7 @@ export default function BroadcastEditorPage() {
<Input <Input
id="specificUserIds" id="specificUserIds"
value={form.target.specificUserIds} value={form.target.specificUserIds}
onChange={(e) => onChange={e =>
setForm({ setForm({
...form, ...form,
target: { ...form.target, specificUserIds: e.target.value }, target: { ...form.target, specificUserIds: e.target.value },
@ -447,7 +448,9 @@ export default function BroadcastEditorPage() {
</Button> </Button>
{reachEstimate && ( {reachEstimate && (
<div className="text-sm"> <div className="text-sm">
<span className="font-medium">{reachEstimate.estimatedCount.toLocaleString()}</span>{' '} <span className="font-medium">
{reachEstimate.estimatedCount.toLocaleString()}
</span>{' '}
users targeted users targeted
</div> </div>
)} )}
@ -460,23 +463,21 @@ export default function BroadcastEditorPage() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Delivery Channels</CardTitle> <CardTitle>Delivery Channels</CardTitle>
<CardDescription> <CardDescription>Select how to deliver this broadcast</CardDescription>
Select how to deliver this broadcast
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-4">
{CHANNELS.map((channel) => ( {CHANNELS.map(channel => (
<div key={channel.id} className="flex items-start space-x-3"> <div key={channel.id} className="flex items-start space-x-3">
<Checkbox <Checkbox
id={channel.id} id={channel.id}
checked={form.channels.includes(channel.id)} checked={form.channels.includes(channel.id)}
onCheckedChange={(checked) => onCheckedChange={checked =>
setForm({ setForm({
...form, ...form,
channels: checked channels: checked
? [...form.channels, channel.id] ? [...form.channels, channel.id]
: form.channels.filter((c) => c !== channel.id), : form.channels.filter(c => c !== channel.id),
}) })
} }
/> />
@ -484,9 +485,7 @@ export default function BroadcastEditorPage() {
<Label htmlFor={channel.id} className="font-medium"> <Label htmlFor={channel.id} className="font-medium">
{channel.label} {channel.label}
</Label> </Label>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">{channel.description}</p>
{channel.description}
</p>
</div> </div>
</div> </div>
))} ))}

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { import {
apiListBroadcasts, apiListBroadcasts,
apiDeleteBroadcast, apiDeleteBroadcast,
@ -10,13 +10,7 @@ import {
type ApiBroadcast, type ApiBroadcast,
} from '@/lib/api'; } from '@/lib/api';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { import {
Table, Table,
TableBody, TableBody,
@ -60,11 +54,7 @@ export default function BroadcastsPage() {
const [deleteDialog, setDeleteDialog] = useState<ApiBroadcast | null>(null); const [deleteDialog, setDeleteDialog] = useState<ApiBroadcast | null>(null);
const { toast } = useToast(); const { toast } = useToast();
useEffect(() => { const loadBroadcasts = useCallback(async () => {
loadBroadcasts();
}, []);
async function loadBroadcasts() {
try { try {
setLoading(true); setLoading(true);
const { data, error } = await apiListBroadcasts(); const { data, error } = await apiListBroadcasts();
@ -79,7 +69,11 @@ export default function BroadcastsPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }, [toast]);
useEffect(() => {
void loadBroadcasts();
}, [loadBroadcasts]);
async function handleDelete(broadcast: ApiBroadcast) { async function handleDelete(broadcast: ApiBroadcast) {
try { try {
@ -143,7 +137,7 @@ export default function BroadcastsPage() {
} }
const filteredBroadcasts = broadcasts.filter( const filteredBroadcasts = broadcasts.filter(
(b) => b =>
b.title.toLowerCase().includes(filter.toLowerCase()) || b.title.toLowerCase().includes(filter.toLowerCase()) ||
b.body.toLowerCase().includes(filter.toLowerCase()) b.body.toLowerCase().includes(filter.toLowerCase())
); );
@ -181,14 +175,12 @@ export default function BroadcastsPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<CardTitle>All Broadcasts</CardTitle> <CardTitle>All Broadcasts</CardTitle>
<CardDescription> <CardDescription>{broadcasts.length} total broadcasts</CardDescription>
{broadcasts.length} total broadcasts
</CardDescription>
</div> </div>
<Input <Input
placeholder="Filter broadcasts..." placeholder="Filter broadcasts..."
value={filter} value={filter}
onChange={(e) => setFilter(e.target.value)} onChange={e => setFilter(e.target.value)}
className="w-64" className="w-64"
/> />
</div> </div>
@ -207,7 +199,7 @@ export default function BroadcastsPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{filteredBroadcasts.map((broadcast) => ( {filteredBroadcasts.map(broadcast => (
<TableRow key={broadcast.id}> <TableRow key={broadcast.id}>
<TableCell> <TableCell>
<div className="font-medium">{broadcast.title}</div> <div className="font-medium">{broadcast.title}</div>
@ -216,13 +208,11 @@ export default function BroadcastsPage() {
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge className={statusColors[broadcast.status]}> <Badge className={statusColors[broadcast.status]}>{broadcast.status}</Badge>
{broadcast.status}
</Badge>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex gap-1"> <div className="flex gap-1">
{broadcast.channels.map((ch) => ( {broadcast.channels.map(ch => (
<Badge key={ch} variant="outline"> <Badge key={ch} variant="outline">
{ch} {ch}
</Badge> </Badge>
@ -248,9 +238,7 @@ export default function BroadcastsPage() {
</div> </div>
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>{new Date(broadcast.createdAt).toLocaleDateString()}</TableCell>
{new Date(broadcast.createdAt).toLocaleDateString()}
</TableCell>
<TableCell> <TableCell>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@ -280,9 +268,7 @@ export default function BroadcastsPage() {
Pause Pause
</DropdownMenuItem> </DropdownMenuItem>
)} )}
<DropdownMenuItem <DropdownMenuItem onClick={() => handleClone(broadcast, 'treatment')}>
onClick={() => handleClone(broadcast, 'treatment')}
>
<Copy className="mr-2 h-4 w-4" /> <Copy className="mr-2 h-4 w-4" />
Clone (A/B Test) Clone (A/B Test)
</DropdownMenuItem> </DropdownMenuItem>
@ -315,8 +301,8 @@ export default function BroadcastsPage() {
<DialogHeader> <DialogHeader>
<DialogTitle>Delete Broadcast</DialogTitle> <DialogTitle>Delete Broadcast</DialogTitle>
<DialogDescription> <DialogDescription>
Are you sure you want to delete &quot;{deleteDialog?.title}&quot;? This action Are you sure you want to delete &quot;{deleteDialog?.title}&quot;? This action cannot
cannot be undone. be undone.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>

View File

@ -1,12 +1,11 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Separator } from '@/components/ui/separator';
import { import {
Select, Select,
SelectContent, SelectContent,
@ -28,7 +27,6 @@ import {
Download, Download,
StopCircle, StopCircle,
Plus, Plus,
MoreHorizontal,
Calendar, Calendar,
User, User,
Smartphone, Smartphone,
@ -77,22 +75,26 @@ export default function SessionDetailPage() {
return localStorage.getItem('admin_access_token'); return localStorage.getItem('admin_access_token');
}; };
const client = createDiagnosticsClient({ const client = useMemo(
baseUrl: process.env.NEXT_PUBLIC_PLATFORM_SERVICE_URL || 'http://localhost:4003', () =>
productId: 'lysnrai', createDiagnosticsClient({
getAuthToken, baseUrl: process.env.NEXT_PUBLIC_PLATFORM_SERVICE_URL || 'http://localhost:4003',
}); productId: 'lysnrai',
getAuthToken,
}),
[]
);
const fetchSession = async () => { const fetchSession = useCallback(async () => {
try { try {
const data = await client.getSession(sessionId); const data = await client.getSession(sessionId);
setSession(data); setSession(data);
} catch (error) { } catch (error) {
console.error('Failed to fetch session:', error); console.error('Failed to fetch session:', error);
} }
}; }, [client, sessionId]);
const fetchTraces = async () => { const fetchTraces = useCallback(async () => {
try { try {
const result = await client.getTraces({ const result = await client.getTraces({
sessionId, sessionId,
@ -103,9 +105,9 @@ export default function SessionDetailPage() {
} catch (error) { } catch (error) {
console.error('Failed to fetch traces:', error); console.error('Failed to fetch traces:', error);
} }
}; }, [client, sessionId]);
const fetchLogs = async () => { const fetchLogs = useCallback(async () => {
try { try {
const result = await client.getLogs({ const result = await client.getLogs({
sessionId, sessionId,
@ -120,7 +122,7 @@ export default function SessionDetailPage() {
} catch (error) { } catch (error) {
console.error('Failed to fetch logs:', error); console.error('Failed to fetch logs:', error);
} }
}; }, [client, logLevelFilter, sessionId]);
useEffect(() => { useEffect(() => {
const loadData = async () => { const loadData = async () => {
@ -129,9 +131,8 @@ export default function SessionDetailPage() {
await Promise.all([fetchTraces(), fetchLogs()]); await Promise.all([fetchTraces(), fetchLogs()]);
setLoading(false); setLoading(false);
}; };
loadData(); void loadData();
// eslint-disable-next-line react-hooks/exhaustive-deps }, [activeTab, fetchLogs, fetchSession, fetchTraces]);
}, [sessionId, activeTab, logLevelFilter]);
const handleExtend = async () => { const handleExtend = async () => {
if (!session) return; if (!session) return;
@ -139,7 +140,7 @@ export default function SessionDetailPage() {
await client.updateSession(sessionId, { await client.updateSession(sessionId, {
maxDurationMinutes: session.maxDurationMinutes + 30, maxDurationMinutes: session.maxDurationMinutes + 30,
}); });
fetchSession(); void fetchSession();
} catch (error) { } catch (error) {
console.error('Failed to extend session:', error); console.error('Failed to extend session:', error);
} }
@ -148,7 +149,7 @@ export default function SessionDetailPage() {
const handleStop = async () => { const handleStop = async () => {
try { try {
await client.cancelSession(sessionId, 'Stopped by admin'); await client.cancelSession(sessionId, 'Stopped by admin');
fetchSession(); void fetchSession();
} catch (error) { } catch (error) {
console.error('Failed to stop session:', error); console.error('Failed to stop session:', error);
} }

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@ -31,7 +31,7 @@ import {
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Plus, Search, RefreshCw, MoreHorizontal } from 'lucide-react'; import { MoreHorizontal, Plus, RefreshCw, Search } from 'lucide-react';
import { import {
createDiagnosticsClient, createDiagnosticsClient,
type DebugSession, type DebugSession,
@ -71,13 +71,17 @@ export default function DebugSessionsPage() {
screenshotOnError: true, screenshotOnError: true,
}); });
const client = createDiagnosticsClient({ const client = useMemo(
baseUrl: process.env.NEXT_PUBLIC_PLATFORM_SERVICE_URL || 'http://localhost:4003', () =>
productId: 'lysnrai', createDiagnosticsClient({
getAuthToken, baseUrl: process.env.NEXT_PUBLIC_PLATFORM_SERVICE_URL || 'http://localhost:4003',
}); productId: 'lysnrai',
getAuthToken,
}),
[]
);
const fetchSessions = async () => { const fetchSessions = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const result = await client.querySessions({ const result = await client.querySessions({
@ -90,14 +94,16 @@ export default function DebugSessionsPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, [client, statusFilter]);
useEffect(() => { useEffect(() => {
fetchSessions(); void fetchSessions();
// Auto-refresh every 5 seconds // Auto-refresh every 5 seconds
const interval = setInterval(fetchSessions, 5000); const interval = setInterval(() => {
void fetchSessions();
}, 5000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [statusFilter]); }, [fetchSessions]);
const handleCreateSession = async () => { const handleCreateSession = async () => {
try { try {

View File

@ -1,12 +1,18 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { import {
Activity, Activity,
TrendingUp, TrendingUp,
@ -21,7 +27,11 @@ import {
RefreshCw, RefreshCw,
} from 'lucide-react'; } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { getProductHealth, type ProductHealth, type HealthDimension } from '@/lib/predictive-client'; import {
getProductHealth,
type ProductHealth,
type HealthDimension,
} from '@/lib/predictive-client';
interface HealthScoreCardProps { interface HealthScoreCardProps {
title: string; title: string;
@ -64,11 +74,13 @@ function HealthScoreCard({ title, dimension, icon }: HealthScoreCardProps) {
{getTrendIcon(dimension.trend)} {getTrendIcon(dimension.trend)}
<span className="text-xs text-muted-foreground capitalize">{dimension.trend}</span> <span className="text-xs text-muted-foreground capitalize">{dimension.trend}</span>
</div> </div>
{Object.entries(dimension.metrics).slice(0, 2).map(([key, value]) => ( {Object.entries(dimension.metrics)
<div key={key} className="mt-1 text-xs text-muted-foreground"> .slice(0, 2)
{key}: {typeof value === 'number' ? value.toFixed(1) : value} .map(([key, value]) => (
</div> <div key={key} className="mt-1 text-xs text-muted-foreground">
))} {key}: {typeof value === 'number' ? value.toFixed(1) : value}
</div>
))}
</CardContent> </CardContent>
</Card> </Card>
); );
@ -80,11 +92,7 @@ export default function HealthDashboardPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedProduct, setSelectedProduct] = useState<string>('all'); const [selectedProduct, setSelectedProduct] = useState<string>('all');
useEffect(() => { const loadHealthData = useCallback(async () => {
loadHealthData();
}, [selectedProduct]);
async function loadHealthData() {
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
@ -95,7 +103,11 @@ export default function HealthDashboardPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }, [selectedProduct]);
useEffect(() => {
void loadHealthData();
}, [loadHealthData]);
const getStatusBadge = (status: string) => { const getStatusBadge = (status: string) => {
switch (status) { switch (status) {
@ -182,7 +194,8 @@ export default function HealthDashboardPage() {
Overall Health Score Overall Health Score
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
{latestHealth.productId} Last updated: {new Date(latestHealth.date).toLocaleDateString()} {latestHealth.productId} Last updated:{' '}
{new Date(latestHealth.date).toLocaleDateString()}
</CardDescription> </CardDescription>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@ -198,14 +211,22 @@ export default function HealthDashboardPage() {
<div className="flex items-center gap-4 text-sm"> <div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="text-muted-foreground">vs 7d:</span> <span className="text-muted-foreground">vs 7d:</span>
<span className={latestHealth.vsBaseline7Day >= 0 ? 'text-green-500' : 'text-red-500'}> <span
{latestHealth.vsBaseline7Day >= 0 ? '+' : ''}{latestHealth.vsBaseline7Day.toFixed(1)}% className={latestHealth.vsBaseline7Day >= 0 ? 'text-green-500' : 'text-red-500'}
>
{latestHealth.vsBaseline7Day >= 0 ? '+' : ''}
{latestHealth.vsBaseline7Day.toFixed(1)}%
</span> </span>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="text-muted-foreground">vs 30d:</span> <span className="text-muted-foreground">vs 30d:</span>
<span className={latestHealth.vsBaseline30Day >= 0 ? 'text-green-500' : 'text-red-500'}> <span
{latestHealth.vsBaseline30Day >= 0 ? '+' : ''}{latestHealth.vsBaseline30Day.toFixed(1)}% className={
latestHealth.vsBaseline30Day >= 0 ? 'text-green-500' : 'text-red-500'
}
>
{latestHealth.vsBaseline30Day >= 0 ? '+' : ''}
{latestHealth.vsBaseline30Day.toFixed(1)}%
</span> </span>
</div> </div>
</div> </div>
@ -213,16 +234,34 @@ export default function HealthDashboardPage() {
<div className="mt-4 flex items-center gap-8 text-sm"> <div className="mt-4 flex items-center gap-8 text-sm">
<div> <div>
<span className="text-muted-foreground">Next 7 days:</span>{' '} <span className="text-muted-foreground">Next 7 days:</span>{' '}
<span className="font-medium">{latestHealth.forecasts.next7Days.expectedHealthScore}</span> <span className="font-medium">
{latestHealth.forecasts.next7Days.expectedHealthScore}
</span>
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{' '}(±{((latestHealth.forecasts.next7Days.confidenceInterval[1] - latestHealth.forecasts.next7Days.confidenceInterval[0]) / 2).toFixed(1)}) {' '}
(±
{(
(latestHealth.forecasts.next7Days.confidenceInterval[1] -
latestHealth.forecasts.next7Days.confidenceInterval[0]) /
2
).toFixed(1)}
)
</span> </span>
</div> </div>
<div> <div>
<span className="text-muted-foreground">Next 30 days:</span>{' '} <span className="text-muted-foreground">Next 30 days:</span>{' '}
<span className="font-medium">{latestHealth.forecasts.next30Days.expectedHealthScore}</span> <span className="font-medium">
{latestHealth.forecasts.next30Days.expectedHealthScore}
</span>
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{' '}(±{((latestHealth.forecasts.next30Days.confidenceInterval[1] - latestHealth.forecasts.next30Days.confidenceInterval[0]) / 2).toFixed(1)}) {' '}
(±
{(
(latestHealth.forecasts.next30Days.confidenceInterval[1] -
latestHealth.forecasts.next30Days.confidenceInterval[0]) /
2
).toFixed(1)}
)
</span> </span>
</div> </div>
</div> </div>
@ -276,11 +315,15 @@ export default function HealthDashboardPage() {
<CardContent> <CardContent>
<div className="space-y-2"> <div className="space-y-2">
{latestHealth.anomalies.map((anomaly, idx) => ( {latestHealth.anomalies.map((anomaly, idx) => (
<div key={idx} className="flex items-center justify-between rounded-lg border p-3"> <div
key={idx}
className="flex items-center justify-between rounded-lg border p-3"
>
<div> <div>
<div className="font-medium">{anomaly.metric}</div> <div className="font-medium">{anomaly.metric}</div>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
Expected: {anomaly.expectedValue.toFixed(2)} Actual: {anomaly.actualValue.toFixed(2)} Expected: {anomaly.expectedValue.toFixed(2)} Actual:{' '}
{anomaly.actualValue.toFixed(2)}
</div> </div>
{anomaly.suggestedCause && ( {anomaly.suggestedCause && (
<div className="text-xs text-muted-foreground mt-1"> <div className="text-xs text-muted-foreground mt-1">
@ -289,7 +332,8 @@ export default function HealthDashboardPage() {
)} )}
</div> </div>
<Badge variant={anomaly.severity === 'critical' ? 'destructive' : 'default'}> <Badge variant={anomaly.severity === 'critical' ? 'destructive' : 'default'}>
{anomaly.deviationPercent > 0 ? '+' : ''}{anomaly.deviationPercent.toFixed(1)}% {anomaly.deviationPercent > 0 ? '+' : ''}
{anomaly.deviationPercent.toFixed(1)}%
</Badge> </Badge>
</div> </div>
))} ))}

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { import {
Activity, Activity,
CheckCircle, CheckCircle,
@ -126,22 +126,25 @@ export default function OpsPage() {
const [pendingRestart, setPendingRestart] = useState<string | null>(null); const [pendingRestart, setPendingRestart] = useState<string | null>(null);
const [pendingDelete, setPendingDelete] = useState<string | null>(null); const [pendingDelete, setPendingDelete] = useState<string | null>(null);
const fetchValkey = async (pattern = valkeyPattern, limit = valkeyLimit) => { const fetchValkey = useCallback(
try { async (pattern = valkeyPattern, limit = valkeyLimit) => {
setValkeyLoading(true); try {
const params = new URLSearchParams({ setValkeyLoading(true);
pattern, const params = new URLSearchParams({
limit, pattern,
}); limit,
const res = await fetch(`/api/ops/valkey?${params.toString()}`); });
if (!res.ok) throw new Error('Failed to fetch Valkey state'); const res = await fetch(`/api/ops/valkey?${params.toString()}`);
setValkey(await res.json()); if (!res.ok) throw new Error('Failed to fetch Valkey state');
} finally { setValkey(await res.json());
setValkeyLoading(false); } finally {
} setValkeyLoading(false);
}; }
},
[valkeyLimit, valkeyPattern]
);
const fetchStatus = async () => { const fetchStatus = useCallback(async () => {
try { try {
setLoading(true); setLoading(true);
const [statusRes, inventoryRes] = await Promise.all([ const [statusRes, inventoryRes] = await Promise.all([
@ -152,7 +155,10 @@ export default function OpsPage() {
if (!statusRes.ok) throw new Error('Failed to fetch status'); if (!statusRes.ok) throw new Error('Failed to fetch status');
if (!inventoryRes.ok) throw new Error('Failed to fetch inventory'); if (!inventoryRes.ok) throw new Error('Failed to fetch inventory');
const [statusJson, inventoryJson] = await Promise.all([statusRes.json(), inventoryRes.json()]); const [statusJson, inventoryJson] = await Promise.all([
statusRes.json(),
inventoryRes.json(),
]);
setData(statusJson); setData(statusJson);
setInventory(inventoryJson); setInventory(inventoryJson);
setLastUpdated(new Date()); setLastUpdated(new Date());
@ -164,7 +170,7 @@ export default function OpsPage() {
setLoading(false); setLoading(false);
setCountdown(10); setCountdown(10);
} }
}; }, [fetchValkey]);
const restartService = async (serviceId: string) => { const restartService = async (serviceId: string) => {
try { try {
@ -232,11 +238,11 @@ export default function OpsPage() {
}; };
useEffect(() => { useEffect(() => {
fetchStatus(); void fetchStatus();
const timer = setInterval(() => { const timer = setInterval(() => {
setCountdown(prev => { setCountdown(prev => {
if (prev <= 1) { if (prev <= 1) {
fetchStatus(); void fetchStatus();
return 10; return 10;
} }
return prev - 1; return prev - 1;
@ -244,7 +250,7 @@ export default function OpsPage() {
}, 1000); }, 1000);
return () => clearInterval(timer); return () => clearInterval(timer);
}, []); }, [fetchStatus]);
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
switch (status) { switch (status) {
@ -460,7 +466,9 @@ export default function OpsPage() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Service Inventory</CardTitle> <CardTitle>Service Inventory</CardTitle>
<CardDescription>Live Docker-managed stack reachable from the admin container.</CardDescription> <CardDescription>
Live Docker-managed stack reachable from the admin container.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Table> <Table>
@ -523,7 +531,9 @@ export default function OpsPage() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>VM Tooling</CardTitle> <CardTitle>VM Tooling</CardTitle>
<CardDescription>Host-level tools and mounted config that support the stack.</CardDescription> <CardDescription>
Host-level tools and mounted config that support the stack.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Table> <Table>
@ -570,9 +580,7 @@ export default function OpsPage() {
</Card> </Card>
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground"> <CardTitle className="text-sm font-medium text-muted-foreground">DB Size</CardTitle>
DB Size
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-3xl font-bold">{valkey?.summary.dbsize ?? 0}</div> <div className="text-3xl font-bold">{valkey?.summary.dbsize ?? 0}</div>
@ -687,7 +695,9 @@ export default function OpsPage() {
<TableCell> <TableCell>
<Badge variant="outline">{item.type}</Badge> <Badge variant="outline">{item.type}</Badge>
</TableCell> </TableCell>
<TableCell>{item.ttlSeconds < 0 ? 'persistent' : `${item.ttlSeconds}s`}</TableCell> <TableCell>
{item.ttlSeconds < 0 ? 'persistent' : `${item.ttlSeconds}s`}
</TableCell>
<TableCell>{item.size ?? 'n/a'}</TableCell> <TableCell>{item.size ?? 'n/a'}</TableCell>
<TableCell className="max-w-[360px] break-all font-mono text-xs"> <TableCell className="max-w-[360px] break-all font-mono text-xs">
{item.preview ?? 'No preview'} {item.preview ?? 'No preview'}
@ -788,7 +798,8 @@ export default function OpsPage() {
Health Review Health Review
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Live status for dashboards, core services, observability, ingress, and shared infrastructure. Live status for dashboards, core services, observability, ingress, and shared
infrastructure.
</p> </p>
</div> </div>
<div className="rounded-lg border p-4"> <div className="rounded-lg border p-4">