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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,12 +1,18 @@
'use client';
import { useState, useEffect } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription } from '@/components/ui/alert';
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 {
Activity,
TrendingUp,
@ -21,7 +27,11 @@ import {
RefreshCw,
} from 'lucide-react';
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 {
title: string;
@ -64,11 +74,13 @@ function HealthScoreCard({ title, dimension, icon }: HealthScoreCardProps) {
{getTrendIcon(dimension.trend)}
<span className="text-xs text-muted-foreground capitalize">{dimension.trend}</span>
</div>
{Object.entries(dimension.metrics).slice(0, 2).map(([key, value]) => (
<div key={key} className="mt-1 text-xs text-muted-foreground">
{key}: {typeof value === 'number' ? value.toFixed(1) : value}
</div>
))}
{Object.entries(dimension.metrics)
.slice(0, 2)
.map(([key, value]) => (
<div key={key} className="mt-1 text-xs text-muted-foreground">
{key}: {typeof value === 'number' ? value.toFixed(1) : value}
</div>
))}
</CardContent>
</Card>
);
@ -80,11 +92,7 @@ export default function HealthDashboardPage() {
const [error, setError] = useState<string | null>(null);
const [selectedProduct, setSelectedProduct] = useState<string>('all');
useEffect(() => {
loadHealthData();
}, [selectedProduct]);
async function loadHealthData() {
const loadHealthData = useCallback(async () => {
try {
setLoading(true);
setError(null);
@ -95,7 +103,11 @@ export default function HealthDashboardPage() {
} finally {
setLoading(false);
}
}
}, [selectedProduct]);
useEffect(() => {
void loadHealthData();
}, [loadHealthData]);
const getStatusBadge = (status: string) => {
switch (status) {
@ -182,7 +194,8 @@ export default function HealthDashboardPage() {
Overall Health Score
</CardTitle>
<CardDescription>
{latestHealth.productId} Last updated: {new Date(latestHealth.date).toLocaleDateString()}
{latestHealth.productId} Last updated:{' '}
{new Date(latestHealth.date).toLocaleDateString()}
</CardDescription>
</div>
<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-1">
<span className="text-muted-foreground">vs 7d:</span>
<span className={latestHealth.vsBaseline7Day >= 0 ? 'text-green-500' : 'text-red-500'}>
{latestHealth.vsBaseline7Day >= 0 ? '+' : ''}{latestHealth.vsBaseline7Day.toFixed(1)}%
<span
className={latestHealth.vsBaseline7Day >= 0 ? 'text-green-500' : 'text-red-500'}
>
{latestHealth.vsBaseline7Day >= 0 ? '+' : ''}
{latestHealth.vsBaseline7Day.toFixed(1)}%
</span>
</div>
<div className="flex items-center gap-1">
<span className="text-muted-foreground">vs 30d:</span>
<span className={latestHealth.vsBaseline30Day >= 0 ? 'text-green-500' : 'text-red-500'}>
{latestHealth.vsBaseline30Day >= 0 ? '+' : ''}{latestHealth.vsBaseline30Day.toFixed(1)}%
<span
className={
latestHealth.vsBaseline30Day >= 0 ? 'text-green-500' : 'text-red-500'
}
>
{latestHealth.vsBaseline30Day >= 0 ? '+' : ''}
{latestHealth.vsBaseline30Day.toFixed(1)}%
</span>
</div>
</div>
@ -213,16 +234,34 @@ export default function HealthDashboardPage() {
<div className="mt-4 flex items-center gap-8 text-sm">
<div>
<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">
{' '}(±{((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>
</div>
<div>
<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">
{' '}(±{((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>
</div>
</div>
@ -276,11 +315,15 @@ export default function HealthDashboardPage() {
<CardContent>
<div className="space-y-2">
{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 className="font-medium">{anomaly.metric}</div>
<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>
{anomaly.suggestedCause && (
<div className="text-xs text-muted-foreground mt-1">
@ -289,7 +332,8 @@ export default function HealthDashboardPage() {
)}
</div>
<Badge variant={anomaly.severity === 'critical' ? 'destructive' : 'default'}>
{anomaly.deviationPercent > 0 ? '+' : ''}{anomaly.deviationPercent.toFixed(1)}%
{anomaly.deviationPercent > 0 ? '+' : ''}
{anomaly.deviationPercent.toFixed(1)}%
</Badge>
</div>
))}

View File

@ -1,6 +1,6 @@
'use client';
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import {
Activity,
CheckCircle,
@ -126,22 +126,25 @@ export default function OpsPage() {
const [pendingRestart, setPendingRestart] = useState<string | null>(null);
const [pendingDelete, setPendingDelete] = useState<string | null>(null);
const fetchValkey = async (pattern = valkeyPattern, limit = valkeyLimit) => {
try {
setValkeyLoading(true);
const params = new URLSearchParams({
pattern,
limit,
});
const res = await fetch(`/api/ops/valkey?${params.toString()}`);
if (!res.ok) throw new Error('Failed to fetch Valkey state');
setValkey(await res.json());
} finally {
setValkeyLoading(false);
}
};
const fetchValkey = useCallback(
async (pattern = valkeyPattern, limit = valkeyLimit) => {
try {
setValkeyLoading(true);
const params = new URLSearchParams({
pattern,
limit,
});
const res = await fetch(`/api/ops/valkey?${params.toString()}`);
if (!res.ok) throw new Error('Failed to fetch Valkey state');
setValkey(await res.json());
} finally {
setValkeyLoading(false);
}
},
[valkeyLimit, valkeyPattern]
);
const fetchStatus = async () => {
const fetchStatus = useCallback(async () => {
try {
setLoading(true);
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 (!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);
setInventory(inventoryJson);
setLastUpdated(new Date());
@ -164,7 +170,7 @@ export default function OpsPage() {
setLoading(false);
setCountdown(10);
}
};
}, [fetchValkey]);
const restartService = async (serviceId: string) => {
try {
@ -232,11 +238,11 @@ export default function OpsPage() {
};
useEffect(() => {
fetchStatus();
void fetchStatus();
const timer = setInterval(() => {
setCountdown(prev => {
if (prev <= 1) {
fetchStatus();
void fetchStatus();
return 10;
}
return prev - 1;
@ -244,7 +250,7 @@ export default function OpsPage() {
}, 1000);
return () => clearInterval(timer);
}, []);
}, [fetchStatus]);
const getStatusColor = (status: string) => {
switch (status) {
@ -460,7 +466,9 @@ export default function OpsPage() {
<Card>
<CardHeader>
<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>
<CardContent>
<Table>
@ -523,7 +531,9 @@ export default function OpsPage() {
<Card>
<CardHeader>
<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>
<CardContent>
<Table>
@ -570,9 +580,7 @@ export default function OpsPage() {
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
DB Size
</CardTitle>
<CardTitle className="text-sm font-medium text-muted-foreground">DB Size</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{valkey?.summary.dbsize ?? 0}</div>
@ -687,7 +695,9 @@ export default function OpsPage() {
<TableCell>
<Badge variant="outline">{item.type}</Badge>
</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 className="max-w-[360px] break-all font-mono text-xs">
{item.preview ?? 'No preview'}
@ -788,7 +798,8 @@ export default function OpsPage() {
Health Review
</div>
<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>
</div>
<div className="rounded-lg border p-4">