chore(admin-web): reduce high-signal lint warnings
This commit is contained in:
parent
631784e551
commit
f8949d230f
@ -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">
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -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 "{deleteDialog?.title}"? This action
|
||||
cannot be undone.
|
||||
Are you sure you want to delete "{deleteDialog?.title}"? This action cannot
|
||||
be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -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">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user