diff --git a/apps/api/src/lib/clickhouse.ts b/apps/api/src/lib/clickhouse.ts index 8eac305..af78c83 100644 --- a/apps/api/src/lib/clickhouse.ts +++ b/apps/api/src/lib/clickhouse.ts @@ -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 ({ 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): Promise { 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, diff --git a/apps/web/app/(dashboard)/alerts/page.tsx b/apps/web/app/(dashboard)/alerts/page.tsx index 4595f30..f6473b9 100644 --- a/apps/web/app/(dashboard)/alerts/page.tsx +++ b/apps/web/app/(dashboard)/alerts/page.tsx @@ -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(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(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 }) => + 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 ; - case 'pending': - return ; - case 'resolved': - return ; - default: - return ; - } - }; - const getSeverityBadge = (severity: string) => { const colors: Record = { 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 (
@@ -91,34 +218,30 @@ export default function AlertsPage() {

Alerts

Monitor and manage alert rules

- +
+ + +
-
- {(['all', 'active', 'resolved'] as const).map((f) => ( + {(['all', 'enabled', 'disabled'] as const).map((f) => (
- {!selectedCluster && ( - - - -

Select a cluster

-

Choose a cluster to view alerts

-
-
- )} - - {selectedCluster && isLoading && ( + {isLoading && (
)} - {selectedCluster && filteredAlerts && filteredAlerts.length === 0 && ( + {!isLoading && (!filteredRules || filteredRules.length === 0) && ( -

No alerts found

+

No alert rules found

- {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'}

{filter === 'all' && ( - )} @@ -161,93 +274,357 @@ export default function AlertsPage() {
)} - {filteredAlerts && filteredAlerts.length > 0 && ( -
- {/* Alert Rules Section */} - - - Alert Rules - Configured alert rules for this cluster - - -
- {filteredAlerts.map((alert: any) => ( -
-
- {getStatusIcon(alert.status)} -
-

{alert.name}

-

- {alert.condition} {alert.operator} {alert.threshold} -

-
-
-
- {getSeverityBadge(alert.severity)} - - {alert.lastTriggered ? formatTimeAgo(alert.lastTriggered) : 'Never triggered'} - -
- - -
+ {filteredRules && filteredRules.length > 0 && ( + + + Alert Rules + {filteredRules.length} rule{filteredRules.length !== 1 ? 's' : ''} configured + + +
+ {filteredRules.map((rule: AlertRule) => ( +
+
+ {rule.isEnabled ? ( + + ) : ( + + )} +
+

{rule.name}

+

+ {rule.condition.metric} {rule.condition.operator} {rule.threshold.value} + {rule.cluster && ` • ${rule.cluster.name}`} +

- ))} -
- - - - {/* Recent Alert Events */} - - - Recent Events - Latest alert activity - - -
-
- -
-

Consumer lag exceeded threshold

-

- ORDERS stream - lag-alert rule triggered -

+
+ {getSeverityBadge(rule.severity)} + + {formatTimeAgo(rule.lastTriggeredAt)} + +
+ + + +
- 2m ago
-
- -
-

Alert resolved

-

- EVENTS stream - throughput-alert back to normal -

-
- 15m ago -
-
- -
-

Alert pending

-

- LOGS stream - storage-alert approaching threshold -

-
- 1h ago -
-
- - -
+ ))} +
+
+
)} + + {/* Create Alert Rule Dialog */} + + + + Create Alert Rule + + Configure a new alert rule to monitor your NATS streams + + +
+
+ + setFormData({ ...formData, name: e.target.value })} + /> +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + + setFormData({ + ...formData, + threshold: { ...formData.threshold, value: Number(e.target.value) }, + }) + } + /> +
+
+ + +
+
+
+ + setFormData({ ...formData, cooldownMins: Number(e.target.value) })} + /> +
+
+ + + + +
+
+ + {/* Edit Alert Rule Dialog */} + + + + Edit Alert Rule + + Update the alert rule configuration + + +
+
+ + setFormData({ ...formData, name: e.target.value })} + /> +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + + setFormData({ + ...formData, + threshold: { ...formData.threshold, value: Number(e.target.value) }, + }) + } + /> +
+
+ + +
+
+
+ + setFormData({ ...formData, cooldownMins: Number(e.target.value) })} + /> +
+
+ + + + +
+
+ + {/* Delete Confirmation Dialog */} + + + + Delete Alert Rule + + Are you sure you want to delete "{selectedRule?.name}"? This action cannot be undone. + + + + Cancel + + {deleteMutation.isPending ? 'Deleting...' : 'Delete'} + + + +
); } diff --git a/apps/web/app/(dashboard)/clusters/[id]/page.tsx b/apps/web/app/(dashboard)/clusters/[id]/page.tsx index eaee889..c7dcbe5 100644 --- a/apps/web/app/(dashboard)/clusters/[id]/page.tsx +++ b/apps/web/app/(dashboard)/clusters/[id]/page.tsx @@ -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() {
+ {/* Delete Confirmation Dialog */} + + + + Delete Cluster + + Are you sure you want to delete cluster "{cluster.name}"? This action cannot be undone. + + + + Cancel + deleteMutation.mutate()} + className="bg-red-600 hover:bg-red-700" + > + {deleteMutation.isPending ? 'Deleting...' : 'Delete'} + + + + + {/* Tabs */} diff --git a/apps/web/app/(dashboard)/clusters/page.tsx b/apps/web/app/(dashboard)/clusters/page.tsx index 3862333..41b93cc 100644 --- a/apps/web/app/(dashboard)/clusters/page.tsx +++ b/apps/web/app/(dashboard)/clusters/page.tsx @@ -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() {

Clusters

Manage your NATS JetStream clusters

- +
+ + +
diff --git a/apps/web/app/(dashboard)/consumers/[clusterId]/[stream]/[name]/page.tsx b/apps/web/app/(dashboard)/consumers/[clusterId]/[stream]/[name]/page.tsx index 64dd851..68e4704 100644 --- a/apps/web/app/(dashboard)/consumers/[clusterId]/[stream]/[name]/page.tsx +++ b/apps/web/app/(dashboard)/consumers/[clusterId]/[stream]/[name]/page.tsx @@ -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() {
+
+ {/* Delete Confirmation Dialog */} + + + + Delete Consumer + + Are you sure you want to delete consumer "{consumerName}"? This action cannot be undone. + + + + Cancel + deleteMutation.mutate()} + className="bg-red-600 hover:bg-red-700" + > + {deleteMutation.isPending ? 'Deleting...' : 'Delete'} + + + + + {/* Tabs */} diff --git a/apps/web/app/(dashboard)/consumers/page.tsx b/apps/web/app/(dashboard)/consumers/page.tsx index dc70a18..1580099 100644 --- a/apps/web/app/(dashboard)/consumers/page.tsx +++ b/apps/web/app/(dashboard)/consumers/page.tsx @@ -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(null); const [selectedStream, setSelectedStream] = useState(null); const [search, setSearch] = useState(''); const [showCreateDialog, setShowCreateDialog] = useState(false); + const [consumerToDelete, setConsumerToDelete] = useState(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() {

Consumers

Manage JetStream consumers

- +
+ + +
{selectedCluster && selectedStream && ( Ack Pending Ack Wait Health + Actions @@ -202,6 +229,18 @@ export default function ConsumersPage() { + + + ); })} @@ -209,6 +248,27 @@ export default function ConsumersPage() { )} + + {/* Delete Confirmation Dialog */} + !open && setConsumerToDelete(null)}> + + + Delete Consumer + + Are you sure you want to delete consumer "{consumerToDelete}"? This action cannot be undone. + + + + Cancel + consumerToDelete && deleteMutation.mutate(consumerToDelete)} + className="bg-red-600 hover:bg-red-700" + > + {deleteMutation.isPending ? 'Deleting...' : 'Delete'} + + + + ); } diff --git a/apps/web/app/(dashboard)/streams/[clusterId]/[name]/page.tsx b/apps/web/app/(dashboard)/streams/[clusterId]/[name]/page.tsx index 8091141..d9f9890 100644 --- a/apps/web/app/(dashboard)/streams/[clusterId]/[name]/page.tsx +++ b/apps/web/app/(dashboard)/streams/[clusterId]/[name]/page.tsx @@ -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() { + {/* Delete Confirmation Dialog */} + + + + Delete Stream + + Are you sure you want to delete stream "{streamName}"? This action cannot be undone. + + + + Cancel + deleteMutation.mutate()} + className="bg-red-600 hover:bg-red-700" + > + {deleteMutation.isPending ? 'Deleting...' : 'Delete'} + + + + + {/* Tabs */} diff --git a/apps/web/app/(dashboard)/streams/page.tsx b/apps/web/app/(dashboard)/streams/page.tsx index 577794c..8b321ae 100644 --- a/apps/web/app/(dashboard)/streams/page.tsx +++ b/apps/web/app/(dashboard)/streams/page.tsx @@ -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() {

Streams

Manage JetStream streams

- +
+ + +
{selectedCluster && ( {stream.config.name} - diff --git a/apps/web/components/ui/alert-dialog.tsx b/apps/web/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..cdbf69b --- /dev/null +++ b/apps/web/components/ui/alert-dialog.tsx @@ -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 ( +
+
onOpenChange(false)} + /> +
+
e.stopPropagation()} + > + {children} +
+
+
+ ); +} + +export function AlertDialogContent({ + className, + children, + ...props +}: React.HTMLAttributes) { + return ( +
+ {children} +
+ ); +} + +export function AlertDialogHeader({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ); +} + +export function AlertDialogTitle({ + className, + ...props +}: React.HTMLAttributes) { + return ( +

+ ); +} + +export function AlertDialogDescription({ + className, + ...props +}: React.HTMLAttributes) { + return ( +

+ ); +} + +export function AlertDialogFooter({ + className, + ...props +}: React.HTMLAttributes) { + return ( +

+ ); +} + +interface AlertDialogActionProps extends React.ButtonHTMLAttributes { + children: React.ReactNode; +} + +export function AlertDialogAction({ + className, + children, + ...props +}: AlertDialogActionProps) { + return ( + + ); +} + +export function AlertDialogCancel({ + className, + children, + ...props +}: AlertDialogActionProps) { + return ( + + ); +} diff --git a/apps/web/components/ui/select.tsx b/apps/web/components/ui/select.tsx new file mode 100644 index 0000000..9861394 --- /dev/null +++ b/apps/web/components/ui/select.tsx @@ -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(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 ( + +
{children}
+
+ ); +} + +interface SelectTriggerProps extends React.HTMLAttributes { + 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 ( + + ); +} + +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 ( + + {context.value || placeholder} + + ); +} + +interface SelectContentProps extends React.HTMLAttributes { + 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 ( + <> +
context.setOpen(false)} /> +
+
{children}
+
+ + ); +} + +interface SelectItemProps extends React.HTMLAttributes { + 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 ( +
{ + context.onValueChange(value); + context.setOpen(false); + }} + {...props} + > + {children} +
+ ); +} diff --git a/apps/workers/src/collectors/metrics.ts b/apps/workers/src/collectors/metrics.ts index 20d669c..c4f85d6 100644 --- a/apps/workers/src/collectors/metrics.ts +++ b/apps/workers/src/collectors/metrics.ts @@ -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, diff --git a/examples/src/setup-streams.ts b/examples/src/setup-streams.ts index c1b2ba4..b199927 100644 --- a/examples/src/setup-streams.ts +++ b/examples/src/setup-streams.ts @@ -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,