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