mirror of
https://github.com/KLogicHQ/nats-console.git
synced 2026-10-07 13:40:52 +00:00
add alert dialog
add confirmation dialog fix clickhouse metrics table.
This commit is contained in:
@@ -2,6 +2,11 @@ import { createClient, ClickHouseClient } from '@clickhouse/client';
|
||||
import { config } from '../config/index';
|
||||
import type { StreamMetrics, ConsumerMetrics, ClusterMetrics, AuditLog } from '../../../shared/src/index';
|
||||
|
||||
// Format timestamp for ClickHouse DateTime64(3)
|
||||
function formatTimestamp(date: Date): string {
|
||||
return date.toISOString().replace('T', ' ').replace('Z', '');
|
||||
}
|
||||
|
||||
let client: ClickHouseClient | null = null;
|
||||
|
||||
export function getClickHouseClient(): ClickHouseClient {
|
||||
@@ -32,7 +37,7 @@ export async function insertStreamMetrics(metrics: StreamMetrics[]): Promise<voi
|
||||
values: metrics.map((m) => ({
|
||||
cluster_id: m.clusterId,
|
||||
stream_name: m.streamName,
|
||||
timestamp: m.timestamp.toISOString(),
|
||||
timestamp: formatTimestamp(m.timestamp),
|
||||
messages_total: m.messagesTotal,
|
||||
bytes_total: m.bytesTotal,
|
||||
messages_rate: m.messagesRate,
|
||||
@@ -113,7 +118,7 @@ export async function insertConsumerMetrics(metrics: ConsumerMetrics[]): Promise
|
||||
cluster_id: m.clusterId,
|
||||
stream_name: m.streamName,
|
||||
consumer_name: m.consumerName,
|
||||
timestamp: m.timestamp.toISOString(),
|
||||
timestamp: formatTimestamp(m.timestamp),
|
||||
pending_count: m.pendingCount,
|
||||
ack_pending: m.ackPending,
|
||||
redelivered: m.redelivered,
|
||||
@@ -196,7 +201,7 @@ export async function insertClusterMetrics(metrics: ClusterMetrics[]): Promise<v
|
||||
cluster_id: m.clusterId,
|
||||
server_id: m.serverId,
|
||||
server_name: m.serverName,
|
||||
timestamp: m.timestamp.toISOString(),
|
||||
timestamp: formatTimestamp(m.timestamp),
|
||||
cpu_percent: m.cpuPercent,
|
||||
memory_bytes: m.memoryBytes,
|
||||
connections: m.connections,
|
||||
@@ -223,7 +228,7 @@ export async function insertAuditLog(log: Omit<AuditLog, 'id'>): Promise<void> {
|
||||
org_id: log.orgId,
|
||||
user_id: log.userId,
|
||||
user_email: log.userEmail,
|
||||
timestamp: log.timestamp.toISOString(),
|
||||
timestamp: formatTimestamp(log.timestamp),
|
||||
action: log.action,
|
||||
resource_type: log.resourceType,
|
||||
resource_id: log.resourceId,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Plus,
|
||||
Bell,
|
||||
@@ -9,54 +9,138 @@ import {
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
MoreVertical,
|
||||
Trash2,
|
||||
Edit,
|
||||
Power,
|
||||
PowerOff,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
interface AlertRule {
|
||||
id: string;
|
||||
name: string;
|
||||
clusterId: string | null;
|
||||
condition: {
|
||||
metric: string;
|
||||
operator: string;
|
||||
window: number;
|
||||
aggregation: string;
|
||||
};
|
||||
threshold: {
|
||||
value: number;
|
||||
type: string;
|
||||
};
|
||||
severity: string;
|
||||
channels: Array<{ type: string; config: any }>;
|
||||
isEnabled: boolean;
|
||||
cooldownMins: number;
|
||||
lastTriggeredAt: string | null;
|
||||
createdAt: string;
|
||||
cluster?: { id: string; name: string } | null;
|
||||
}
|
||||
|
||||
const defaultRule = {
|
||||
name: '',
|
||||
clusterId: null as string | null,
|
||||
condition: {
|
||||
metric: 'consumer_lag',
|
||||
operator: 'gt',
|
||||
window: 300,
|
||||
aggregation: 'avg',
|
||||
},
|
||||
threshold: {
|
||||
value: 1000,
|
||||
type: 'absolute',
|
||||
},
|
||||
severity: 'warning',
|
||||
channels: [{ type: 'email', config: {} }],
|
||||
isEnabled: true,
|
||||
cooldownMins: 5,
|
||||
};
|
||||
|
||||
export default function AlertsPage() {
|
||||
const [selectedCluster, setSelectedCluster] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState<'all' | 'active' | 'resolved'>('all');
|
||||
const queryClient = useQueryClient();
|
||||
const [filter, setFilter] = useState<'all' | 'enabled' | 'disabled'>('all');
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const [selectedRule, setSelectedRule] = useState<AlertRule | null>(null);
|
||||
const [formData, setFormData] = useState(defaultRule);
|
||||
|
||||
const { data: clustersData } = useQuery({
|
||||
queryKey: ['clusters'],
|
||||
queryFn: () => api.clusters.list(),
|
||||
});
|
||||
|
||||
const { data: alertsData, isLoading } = useQuery({
|
||||
queryKey: ['alerts', selectedCluster],
|
||||
queryFn: () => (selectedCluster ? api.alerts.list(selectedCluster) : null),
|
||||
enabled: !!selectedCluster,
|
||||
const { data: rulesData, isLoading, refetch } = useQuery({
|
||||
queryKey: ['alert-rules'],
|
||||
queryFn: () => api.alerts.listRules(),
|
||||
});
|
||||
|
||||
// Auto-select first cluster
|
||||
if (clustersData?.clusters?.length && !selectedCluster) {
|
||||
setSelectedCluster(clustersData.clusters[0].id);
|
||||
}
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: typeof defaultRule) => api.alerts.createRule(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['alert-rules'] });
|
||||
setIsCreateOpen(false);
|
||||
setFormData(defaultRule);
|
||||
},
|
||||
});
|
||||
|
||||
const filteredAlerts = alertsData?.alerts?.filter((alert: any) => {
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Partial<typeof defaultRule> }) =>
|
||||
api.alerts.updateRule(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['alert-rules'] });
|
||||
setIsEditOpen(false);
|
||||
setSelectedRule(null);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.alerts.deleteRule(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['alert-rules'] });
|
||||
setIsDeleteOpen(false);
|
||||
setSelectedRule(null);
|
||||
},
|
||||
});
|
||||
|
||||
const filteredRules = rulesData?.rules?.filter((rule: AlertRule) => {
|
||||
if (filter === 'all') return true;
|
||||
if (filter === 'active') return alert.status === 'firing' || alert.status === 'pending';
|
||||
if (filter === 'resolved') return alert.status === 'resolved';
|
||||
if (filter === 'enabled') return rule.isEnabled;
|
||||
if (filter === 'disabled') return !rule.isEnabled;
|
||||
return true;
|
||||
});
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'firing':
|
||||
return <AlertTriangle className="h-5 w-5 text-red-500" />;
|
||||
case 'pending':
|
||||
return <Clock className="h-5 w-5 text-yellow-500" />;
|
||||
case 'resolved':
|
||||
return <CheckCircle className="h-5 w-5 text-green-500" />;
|
||||
default:
|
||||
return <Bell className="h-5 w-5 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityBadge = (severity: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
critical: 'bg-red-100 text-red-700',
|
||||
@@ -70,7 +154,8 @@ export default function AlertsPage() {
|
||||
);
|
||||
};
|
||||
|
||||
const formatTimeAgo = (timestamp: string) => {
|
||||
const formatTimeAgo = (timestamp: string | null) => {
|
||||
if (!timestamp) return 'Never';
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
@@ -84,6 +169,48 @@ export default function AlertsPage() {
|
||||
return 'Just now';
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
createMutation.mutate(formData);
|
||||
};
|
||||
|
||||
const handleEdit = (rule: AlertRule) => {
|
||||
setSelectedRule(rule);
|
||||
setFormData({
|
||||
name: rule.name,
|
||||
clusterId: rule.clusterId,
|
||||
condition: rule.condition,
|
||||
threshold: rule.threshold,
|
||||
severity: rule.severity,
|
||||
channels: rule.channels,
|
||||
isEnabled: rule.isEnabled,
|
||||
cooldownMins: rule.cooldownMins,
|
||||
});
|
||||
setIsEditOpen(true);
|
||||
};
|
||||
|
||||
const handleUpdate = () => {
|
||||
if (!selectedRule) return;
|
||||
updateMutation.mutate({ id: selectedRule.id, data: formData });
|
||||
};
|
||||
|
||||
const handleToggleEnabled = async (rule: AlertRule) => {
|
||||
await updateMutation.mutateAsync({
|
||||
id: rule.id,
|
||||
data: { isEnabled: !rule.isEnabled },
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = (rule: AlertRule) => {
|
||||
setSelectedRule(rule);
|
||||
setIsDeleteOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (selectedRule) {
|
||||
deleteMutation.mutate(selectedRule.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -91,34 +218,30 @@ export default function AlertsPage() {
|
||||
<h1 className="text-3xl font-bold">Alerts</h1>
|
||||
<p className="text-muted-foreground">Monitor and manage alert rules</p>
|
||||
</div>
|
||||
<Button disabled={!selectedCluster}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Alert Rule
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="icon" onClick={() => refetch()}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button onClick={() => {
|
||||
setFormData(defaultRule);
|
||||
setIsCreateOpen(true);
|
||||
}}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create Rule
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<select
|
||||
className="h-10 rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
value={selectedCluster || ''}
|
||||
onChange={(e) => setSelectedCluster(e.target.value)}
|
||||
>
|
||||
<option value="">Select cluster...</option>
|
||||
{clustersData?.clusters?.map((cluster: any) => (
|
||||
<option key={cluster.id} value={cluster.id}>
|
||||
{cluster.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex rounded-md border">
|
||||
{(['all', 'active', 'resolved'] as const).map((f) => (
|
||||
{(['all', 'enabled', 'disabled'] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
||||
filter === f
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'hover:bg-muted'
|
||||
} ${f === 'all' ? 'rounded-l-md' : ''} ${f === 'resolved' ? 'rounded-r-md' : ''}`}
|
||||
} ${f === 'all' ? 'rounded-l-md' : ''} ${f === 'disabled' ? 'rounded-r-md' : ''}`}
|
||||
onClick={() => setFilter(f)}
|
||||
>
|
||||
{f.charAt(0).toUpperCase() + f.slice(1)}
|
||||
@@ -127,33 +250,23 @@ export default function AlertsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!selectedCluster && (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<Bell className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">Select a cluster</h3>
|
||||
<p className="text-muted-foreground">Choose a cluster to view alerts</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{selectedCluster && isLoading && (
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedCluster && filteredAlerts && filteredAlerts.length === 0 && (
|
||||
{!isLoading && (!filteredRules || filteredRules.length === 0) && (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<BellOff className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">No alerts found</h3>
|
||||
<h3 className="text-lg font-semibold mb-2">No alert rules found</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{filter !== 'all' ? `No ${filter} alerts` : 'Create your first alert rule to get started'}
|
||||
{filter !== 'all' ? `No ${filter} rules` : 'Create your first alert rule to get started'}
|
||||
</p>
|
||||
{filter === 'all' && (
|
||||
<Button>
|
||||
<Plus className="h-4 w-4" />
|
||||
<Button onClick={() => setIsCreateOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create Alert Rule
|
||||
</Button>
|
||||
)}
|
||||
@@ -161,93 +274,357 @@ export default function AlertsPage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{filteredAlerts && filteredAlerts.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{/* Alert Rules Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Alert Rules</CardTitle>
|
||||
<CardDescription>Configured alert rules for this cluster</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{filteredAlerts.map((alert: any) => (
|
||||
<div
|
||||
key={alert.id}
|
||||
className="flex items-center justify-between p-4 border rounded-lg hover:bg-muted/30"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
{getStatusIcon(alert.status)}
|
||||
<div>
|
||||
<h4 className="font-medium">{alert.name}</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{alert.condition} {alert.operator} {alert.threshold}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{getSeverityBadge(alert.severity)}
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{alert.lastTriggered ? formatTimeAgo(alert.lastTriggered) : 'Never triggered'}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{filteredRules && filteredRules.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Alert Rules</CardTitle>
|
||||
<CardDescription>{filteredRules.length} rule{filteredRules.length !== 1 ? 's' : ''} configured</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{filteredRules.map((rule: AlertRule) => (
|
||||
<div
|
||||
key={rule.id}
|
||||
className={`flex items-center justify-between p-4 border rounded-lg hover:bg-muted/30 ${
|
||||
!rule.isEnabled ? 'opacity-60' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
{rule.isEnabled ? (
|
||||
<Bell className="h-5 w-5 text-primary" />
|
||||
) : (
|
||||
<BellOff className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
<div>
|
||||
<h4 className="font-medium">{rule.name}</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{rule.condition.metric} {rule.condition.operator} {rule.threshold.value}
|
||||
{rule.cluster && ` • ${rule.cluster.name}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recent Alert Events */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Events</CardTitle>
|
||||
<CardDescription>Latest alert activity</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-4 p-3 bg-red-50 rounded-lg">
|
||||
<AlertTriangle className="h-5 w-5 text-red-500" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">Consumer lag exceeded threshold</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
ORDERS stream - lag-alert rule triggered
|
||||
</p>
|
||||
<div className="flex items-center gap-4">
|
||||
{getSeverityBadge(rule.severity)}
|
||||
<span className="text-sm text-muted-foreground min-w-[80px]">
|
||||
{formatTimeAgo(rule.lastTriggeredAt)}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleToggleEnabled(rule)}
|
||||
title={rule.isEnabled ? 'Disable' : 'Enable'}
|
||||
>
|
||||
{rule.isEnabled ? (
|
||||
<Power className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<PowerOff className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => handleEdit(rule)}>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => handleDelete(rule)}>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">2m ago</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 p-3 bg-green-50 rounded-lg">
|
||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">Alert resolved</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
EVENTS stream - throughput-alert back to normal
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">15m ago</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 p-3 bg-yellow-50 rounded-lg">
|
||||
<Clock className="h-5 w-5 text-yellow-500" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">Alert pending</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
LOGS stream - storage-alert approaching threshold
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">1h ago</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Create Alert Rule Dialog */}
|
||||
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Alert Rule</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure a new alert rule to monitor your NATS streams
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Name</label>
|
||||
<Input
|
||||
placeholder="e.g., High consumer lag alert"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Cluster (optional)</label>
|
||||
<Select
|
||||
value={formData.clusterId || 'all'}
|
||||
onValueChange={(v) => setFormData({ ...formData, clusterId: v === 'all' ? null : v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All clusters" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All clusters</SelectItem>
|
||||
{clustersData?.clusters?.map((cluster: any) => (
|
||||
<SelectItem key={cluster.id} value={cluster.id}>
|
||||
{cluster.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Metric</label>
|
||||
<Select
|
||||
value={formData.condition.metric}
|
||||
onValueChange={(v) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
condition: { ...formData.condition, metric: v },
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="consumer_lag">Consumer Lag</SelectItem>
|
||||
<SelectItem value="message_rate">Message Rate</SelectItem>
|
||||
<SelectItem value="stream_size">Stream Size</SelectItem>
|
||||
<SelectItem value="pending_count">Pending Count</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Operator</label>
|
||||
<Select
|
||||
value={formData.condition.operator}
|
||||
onValueChange={(v) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
condition: { ...formData.condition, operator: v },
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="gt">Greater than</SelectItem>
|
||||
<SelectItem value="lt">Less than</SelectItem>
|
||||
<SelectItem value="gte">Greater or equal</SelectItem>
|
||||
<SelectItem value="lte">Less or equal</SelectItem>
|
||||
<SelectItem value="eq">Equals</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Threshold</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.threshold.value}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
threshold: { ...formData.threshold, value: Number(e.target.value) },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Severity</label>
|
||||
<Select
|
||||
value={formData.severity}
|
||||
onValueChange={(v) => setFormData({ ...formData, severity: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="info">Info</SelectItem>
|
||||
<SelectItem value="warning">Warning</SelectItem>
|
||||
<SelectItem value="critical">Critical</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Cooldown (minutes)</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.cooldownMins}
|
||||
onChange={(e) => setFormData({ ...formData, cooldownMins: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsCreateOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={!formData.name || createMutation.isPending}>
|
||||
{createMutation.isPending ? 'Creating...' : 'Create Rule'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit Alert Rule Dialog */}
|
||||
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Alert Rule</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update the alert rule configuration
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Name</label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Cluster (optional)</label>
|
||||
<Select
|
||||
value={formData.clusterId || 'all'}
|
||||
onValueChange={(v) => setFormData({ ...formData, clusterId: v === 'all' ? null : v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All clusters" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All clusters</SelectItem>
|
||||
{clustersData?.clusters?.map((cluster: any) => (
|
||||
<SelectItem key={cluster.id} value={cluster.id}>
|
||||
{cluster.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Metric</label>
|
||||
<Select
|
||||
value={formData.condition.metric}
|
||||
onValueChange={(v) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
condition: { ...formData.condition, metric: v },
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="consumer_lag">Consumer Lag</SelectItem>
|
||||
<SelectItem value="message_rate">Message Rate</SelectItem>
|
||||
<SelectItem value="stream_size">Stream Size</SelectItem>
|
||||
<SelectItem value="pending_count">Pending Count</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Operator</label>
|
||||
<Select
|
||||
value={formData.condition.operator}
|
||||
onValueChange={(v) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
condition: { ...formData.condition, operator: v },
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="gt">Greater than</SelectItem>
|
||||
<SelectItem value="lt">Less than</SelectItem>
|
||||
<SelectItem value="gte">Greater or equal</SelectItem>
|
||||
<SelectItem value="lte">Less or equal</SelectItem>
|
||||
<SelectItem value="eq">Equals</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Threshold</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.threshold.value}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
threshold: { ...formData.threshold, value: Number(e.target.value) },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Severity</label>
|
||||
<Select
|
||||
value={formData.severity}
|
||||
onValueChange={(v) => setFormData({ ...formData, severity: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="info">Info</SelectItem>
|
||||
<SelectItem value="warning">Warning</SelectItem>
|
||||
<SelectItem value="critical">Critical</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Cooldown (minutes)</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.cooldownMins}
|
||||
onChange={(e) => setFormData({ ...formData, cooldownMins: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsEditOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleUpdate} disabled={!formData.name || updateMutation.isPending}>
|
||||
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Alert Rule</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete "{selectedRule?.name}"? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmDelete}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import { Suspense, useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
@@ -24,6 +24,16 @@ import { api } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { TabsList, TabsContent, useTabs, Tab } from '@/components/ui/tabs';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { formatBytes, formatNumber } from '@nats-console/shared';
|
||||
|
||||
const tabs: Tab[] = [
|
||||
@@ -38,6 +48,7 @@ function ClusterDetailContent() {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const clusterId = params.id as string;
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
const { activeTab, setActiveTab } = useTabs(tabs, 'overview');
|
||||
|
||||
@@ -151,19 +162,36 @@ function ClusterDetailContent() {
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (confirm('Are you sure you want to delete this cluster?')) {
|
||||
deleteMutation.mutate();
|
||||
}
|
||||
}}
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Cluster</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete cluster "{cluster.name}"? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteMutation.mutate()}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Tabs */}
|
||||
<TabsList tabs={tabs} activeTab={activeTab} onTabChange={setActiveTab} />
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Plus, Server, MoreVertical, CheckCircle, XCircle, AlertCircle } from 'lucide-react';
|
||||
import { Plus, Server, MoreVertical, CheckCircle, XCircle, AlertCircle, RefreshCw } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { api } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -12,7 +12,7 @@ import { CreateClusterDialog } from '@/components/forms/create-cluster-dialog';
|
||||
export default function ClustersPage() {
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['clusters'],
|
||||
queryFn: () => api.clusters.list(),
|
||||
});
|
||||
@@ -50,10 +50,15 @@ export default function ClustersPage() {
|
||||
<h1 className="text-3xl font-bold">Clusters</h1>
|
||||
<p className="text-muted-foreground">Manage your NATS JetStream clusters</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreateDialog(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Cluster
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="icon" onClick={() => refetch()}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button onClick={() => setShowCreateDialog(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Cluster
|
||||
</Button>
|
||||
</div>
|
||||
<CreateClusterDialog open={showCreateDialog} onOpenChange={setShowCreateDialog} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import { Suspense, useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
@@ -22,6 +22,16 @@ import { api } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { TabsList, useTabs, Tab } from '@/components/ui/tabs';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { formatNumber, formatDuration } from '@nats-console/shared';
|
||||
|
||||
const tabs: Tab[] = [
|
||||
@@ -37,6 +47,7 @@ function ConsumerDetailContent() {
|
||||
const clusterId = params.clusterId as string;
|
||||
const streamName = params.stream as string;
|
||||
const consumerName = params.name as string;
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
const { activeTab, setActiveTab } = useTabs(tabs, 'overview');
|
||||
|
||||
@@ -107,26 +118,50 @@ function ConsumerDetailContent() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => queryClient.invalidateQueries({ queryKey: ['consumer', clusterId, streamName, consumerName] })}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Edit className="h-4 w-4" />
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (confirm('Are you sure you want to delete this consumer?')) {
|
||||
deleteMutation.mutate();
|
||||
}
|
||||
}}
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Consumer</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete consumer "{consumerName}"? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteMutation.mutate()}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Tabs */}
|
||||
<TabsList tabs={tabs} activeTab={activeTab} onTabChange={setActiveTab} />
|
||||
|
||||
|
||||
@@ -1,21 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Plus, Users, Search, AlertTriangle, CheckCircle, ChevronRight } from 'lucide-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Users, Search, AlertTriangle, CheckCircle, ChevronRight, RefreshCw, Trash2 } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { api } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { formatNumber, formatDuration } from '@nats-console/shared';
|
||||
import { CreateConsumerDialog } from '@/components/forms/create-consumer-dialog';
|
||||
|
||||
export default function ConsumersPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedCluster, setSelectedCluster] = useState<string | null>(null);
|
||||
const [selectedStream, setSelectedStream] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
const [consumerToDelete, setConsumerToDelete] = useState<string | null>(null);
|
||||
|
||||
const { data: clustersData } = useQuery({
|
||||
queryKey: ['clusters'],
|
||||
@@ -28,7 +40,7 @@ export default function ConsumersPage() {
|
||||
enabled: !!selectedCluster,
|
||||
});
|
||||
|
||||
const { data: consumersData, isLoading } = useQuery({
|
||||
const { data: consumersData, isLoading, refetch } = useQuery({
|
||||
queryKey: ['consumers', selectedCluster, selectedStream],
|
||||
queryFn: () =>
|
||||
selectedCluster && selectedStream
|
||||
@@ -37,6 +49,15 @@ export default function ConsumersPage() {
|
||||
enabled: !!selectedCluster && !!selectedStream,
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (consumerName: string) =>
|
||||
api.consumers.delete(selectedCluster!, selectedStream!, consumerName),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['consumers', selectedCluster, selectedStream] });
|
||||
setConsumerToDelete(null);
|
||||
},
|
||||
});
|
||||
|
||||
// Auto-select first cluster
|
||||
if (clustersData?.clusters?.length && !selectedCluster) {
|
||||
setSelectedCluster(clustersData.clusters[0].id);
|
||||
@@ -65,10 +86,15 @@ export default function ConsumersPage() {
|
||||
<h1 className="text-3xl font-bold">Consumers</h1>
|
||||
<p className="text-muted-foreground">Manage JetStream consumers</p>
|
||||
</div>
|
||||
<Button disabled={!selectedStream} onClick={() => setShowCreateDialog(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Consumer
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="icon" onClick={() => refetch()} disabled={!selectedStream}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button disabled={!selectedStream} onClick={() => setShowCreateDialog(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create Consumer
|
||||
</Button>
|
||||
</div>
|
||||
{selectedCluster && selectedStream && (
|
||||
<CreateConsumerDialog
|
||||
open={showCreateDialog}
|
||||
@@ -165,6 +191,7 @@ export default function ConsumersPage() {
|
||||
<th className="text-right p-4 font-medium">Ack Pending</th>
|
||||
<th className="text-right p-4 font-medium">Ack Wait</th>
|
||||
<th className="text-center p-4 font-medium">Health</th>
|
||||
<th className="text-center p-4 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -202,6 +229,18 @@ export default function ConsumersPage() {
|
||||
<td className="p-4 text-center">
|
||||
<HealthIcon className={`h-5 w-5 mx-auto ${health.color}`} />
|
||||
</td>
|
||||
<td className="p-4 text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setConsumerToDelete(consumer.name);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
@@ -209,6 +248,27 @@ export default function ConsumersPage() {
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={!!consumerToDelete} onOpenChange={(open) => !open && setConsumerToDelete(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Consumer</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete consumer "{consumerToDelete}"? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => consumerToDelete && deleteMutation.mutate(consumerToDelete)}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,16 @@ import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { TabsList, useTabs, Tab } from '@/components/ui/tabs';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { formatBytes, formatNumber, formatDuration } from '@nats-console/shared';
|
||||
|
||||
const tabs: Tab[] = [
|
||||
@@ -43,6 +53,7 @@ function StreamDetailContent() {
|
||||
const [messageSubject, setMessageSubject] = useState('');
|
||||
const [messageData, setMessageData] = useState('');
|
||||
const [startSeq, setStartSeq] = useState('1');
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
const { data: streamData, isLoading } = useQuery({
|
||||
queryKey: ['stream', clusterId, streamName],
|
||||
@@ -147,19 +158,36 @@ function StreamDetailContent() {
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (confirm('Are you sure you want to delete this stream?')) {
|
||||
deleteMutation.mutate();
|
||||
}
|
||||
}}
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Stream</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete stream "{streamName}"? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteMutation.mutate()}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Tabs */}
|
||||
<TabsList tabs={tabs} activeTab={activeTab} onTabChange={setActiveTab} />
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Plus, Database, Search, ChevronRight } from 'lucide-react';
|
||||
import { Plus, Database, Search, ChevronRight, RefreshCw } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { api } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -21,7 +21,7 @@ export default function StreamsPage() {
|
||||
queryFn: () => api.clusters.list(),
|
||||
});
|
||||
|
||||
const { data: streamsData, isLoading } = useQuery({
|
||||
const { data: streamsData, isLoading, refetch } = useQuery({
|
||||
queryKey: ['streams', selectedCluster],
|
||||
queryFn: () => (selectedCluster ? api.streams.list(selectedCluster) : null),
|
||||
enabled: !!selectedCluster,
|
||||
@@ -43,10 +43,15 @@ export default function StreamsPage() {
|
||||
<h1 className="text-3xl font-bold">Streams</h1>
|
||||
<p className="text-muted-foreground">Manage JetStream streams</p>
|
||||
</div>
|
||||
<Button disabled={!selectedCluster} onClick={() => setShowCreateDialog(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Stream
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="icon" onClick={() => refetch()} disabled={!selectedCluster}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button disabled={!selectedCluster} onClick={() => setShowCreateDialog(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create Stream
|
||||
</Button>
|
||||
</div>
|
||||
{selectedCluster && (
|
||||
<CreateStreamDialog
|
||||
open={showCreateDialog}
|
||||
@@ -136,7 +141,6 @@ export default function StreamsPage() {
|
||||
className="font-medium text-primary hover:underline flex items-center gap-1"
|
||||
>
|
||||
{stream.config.name}
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-4 text-muted-foreground">
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from './button';
|
||||
|
||||
interface AlertDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AlertDialog({ open, onOpenChange, children }: AlertDialogProps) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50">
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50"
|
||||
onClick={() => onOpenChange(false)}
|
||||
/>
|
||||
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||
<div
|
||||
className="relative bg-background rounded-lg shadow-lg max-h-[90vh] overflow-auto w-full max-w-lg p-6"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlertDialogContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div className={cn('', className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex flex-col space-y-2 text-center sm:text-left', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return (
|
||||
<h2
|
||||
className={cn('text-lg font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||
return (
|
||||
<p
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 mt-4',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface AlertDialogActionProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AlertDialogAction({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: AlertDialogActionProps) {
|
||||
return (
|
||||
<Button className={cn(className)} {...props}>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlertDialogCancel({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: AlertDialogActionProps) {
|
||||
return (
|
||||
<Button variant="outline" className={cn('mt-2 sm:mt-0', className)} {...props}>
|
||||
{children || 'Cancel'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SelectContextValue {
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const SelectContext = React.createContext<SelectContextValue | null>(null);
|
||||
|
||||
interface SelectProps {
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Select({ value, onValueChange, children }: SelectProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<SelectContext.Provider value={{ value, onValueChange, open, setOpen }}>
|
||||
<div className="relative">{children}</div>
|
||||
</SelectContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
interface SelectTriggerProps extends React.HTMLAttributes<HTMLButtonElement> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SelectTrigger({ className, children, ...props }: SelectTriggerProps) {
|
||||
const context = React.useContext(SelectContext);
|
||||
if (!context) throw new Error('SelectTrigger must be used within Select');
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
onClick={() => context.setOpen(!context.open)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface SelectValueProps {
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function SelectValue({ placeholder }: SelectValueProps) {
|
||||
const context = React.useContext(SelectContext);
|
||||
if (!context) throw new Error('SelectValue must be used within Select');
|
||||
|
||||
return (
|
||||
<span className={!context.value ? 'text-muted-foreground' : ''}>
|
||||
{context.value || placeholder}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface SelectContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SelectContent({ className, children, ...props }: SelectContentProps) {
|
||||
const context = React.useContext(SelectContext);
|
||||
if (!context) throw new Error('SelectContent must be used within Select');
|
||||
|
||||
if (!context.open) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => context.setOpen(false)} />
|
||||
<div
|
||||
className={cn(
|
||||
'absolute z-50 min-w-[8rem] w-full mt-1 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="p-1">{children}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface SelectItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
value: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SelectItem({ className, value, children, ...props }: SelectItemProps) {
|
||||
const context = React.useContext(SelectContext);
|
||||
if (!context) throw new Error('SelectItem must be used within Select');
|
||||
|
||||
const isSelected = context.value === value;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 px-2 text-sm outline-none hover:bg-accent hover:text-accent-foreground',
|
||||
isSelected && 'bg-accent text-accent-foreground',
|
||||
className
|
||||
)}
|
||||
onClick={() => {
|
||||
context.onValueChange(value);
|
||||
context.setOpen(false);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,11 @@ import type { StreamMetrics, ConsumerMetrics, ClusterMetrics } from '../../../sh
|
||||
|
||||
const logger = pino({ name: 'metrics-collector' });
|
||||
|
||||
// Format timestamp for ClickHouse DateTime64(3)
|
||||
function formatTimestamp(date: Date): string {
|
||||
return date.toISOString().replace('T', ' ').replace('Z', '');
|
||||
}
|
||||
|
||||
interface ClusterConnection {
|
||||
id: string;
|
||||
nc: NatsConnection;
|
||||
@@ -203,7 +208,7 @@ export class MetricsCollector {
|
||||
values: streamMetrics.map((m) => ({
|
||||
cluster_id: m.clusterId,
|
||||
stream_name: m.streamName,
|
||||
timestamp: m.timestamp.toISOString(),
|
||||
timestamp: formatTimestamp(m.timestamp),
|
||||
messages_total: m.messagesTotal,
|
||||
bytes_total: m.bytesTotal,
|
||||
messages_rate: m.messagesRate,
|
||||
@@ -230,7 +235,7 @@ export class MetricsCollector {
|
||||
cluster_id: m.clusterId,
|
||||
stream_name: m.streamName,
|
||||
consumer_name: m.consumerName,
|
||||
timestamp: m.timestamp.toISOString(),
|
||||
timestamp: formatTimestamp(m.timestamp),
|
||||
pending_count: m.pendingCount,
|
||||
ack_pending: m.ackPending,
|
||||
redelivered: m.redelivered,
|
||||
@@ -288,7 +293,7 @@ export class MetricsCollector {
|
||||
cluster_id: m.clusterId,
|
||||
server_id: m.serverId,
|
||||
server_name: m.serverName,
|
||||
timestamp: m.timestamp.toISOString(),
|
||||
timestamp: formatTimestamp(m.timestamp),
|
||||
cpu_percent: m.cpuPercent,
|
||||
memory_bytes: m.memoryBytes,
|
||||
connections: m.connections,
|
||||
|
||||
@@ -15,7 +15,7 @@ async function main() {
|
||||
// Stream 1: ORDERS - typical e-commerce order stream
|
||||
await createStreamIfNotExists(jsm, {
|
||||
name: 'ORDERS',
|
||||
subjects: ['orders.>', 'orders.created', 'orders.updated', 'orders.shipped', 'orders.completed'],
|
||||
subjects: ['orders.>'],
|
||||
retention: RetentionPolicy.Limits,
|
||||
storage: StorageType.File,
|
||||
max_msgs: 100000,
|
||||
@@ -37,7 +37,7 @@ async function main() {
|
||||
// Stream 3: LOGS - log aggregation stream
|
||||
await createStreamIfNotExists(jsm, {
|
||||
name: 'LOGS',
|
||||
subjects: ['logs.>', 'logs.info', 'logs.warn', 'logs.error'],
|
||||
subjects: ['logs.>'],
|
||||
retention: RetentionPolicy.Limits,
|
||||
storage: StorageType.File,
|
||||
max_msgs: 1000000,
|
||||
|
||||
Reference in New Issue
Block a user