From f72fcf316a0e90bea42f41c21e646ca157bfb55b Mon Sep 17 00:00:00 2001 From: Fizer Khan Date: Sun, 7 Dec 2025 12:58:43 +0530 Subject: [PATCH] fix all build issues. --- Dockerfile | 124 +++++--- apps/api/Dockerfile | 57 +++- apps/api/src/config/index.ts | 3 +- apps/api/src/index.ts | 14 +- apps/shared/src/utils/index.ts | 10 +- apps/web/Dockerfile | 28 +- .../app/(dashboard)/clusters/[id]/page.tsx | 101 ++++--- .../[clusterId]/[stream]/[name]/page.tsx | 48 ++-- apps/web/app/(dashboard)/consumers/page.tsx | 253 +++++++++------- .../streams/[clusterId]/[name]/page.tsx | 149 ++++++---- apps/web/app/(dashboard)/streams/page.tsx | 168 ++++++----- apps/web/components/ui/data-table.tsx | 269 +++++++++++++----- apps/web/next.config.ts | 21 ++ apps/web/stores/table.ts | 22 ++ apps/workers/Dockerfile | 52 +++- docker/scripts/init-databases.sh | 4 +- docker/supervisord.conf | 8 +- 17 files changed, 890 insertions(+), 441 deletions(-) create mode 100644 apps/web/stores/table.ts diff --git a/Dockerfile b/Dockerfile index bc1cb8d..1be6dff 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # ============================================================================= -# NATS Console - All-in-One Docker Image +# NATS Console - All-in-One Docker Image (Multi-Stage Build) # ============================================================================= # This Dockerfile creates a single image containing: # - PostgreSQL 16 @@ -12,7 +12,58 @@ # For production, use docker-compose.prod.yml with separate services. # ============================================================================= -FROM ubuntu:22.04 +# ============================================================================= +# Stage 1: Builder - Install dependencies and build applications +# ============================================================================= +FROM node:20-slim AS builder + +# Install pnpm +RUN npm install -g pnpm@9 + +WORKDIR /app + +# Copy package files first for better caching +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./ +COPY apps/api/package.json ./apps/api/ +COPY apps/web/package.json ./apps/web/ +COPY apps/workers/package.json ./apps/workers/ +COPY apps/shared/package.json ./apps/shared/ + +# Install all dependencies (including devDependencies for build) +RUN pnpm install --frozen-lockfile + +# Copy source code +COPY apps/ ./apps/ + +# Generate Prisma client +RUN cd apps/api && pnpm prisma generate + +# Build all applications +RUN pnpm run build + +# ============================================================================= +# Stage 2: Production Dependencies +# ============================================================================= +FROM node:20-slim AS prod-deps + +RUN npm install -g pnpm@9 + +WORKDIR /app + +# Copy package files +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY apps/api/package.json ./apps/api/ +COPY apps/web/package.json ./apps/web/ +COPY apps/workers/package.json ./apps/workers/ +COPY apps/shared/package.json ./apps/shared/ + +# Install production dependencies +RUN pnpm install --frozen-lockfile --prod + +# ============================================================================= +# Stage 3: Runner - Final image with only built artifacts +# ============================================================================= +FROM ubuntu:22.04 AS runner LABEL maintainer="NATS Console Team" LABEL description="All-in-One NATS JetStream Console with embedded databases" @@ -31,16 +82,14 @@ RUN apt-get update && apt-get install -y \ lsb-release \ ca-certificates \ supervisor \ - git \ - build-essential \ && rm -rf /var/lib/apt/lists/* # ============================================================================= -# Install Node.js 20 +# Install Node.js 20 (runtime only, no build tools needed) # ============================================================================= RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ && apt-get install -y nodejs \ - && npm install -g pnpm@9 + && rm -rf /var/lib/apt/lists/* # ============================================================================= # Install PostgreSQL 16 @@ -94,40 +143,50 @@ RUN chown -R clickhouse:clickhouse /var/lib/clickhouse RUN chown -R redis:redis /var/lib/redis # ============================================================================= -# Copy application code +# Copy built applications from builder stage # ============================================================================= WORKDIR /app -# Copy package files first for better caching -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./ -COPY apps/api/package.json ./apps/api/ -COPY apps/web/package.json ./apps/web/ -COPY apps/workers/package.json ./apps/workers/ -COPY apps/shared/package.json ./apps/shared/ +# Copy production node_modules first +COPY --from=prod-deps /app/node_modules ./node_modules -# Install dependencies -RUN pnpm install --frozen-lockfile +# Overlay Prisma-generated client from builder (generated client files) +COPY --from=builder /app/node_modules/.pnpm/@prisma+client*/node_modules/.prisma ./node_modules/.pnpm/@prisma+client*/node_modules/.prisma -# Copy source code -COPY . . +# Copy shared package (built) +COPY --from=builder /app/apps/shared/dist ./apps/shared/dist +COPY --from=builder /app/apps/shared/package.json ./apps/shared/ -# Build the application -RUN pnpm run build +# Copy API (built only) +COPY --from=builder /app/apps/api/dist ./apps/api/dist +COPY --from=builder /app/apps/api/package.json ./apps/api/ +COPY --from=builder /app/apps/api/prisma ./apps/api/prisma -# Generate Prisma client -RUN cd apps/api && pnpm prisma generate +# Copy Web (Next.js standalone build) +COPY --from=builder /app/apps/web/.next/standalone ./apps/web-standalone +COPY --from=builder /app/apps/web/.next/static ./apps/web-standalone/apps/web/.next/static +COPY --from=builder /app/apps/web/public ./apps/web-standalone/apps/web/public + +# Copy Workers (built only) +COPY --from=builder /app/apps/workers/dist ./apps/workers/dist +COPY --from=builder /app/apps/workers/package.json ./apps/workers/ + +# Copy Prisma CLI for migrations (from builder since it's a devDependency) +COPY --from=builder /app/node_modules/.pnpm/prisma*/node_modules/prisma ./node_modules/prisma +COPY --from=builder /app/node_modules/.pnpm/@prisma+engines*/node_modules/@prisma/engines ./node_modules/@prisma/engines + +# Copy package.json files for workspace resolution +COPY package.json pnpm-workspace.yaml ./ # ============================================================================= -# Copy initialization scripts +# Copy initialization scripts and configuration # ============================================================================= COPY infrastructure/clickhouse/init/init.sql /docker-entrypoint-initdb.d/clickhouse-init.sql COPY docker/scripts/entrypoint.sh /entrypoint.sh COPY docker/scripts/init-databases.sh /init-databases.sh RUN chmod +x /entrypoint.sh /init-databases.sh -# ============================================================================= # Configure supervisord -# ============================================================================= COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf # ============================================================================= @@ -144,7 +203,15 @@ ENV NATS_URL=nats://localhost:4222 ENV JWT_SECRET=change-me-in-production-use-secure-random-string ENV JWT_EXPIRES_IN=15m ENV JWT_REFRESH_EXPIRES_IN=7d -ENV NEXT_PUBLIC_API_URL=http://localhost:3001/api/v1 + +# CORS: Allow all origins in single-container mode (frontend proxies to API) +ENV CORS_ORIGIN=* + +# API URL for Next.js rewrites (internal, same container) +ENV API_URL=http://localhost:3001 + +# Public API URL for client-side requests (can be overridden for external access) +ENV NEXT_PUBLIC_API_URL=/api/v1 # ============================================================================= # Expose ports @@ -175,13 +242,6 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ # ============================================================================= # Volumes for data persistence # ============================================================================= -# Single data directory with all databases -# /data -# ├── postgres/ -# ├── redis/ -# ├── clickhouse/ -# ├── nats/ -# └── logs/ VOLUME ["/data"] # ============================================================================= diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 8773666..afc904a 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -1,4 +1,10 @@ -# Build stage +# ============================================================================= +# NATS Console API - Multi-Stage Docker Build +# ============================================================================= + +# ============================================================================= +# Stage 1: Builder - Install all dependencies and build +# ============================================================================= FROM node:20-alpine AS builder WORKDIR /app @@ -6,12 +12,12 @@ WORKDIR /app # Install pnpm RUN corepack enable && corepack prepare pnpm@latest --activate -# Copy package files +# Copy package files for dependency caching COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./ COPY apps/api/package.json ./apps/api/ COPY apps/shared/package.json ./apps/shared/ -# Install dependencies +# Install all dependencies (including devDependencies for build) RUN pnpm install --frozen-lockfile # Copy source files @@ -25,22 +31,47 @@ RUN pnpm --filter @nats-console/api prisma generate RUN pnpm --filter @nats-console/shared build RUN pnpm --filter @nats-console/api build -# Production stage -FROM node:20-alpine AS runner +# ============================================================================= +# Stage 2: Production Dependencies +# ============================================================================= +FROM node:20-alpine AS prod-deps WORKDIR /app RUN corepack enable && corepack prepare pnpm@latest --activate -# Copy built application -COPY --from=builder /app/package.json /app/pnpm-lock.yaml /app/pnpm-workspace.yaml ./ -COPY --from=builder /app/apps/api/package.json ./apps/api/ -COPY --from=builder /app/apps/api/dist ./apps/api/dist -COPY --from=builder /app/apps/api/prisma ./apps/api/prisma -COPY --from=builder /app/apps/api/node_modules ./apps/api/node_modules -COPY --from=builder /app/apps/shared/package.json ./apps/shared/ +# Copy package files +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY apps/api/package.json ./apps/api/ +COPY apps/shared/package.json ./apps/shared/ + +# Install production dependencies only +RUN pnpm install --frozen-lockfile --prod + +# ============================================================================= +# Stage 3: Runner - Final production image +# ============================================================================= +FROM node:20-alpine AS runner + +WORKDIR /app + +# Copy production node_modules +COPY --from=prod-deps /app/node_modules ./node_modules + +# Copy shared package (built) COPY --from=builder /app/apps/shared/dist ./apps/shared/dist -COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/apps/shared/package.json ./apps/shared/ + +# Copy API (built artifacts only) +COPY --from=builder /app/apps/api/dist ./apps/api/dist +COPY --from=builder /app/apps/api/package.json ./apps/api/ +COPY --from=builder /app/apps/api/prisma ./apps/api/prisma + +# Copy Prisma generated client +COPY --from=builder /app/node_modules/.pnpm/@prisma+client*/node_modules/.prisma ./node_modules/.pnpm/@prisma+client*/node_modules/.prisma + +# Copy workspace config for module resolution +COPY package.json pnpm-workspace.yaml ./ WORKDIR /app/apps/api diff --git a/apps/api/src/config/index.ts b/apps/api/src/config/index.ts index 50c2beb..2d0296d 100644 --- a/apps/api/src/config/index.ts +++ b/apps/api/src/config/index.ts @@ -30,7 +30,8 @@ const envSchema = z.object({ ENCRYPTION_KEY: z.string().min(32).optional(), // CORS - CORS_ORIGIN: z.string().default('http://localhost:3000'), + // Use '*' for single-container mode, comma-separated origins for multi-container + CORS_ORIGIN: z.string().default('*'), // Rate limiting (only applies in production, disabled in development) RATE_LIMIT_MAX: z.coerce.number().default(1000), // 1000 requests per window in production diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 23be978..08bbb6d 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -47,11 +47,21 @@ const app = Fastify({ }); // Register plugins +// CORS configuration: +// - Development: allow all origins +// - Production with '*': allow all origins (single-container mode) +// - Production with specific origins: only allow listed origins +const getCorsOrigin = () => { + if (config.NODE_ENV === 'development') return true; + if (config.CORS_ORIGIN === '*') return true; + return config.CORS_ORIGIN.split(',').map(o => o.trim()); +}; + await app.register(cors, { - origin: config.NODE_ENV === 'development' ? true : config.CORS_ORIGIN.split(','), + origin: getCorsOrigin(), credentials: true, methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], - allowedHeaders: ['Content-Type', 'Authorization'], + allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'], }); await app.register(helmet, { diff --git a/apps/shared/src/utils/index.ts b/apps/shared/src/utils/index.ts index 557ee7c..84e0da4 100644 --- a/apps/shared/src/utils/index.ts +++ b/apps/shared/src/utils/index.ts @@ -22,7 +22,8 @@ export function nsToSec(ns: number): number { return Math.floor(ns / NS_PER_SEC); } -export function formatDuration(ns: number): string { +export function formatDuration(ns: number | null | undefined): string { + if (ns == null || ns === 0) return '0s'; if (ns < NS_PER_SEC) { return `${nsToMs(ns)}ms`; } @@ -65,8 +66,8 @@ export function parseDuration(duration: string): number { const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; -export function formatBytes(bytes: number, decimals = 2): string { - if (bytes === 0) return '0 B'; +export function formatBytes(bytes: number | null | undefined, decimals = 2): string { + if (bytes == null || bytes === 0) return '0 B'; const k = 1024; const i = Math.floor(Math.log(bytes) / Math.log(k)); const value = bytes / Math.pow(k, i); @@ -89,7 +90,8 @@ export function parseBytes(str: string): number { // ==================== Number Formatting ==================== -export function formatNumber(num: number, decimals?: number): string { +export function formatNumber(num: number | null | undefined, decimals?: number): string { + if (num == null) return '0'; if (num >= 1_000_000_000) { return `${(num / 1_000_000_000).toFixed(1)}B`; } diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index b2acfd3..28c7c63 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -1,4 +1,10 @@ -# Build stage +# ============================================================================= +# NATS Console Web - Multi-Stage Docker Build +# ============================================================================= + +# ============================================================================= +# Stage 1: Builder - Install all dependencies and build +# ============================================================================= FROM node:20-alpine AS builder WORKDIR /app @@ -6,12 +12,12 @@ WORKDIR /app # Install pnpm RUN corepack enable && corepack prepare pnpm@latest --activate -# Copy package files +# Copy package files for dependency caching COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./ COPY apps/web/package.json ./apps/web/ COPY apps/shared/package.json ./apps/shared/ -# Install dependencies +# Install all dependencies (including devDependencies for build) RUN pnpm install --frozen-lockfile # Copy source files @@ -22,18 +28,28 @@ COPY apps/web ./apps/web RUN pnpm --filter @nats-console/shared build RUN pnpm --filter @nats-console/web build -# Production stage +# ============================================================================= +# Stage 2: Runner - Final production image +# ============================================================================= +# Next.js standalone output includes all dependencies, no need for prod-deps stage FROM node:20-alpine AS runner WORKDIR /app -RUN corepack enable && corepack prepare pnpm@latest --activate +# Don't run as root +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs -# Copy built application +# Copy Next.js standalone build (includes node_modules) COPY --from=builder /app/apps/web/.next/standalone ./ COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static COPY --from=builder /app/apps/web/public ./apps/web/public +# Set correct ownership +RUN chown -R nextjs:nodejs /app + +USER nextjs + ENV NODE_ENV=production ENV PORT=3000 ENV HOSTNAME=0.0.0.0 diff --git a/apps/web/app/(dashboard)/clusters/[id]/page.tsx b/apps/web/app/(dashboard)/clusters/[id]/page.tsx index bd0bdfe..3f27727 100644 --- a/apps/web/app/(dashboard)/clusters/[id]/page.tsx +++ b/apps/web/app/(dashboard)/clusters/[id]/page.tsx @@ -39,6 +39,8 @@ import { CreateClusterDialog } from '@/components/forms/create-cluster-dialog'; import { GaugeChart } from '@/components/charts'; import { useClusterStore } from '@/stores/cluster'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { DataTable } from '@/components/ui/data-table'; +import { ColumnDef } from '@tanstack/react-table'; const tabs: Tab[] = [ { id: 'overview', label: 'Overview', icon: Server }, @@ -46,6 +48,19 @@ const tabs: Tab[] = [ { id: 'config', label: 'Configuration', icon: Settings }, ]; +interface Stream { + config: { + name: string; + subjects?: string[]; + storage: string; + }; + state?: { + messages?: number; + bytes?: number; + consumerCount?: number; + }; +} + function ClusterDetailContent() { const params = useParams(); const router = useRouter(); @@ -91,6 +106,50 @@ function ClusterDetailContent() { }, }); + const streamColumns: ColumnDef[] = useMemo(() => [ + { + id: 'name', + accessorFn: (row) => row.config.name, + header: 'Name', + cell: ({ row }) => ( + + {row.original.config.name} + + ), + }, + { + accessorKey: 'config.subjects', + header: 'Subjects', + cell: ({ row }) => ( + + {row.original.config.subjects?.join(', ') || '-'} + + ), + enableSorting: false, + }, + { + accessorKey: 'state.messages', + header: 'Messages', + cell: ({ row }) => formatNumber(row.original.state?.messages || 0), + meta: { align: 'right' as const }, + }, + { + accessorKey: 'state.bytes', + header: 'Size', + cell: ({ row }) => formatBytes(row.original.state?.bytes || 0), + meta: { align: 'right' as const }, + }, + { + accessorKey: 'state.consumerCount', + header: 'Consumers', + cell: ({ row }) => row.original.state?.consumerCount || 0, + meta: { align: 'right' as const }, + }, + ], [clusterId]); + if (isLoading) { return (
@@ -137,7 +196,7 @@ function ClusterDetailContent() { }; const jetstream = infoData?.jetstream || { streams: 0, consumers: 0, messages: 0, bytes: 0 }; - const streams = streamsData?.streams || []; + const streams: Stream[] = streamsData?.streams || []; // Get URL from primary connection const clusterUrl = cluster?.connections?.[0]?.serverUrl || ''; @@ -436,39 +495,13 @@ function ClusterDetailContent() {

No streams on this cluster

) : ( -
- - - - - - - - - - - - {streams.map((stream: any) => ( - - - - - - - - ))} - -
NameSubjectsMessagesSizeConsumers
- - {stream.config.name} - - - {stream.config.subjects?.join(', ') || '-'} - {formatNumber(stream.state?.messages || 0)}{formatBytes(stream.state?.bytes || 0)}{stream.state?.consumer_count || 0}
-
+ )} 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 e480742..cfcd0db 100644 --- a/apps/web/app/(dashboard)/consumers/[clusterId]/[stream]/[name]/page.tsx +++ b/apps/web/app/(dashboard)/consumers/[clusterId]/[stream]/[name]/page.tsx @@ -151,7 +151,7 @@ function ConsumerDetailContent() { } const getHealthStatus = () => { - const pending = consumer.num_pending || 0; + const pending = consumer.numPending || 0; if (pending > 10000) return { status: 'critical', label: 'Critical Lag', color: 'text-red-500', bg: 'bg-red-100' }; if (pending > 1000) return { status: 'warning', label: 'High Lag', color: 'text-yellow-500', bg: 'bg-yellow-100' }; return { status: 'healthy', label: 'Healthy', color: 'text-green-500', bg: 'bg-green-100' }; @@ -159,9 +159,9 @@ function ConsumerDetailContent() { const health = getHealthStatus(); - // Check if consumer is paused (pause_until is in the future) + // Check if consumer is paused (pauseUntil is in the future) const isPaused = () => { - const pauseUntil = consumer.config?.pause_until; + const pauseUntil = consumer.config?.pauseUntil; if (!pauseUntil) return false; return new Date(pauseUntil) > new Date(); }; @@ -285,7 +285,7 @@ function ConsumerDetailContent() {
- {formatNumber(consumer.num_pending || 0)} + {formatNumber(consumer.numPending || 0)}

Messages waiting to be delivered

@@ -296,7 +296,7 @@ function ConsumerDetailContent() {
- {formatNumber(consumer.num_ack_pending || 0)} + {formatNumber(consumer.numAckPending || 0)}

Delivered, awaiting acknowledgment

@@ -307,7 +307,7 @@ function ConsumerDetailContent() {
- {formatNumber(consumer.num_redelivered || 0)} + {formatNumber(consumer.numRedelivered || 0)}

Messages redelivered

@@ -318,7 +318,7 @@ function ConsumerDetailContent() {
- {formatNumber(consumer.num_waiting || 0)} + {formatNumber(consumer.numWaiting || 0)}

Pull requests waiting

@@ -335,28 +335,28 @@ function ConsumerDetailContent() {
Type - {consumer.config?.durable_name ? 'Durable' : 'Ephemeral'} + {consumer.config?.durableName ? 'Durable' : 'Ephemeral'}
Deliver Policy - {consumer.config?.deliver_policy || 'all'} + {consumer.config?.deliverPolicy || 'all'}
Ack Policy - {consumer.config?.ack_policy || 'explicit'} + {consumer.config?.ackPolicy || 'explicit'}
Replay Policy - {consumer.config?.replay_policy || 'instant'} + {consumer.config?.replayPolicy || 'instant'}
Filter Subject - {consumer.config?.filter_subject || '*'} + {consumer.config?.filterSubject || '*'}
@@ -368,24 +368,24 @@ function ConsumerDetailContent() {
Ack Wait - {formatDuration(consumer.config?.ack_wait || 30000000000)} + {formatDuration(consumer.config?.ackWait || 30000000000)}
Max Deliver - {consumer.config?.max_deliver === -1 ? 'Unlimited' : consumer.config?.max_deliver} + {consumer.config?.maxDeliver === -1 ? 'Unlimited' : consumer.config?.maxDeliver}
Max Ack Pending - {consumer.config?.max_ack_pending === -1 ? 'Unlimited' : formatNumber(consumer.config?.max_ack_pending || 0)} + {consumer.config?.maxAckPending === -1 ? 'Unlimited' : formatNumber(consumer.config?.maxAckPending || 0)}
Max Waiting - {consumer.config?.max_waiting || 512} + {consumer.config?.maxWaiting || 512}
Deliver Subject - {consumer.config?.deliver_subject || '-'} + {consumer.config?.deliverSubject || '-'}
@@ -404,20 +404,20 @@ function ConsumerDetailContent() {
Pending Messages - {formatNumber(consumer.num_pending || 0)} + {formatNumber(consumer.numPending || 0)}
10000 + (consumer.numPending || 0) > 10000 ? 'bg-red-500' - : (consumer.num_pending || 0) > 1000 + : (consumer.numPending || 0) > 1000 ? 'bg-yellow-500' : 'bg-green-500' }`} style={{ - width: `${Math.min(((consumer.num_pending || 0) / 10000) * 100, 100)}%`, + width: `${Math.min(((consumer.numPending || 0) / 10000) * 100, 100)}%`, }} />
@@ -426,14 +426,14 @@ function ConsumerDetailContent() {
Ack Pending - {formatNumber(consumer.num_ack_pending || 0)} + {formatNumber(consumer.numAckPending || 0)}
diff --git a/apps/web/app/(dashboard)/consumers/page.tsx b/apps/web/app/(dashboard)/consumers/page.tsx index 2354770..48aed2b 100644 --- a/apps/web/app/(dashboard)/consumers/page.tsx +++ b/apps/web/app/(dashboard)/consumers/page.tsx @@ -1,13 +1,14 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Plus, Users, Search, AlertTriangle, CheckCircle, ChevronRight, RefreshCw, Trash2, WifiOff } from 'lucide-react'; +import { ColumnDef } from '@tanstack/react-table'; +import { Plus, Users, AlertTriangle, CheckCircle, RefreshCw, Trash2, WifiOff } 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 { DataTable } from '@/components/ui/data-table'; import { AlertDialog, AlertDialogAction, @@ -22,11 +23,22 @@ import { formatNumber, formatDuration } from '@nats-console/shared'; import { CreateConsumerDialog } from '@/components/forms/create-consumer-dialog'; import { useClusterStore } from '@/stores/cluster'; +interface Consumer { + name: string; + streamName?: string; + config?: { + durableName?: string; + ackWait?: number; + }; + numPending?: number; + numRedelivered?: number; + numAckPending?: number; +} + export default function ConsumersPage() { const queryClient = useQueryClient(); const { selectedClusterId, setSelectedClusterId } = useClusterStore(); const [selectedStream, setSelectedStream] = useState('__all__'); - const [search, setSearch] = useState(''); const [showCreateDialog, setShowCreateDialog] = useState(false); const [consumerToDelete, setConsumerToDelete] = useState<{ name: string; streamName: string } | null>(null); @@ -89,17 +101,136 @@ export default function ConsumersPage() { } }, [clustersData?.clusters, selectedClusterId, setSelectedClusterId]); - const filteredConsumers = consumersData?.consumers?.filter((consumer: any) => - consumer.name.toLowerCase().includes(search.toLowerCase()) - ); + const consumers = useMemo(() => { + return consumersData?.consumers || []; + }, [consumersData?.consumers]); - const getHealthStatus = (consumer: any) => { - const pending = consumer.num_pending || 0; + const getHealthStatus = (consumer: Consumer) => { + const pending = consumer.numPending || 0; if (pending > 10000) return { status: 'critical', icon: AlertTriangle, color: 'text-red-500' }; if (pending > 1000) return { status: 'warning', icon: AlertTriangle, color: 'text-yellow-500' }; return { status: 'healthy', icon: CheckCircle, color: 'text-green-500' }; }; + const columns: ColumnDef[] = useMemo(() => { + const cols: ColumnDef[] = [ + { + accessorKey: 'name', + header: 'Name', + cell: ({ row }) => { + const streamName = row.original.streamName || selectedStream; + return ( + + {row.original.name} + + ); + }, + }, + ]; + + if (selectedStream === '__all__') { + cols.push({ + accessorKey: 'streamName', + header: 'Stream', + cell: ({ row }) => { + const streamName = row.original.streamName || selectedStream; + return ( + + {streamName} + + ); + }, + }); + } + + cols.push( + { + accessorKey: 'config.durableName', + header: 'Type', + cell: ({ row }) => ( + + {row.original.config?.durableName ? 'Durable' : 'Ephemeral'} + + ), + }, + { + accessorKey: 'numPending', + header: 'Pending', + cell: ({ row }) => formatNumber(row.original.numPending || 0), + meta: { align: 'right' as const }, + }, + { + accessorKey: 'numRedelivered', + header: 'Redelivered', + cell: ({ row }) => formatNumber(row.original.numRedelivered || 0), + meta: { align: 'right' as const }, + }, + { + accessorKey: 'numAckPending', + header: 'Ack Pending', + cell: ({ row }) => formatNumber(row.original.numAckPending || 0), + meta: { align: 'right' as const }, + }, + { + accessorKey: 'config.ackWait', + header: 'Ack Wait', + cell: ({ row }) => ( + + {formatDuration(row.original.config?.ackWait || 30000000000)} + + ), + meta: { align: 'right' as const }, + }, + { + id: 'health', + header: 'Health', + cell: ({ row }) => { + const health = getHealthStatus(row.original); + const HealthIcon = health.icon; + return ; + }, + meta: { align: 'center' as const }, + enableSorting: false, + }, + { + id: 'actions', + header: 'Actions', + cell: ({ row }) => { + const streamName = row.original.streamName || selectedStream; + return ( + + ); + }, + meta: { align: 'center' as const }, + enableSorting: false, + } + ); + + return cols; + }, [selectedClusterId, selectedStream]); + return (
@@ -155,15 +286,6 @@ export default function ConsumersPage() { ))} -
- - setSearch(e.target.value)} - className="pl-9" - /> -
{isClusterDisconnected && ( @@ -188,15 +310,15 @@ export default function ConsumersPage() {
)} - {!isLoading && isClusterConnected && filteredConsumers && filteredConsumers.length === 0 && ( + {!isLoading && isClusterConnected && consumers.length === 0 && (

No consumers found

- {search ? 'No consumers match your search' : 'Create your first consumer to get started'} + Create your first consumer to get started

- {!search && selectedStream !== '__all__' && ( + {selectedStream !== '__all__' && ( + + Consumers + Consumers attached to this stream - {consumersData?.consumers?.length === 0 ? ( + {consumers.length === 0 ? (

No consumers

) : ( -
- - - - - - - - - - - - {consumersData?.consumers?.map((consumer: any) => ( - router.push(`/consumers/${clusterId}/${streamName}/${consumer.name}`)} - > - - - - - - - ))} - -
NameTypePendingAck PendingRedelivered
{consumer.name} - - {consumer.config?.durable_name ? 'Durable' : 'Ephemeral'} - - {formatNumber(consumer.num_pending || 0)}{formatNumber(consumer.num_ack_pending || 0)}{formatNumber(consumer.num_redelivered || 0)}
-
+ )}
diff --git a/apps/web/app/(dashboard)/streams/page.tsx b/apps/web/app/(dashboard)/streams/page.tsx index 933bcc7..91ebd79 100644 --- a/apps/web/app/(dashboard)/streams/page.tsx +++ b/apps/web/app/(dashboard)/streams/page.tsx @@ -1,20 +1,35 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Plus, Database, Search, ChevronRight, RefreshCw, WifiOff } from 'lucide-react'; +import { ColumnDef } from '@tanstack/react-table'; +import { Plus, Database, RefreshCw, WifiOff } 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, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Card, CardContent } from '@/components/ui/card'; +import { DataTable } from '@/components/ui/data-table'; import { formatBytes, formatNumber } from '@nats-console/shared'; import { CreateStreamDialog } from '@/components/forms/create-stream-dialog'; import { useClusterStore } from '@/stores/cluster'; +interface Stream { + config: { + name: string; + subjects?: string[]; + storage: string; + maxMsgs?: number; + maxBytes?: number; + }; + state?: { + messages?: number; + bytes?: number; + consumerCount?: number; + }; +} + export default function StreamsPage() { const { selectedClusterId, setSelectedClusterId } = useClusterStore(); - const [search, setSearch] = useState(''); const [showCreateDialog, setShowCreateDialog] = useState(false); const { data: clustersData } = useQuery({ @@ -55,9 +70,69 @@ export default function StreamsPage() { } }, [clustersData?.clusters, selectedClusterId, setSelectedClusterId]); - const filteredStreams = streamsData?.streams?.filter((stream: any) => - stream.config.name.toLowerCase().includes(search.toLowerCase()) - ); + const streams = useMemo(() => { + return streamsData?.streams || []; + }, [streamsData?.streams]); + + const columns: ColumnDef[] = useMemo(() => [ + { + id: 'name', + accessorFn: (row) => row.config.name, + header: 'Name', + cell: ({ row }) => ( + + {row.original.config.name} + + ), + }, + { + accessorKey: 'config.subjects', + header: 'Subjects', + cell: ({ row }) => ( + + {row.original.config.subjects?.join(', ') || '-'} + + ), + enableSorting: false, + }, + { + accessorKey: 'state.messages', + header: 'Messages', + cell: ({ row }) => formatNumber(row.original.state?.messages || 0), + meta: { align: 'right' as const }, + }, + { + accessorKey: 'state.bytes', + header: 'Size', + cell: ({ row }) => formatBytes(row.original.state?.bytes || 0), + meta: { align: 'right' as const }, + }, + { + accessorKey: 'state.consumerCount', + header: 'Consumers', + cell: ({ row }) => row.original.state?.consumerCount || 0, + meta: { align: 'right' as const }, + }, + { + accessorKey: 'config.storage', + header: 'Storage', + cell: ({ row }) => ( + + {row.original.config.storage} + + ), + meta: { align: 'right' as const }, + }, + ], [selectedClusterId]); return (
@@ -97,15 +172,6 @@ export default function StreamsPage() { ))} -
- - setSearch(e.target.value)} - className="pl-9" - /> -
{!selectedClusterId && ( @@ -140,70 +206,30 @@ export default function StreamsPage() {
)} - {selectedClusterId && isClusterConnected && filteredStreams && filteredStreams.length === 0 && ( + {selectedClusterId && isClusterConnected && !isLoading && streams.length === 0 && (

No streams found

- {search ? 'No streams match your search' : 'Create your first stream to get started'} + Create your first stream to get started

- {!search && ( - - )} +
)} - {filteredStreams && filteredStreams.length > 0 && ( -
- - - - - - - - - - - - - {filteredStreams.map((stream: any) => ( - - - - - - - - - ))} - -
NameSubjectsMessagesSizeConsumersStorage
- - {stream.config.name} - - - {stream.config.subjects?.join(', ') || '-'} - {formatNumber(stream.state?.messages || 0)}{formatBytes(stream.state?.bytes || 0)}{stream.state?.consumer_count || 0} - - {stream.config.storage} - -
-
+ {streams.length > 0 && ( + )}
); diff --git a/apps/web/components/ui/data-table.tsx b/apps/web/components/ui/data-table.tsx index 163a6aa..1b222b7 100644 --- a/apps/web/components/ui/data-table.tsx +++ b/apps/web/components/ui/data-table.tsx @@ -1,11 +1,10 @@ 'use client'; -import { useState } from 'react'; +import { useCallback, Suspense } from 'react'; +import { useRouter, useSearchParams, usePathname } from 'next/navigation'; import { ColumnDef, - ColumnFiltersState, SortingState, - VisibilityState, flexRender, getCoreRowModel, getFilteredRowModel, @@ -13,106 +12,203 @@ import { getSortedRowModel, useReactTable, } from '@tanstack/react-table'; -import { ChevronDown, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Search } from 'lucide-react'; +import { ArrowUpDown, ArrowUp, ArrowDown, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Search } from 'lucide-react'; import { Button } from './button'; import { Input } from './input'; +import { useTableStore, PAGE_SIZE_OPTIONS, DEFAULT_PAGE_SIZE } from '@/stores/table'; interface DataTableProps { columns: ColumnDef[]; data: TData[]; searchColumn?: string; searchPlaceholder?: string; - pageSize?: number; showPagination?: boolean; + showSearch?: boolean; onRowClick?: (row: TData) => void; + emptyMessage?: string; } -export function DataTable({ +// Wrapper component to handle Suspense for useSearchParams +export function DataTable(props: DataTableProps) { + return ( + }> + + + ); +} + +function DataTableSkeleton() { + return ( +
+
+
+
+
+
+ ); +} + +function DataTableInner({ columns, data, searchColumn, searchPlaceholder = 'Search...', - pageSize = 10, showPagination = true, + showSearch = true, onRowClick, + emptyMessage = 'No results.', }: DataTableProps) { - const [sorting, setSorting] = useState([]); - const [columnFilters, setColumnFilters] = useState([]); - const [columnVisibility, setColumnVisibility] = useState({}); - const [rowSelection, setRowSelection] = useState({}); + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const { pageSize: storedPageSize, setPageSize: setStoredPageSize } = useTableStore(); + + // Parse URL params + const urlPage = parseInt(searchParams.get('page') || '1', 10) - 1; + const urlSort = searchParams.get('sort') || ''; + const urlOrder = searchParams.get('order') as 'asc' | 'desc' | null; + const urlSearch = searchParams.get('q') || ''; + const urlPageSize = parseInt(searchParams.get('size') || String(storedPageSize || DEFAULT_PAGE_SIZE), 10); + + // Parse sorting from URL + const initialSorting: SortingState = urlSort + ? [{ id: urlSort, desc: urlOrder === 'desc' }] + : []; const table = useReactTable({ data, columns, - onSortingChange: setSorting, - onColumnFiltersChange: setColumnFilters, getCoreRowModel: getCoreRowModel(), getPaginationRowModel: getPaginationRowModel(), getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), - onColumnVisibilityChange: setColumnVisibility, - onRowSelectionChange: setRowSelection, initialState: { - pagination: { pageSize }, - }, - state: { - sorting, - columnFilters, - columnVisibility, - rowSelection, + pagination: { pageIndex: urlPage, pageSize: urlPageSize }, + sorting: initialSorting, + columnFilters: searchColumn && urlSearch ? [{ id: searchColumn, value: urlSearch }] : [], }, }); + // Update URL params + const updateUrlParams = useCallback((updates: Record) => { + const params = new URLSearchParams(searchParams.toString()); + + Object.entries(updates).forEach(([key, value]) => { + if (value === null || value === '' || value === '1' && key === 'page') { + params.delete(key); + } else { + params.set(key, value); + } + }); + + const newUrl = params.toString() ? `${pathname}?${params}` : pathname; + router.replace(newUrl, { scroll: false }); + }, [pathname, router, searchParams]); + + // Handle page change + const handlePageChange = (pageIndex: number) => { + table.setPageIndex(pageIndex); + updateUrlParams({ page: String(pageIndex + 1) }); + }; + + // Handle page size change + const handlePageSizeChange = (newSize: number) => { + setStoredPageSize(newSize); + table.setPageSize(newSize); + table.setPageIndex(0); + updateUrlParams({ + size: newSize === DEFAULT_PAGE_SIZE ? null : String(newSize), + page: null + }); + }; + + // Handle sorting change + const handleSortingChange = (columnId: string) => { + const currentSort = table.getState().sorting[0]; + let newSort: SortingState = []; + let sortParam: string | null = null; + let orderParam: string | null = null; + + if (!currentSort || currentSort.id !== columnId) { + // First click: sort ascending + newSort = [{ id: columnId, desc: false }]; + sortParam = columnId; + orderParam = 'asc'; + } else if (!currentSort.desc) { + // Second click: sort descending + newSort = [{ id: columnId, desc: true }]; + sortParam = columnId; + orderParam = 'desc'; + } + // Third click: clear sorting (newSort stays empty) + + table.setSorting(newSort); + updateUrlParams({ sort: sortParam, order: orderParam, page: null }); + }; + + // Handle search change + const handleSearchChange = (value: string) => { + if (searchColumn) { + table.getColumn(searchColumn)?.setFilterValue(value); + table.setPageIndex(0); + updateUrlParams({ q: value || null, page: null }); + } + }; + + const getSortIcon = (isSorted: false | 'asc' | 'desc') => { + if (isSorted === 'asc') return ; + if (isSorted === 'desc') return ; + return ; + }; + return (
- {searchColumn && ( + {searchColumn && showSearch && (
- table.getColumn(searchColumn)?.setFilterValue(event.target.value) - } + onChange={(e) => handleSearchChange(e.target.value)} className="pl-9" />
)} -
+
{table.getHeaderGroups().map((headerGroup) => ( - {headerGroup.headers.map((header) => ( - - ))} + {headerGroup.headers.map((header) => { + const meta = header.column.columnDef.meta as { align?: 'left' | 'center' | 'right' } | undefined; + const align = meta?.align || 'left'; + return ( + + ); + })} ))} @@ -127,17 +223,26 @@ export function DataTable({ }`} onClick={() => onRowClick?.(row.original)} > - {row.getVisibleCells().map((cell) => ( - - ))} + {row.getVisibleCells().map((cell) => { + const meta = cell.column.columnDef.meta as { align?: 'left' | 'center' | 'right' } | undefined; + const align = meta?.align || 'left'; + return ( + + ); + })} )) ) : ( )} @@ -145,27 +250,33 @@ export function DataTable({
- {header.isPlaceholder ? null : ( -
- {flexRender(header.column.columnDef.header, header.getContext())} - {header.column.getCanSort() && ( - - )} -
- )} -
+ {header.isPlaceholder ? null : ( +
header.column.getCanSort() && handleSortingChange(header.column.id)} + > + {flexRender(header.column.columnDef.header, header.getContext())} + {header.column.getCanSort() && getSortIcon(header.column.getIsSorted())} +
+ )} +
- {flexRender(cell.column.columnDef.cell, cell.getContext())} - + {flexRender(cell.column.columnDef.cell, cell.getContext())} +
- No results. + {emptyMessage}
- {showPagination && ( + {showPagination && table.getFilteredRowModel().rows.length > 0 && (
-
- {table.getFilteredSelectedRowModel().rows.length > 0 && ( - - {table.getFilteredSelectedRowModel().rows.length} of{' '} - {table.getFilteredRowModel().rows.length} row(s) selected. - - )} - {table.getFilteredSelectedRowModel().rows.length === 0 && ( - - Showing {table.getRowModel().rows.length} of{' '} - {table.getFilteredRowModel().rows.length} row(s). - - )} +
+
+ Showing {table.getRowModel().rows.length} of{' '} + {table.getFilteredRowModel().rows.length} row(s). +
+
+ Rows per page: + +
- Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()} + Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount() || 1}