fix all build issues.

This commit is contained in:
Fizer Khan
2025-12-07 12:58:43 +05:30
parent dba894cba7
commit f72fcf316a
17 changed files with 890 additions and 441 deletions
+92 -32
View File
@@ -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"]
# =============================================================================
+44 -13
View File
@@ -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
+2 -1
View File
@@ -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
+12 -2
View File
@@ -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, {
+6 -4
View File
@@ -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`;
}
+22 -6
View File
@@ -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
+67 -34
View File
@@ -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<Stream>[] = useMemo(() => [
{
id: 'name',
accessorFn: (row) => row.config.name,
header: 'Name',
cell: ({ row }) => (
<Link
href={`/streams/${clusterId}/${row.original.config.name}`}
className="font-medium text-primary hover:underline"
>
{row.original.config.name}
</Link>
),
},
{
accessorKey: 'config.subjects',
header: 'Subjects',
cell: ({ row }) => (
<span className="text-muted-foreground">
{row.original.config.subjects?.join(', ') || '-'}
</span>
),
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 (
<div className="flex items-center justify-center h-64">
@@ -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() {
<p>No streams on this cluster</p>
</div>
) : (
<div className="border rounded-lg">
<table className="w-full">
<thead className="bg-muted/50">
<tr>
<th className="text-left p-3 font-medium">Name</th>
<th className="text-left p-3 font-medium">Subjects</th>
<th className="text-right p-3 font-medium">Messages</th>
<th className="text-right p-3 font-medium">Size</th>
<th className="text-right p-3 font-medium">Consumers</th>
</tr>
</thead>
<tbody>
{streams.map((stream: any) => (
<tr key={stream.config.name} className="border-t hover:bg-muted/30">
<td className="p-3">
<Link
href={`/streams/${clusterId}/${stream.config.name}`}
className="font-medium text-primary hover:underline"
>
{stream.config.name}
</Link>
</td>
<td className="p-3 text-muted-foreground">
{stream.config.subjects?.join(', ') || '-'}
</td>
<td className="p-3 text-right">{formatNumber(stream.state?.messages || 0)}</td>
<td className="p-3 text-right">{formatBytes(stream.state?.bytes || 0)}</td>
<td className="p-3 text-right">{stream.state?.consumer_count || 0}</td>
</tr>
))}
</tbody>
</table>
</div>
<DataTable
columns={streamColumns}
data={streams}
searchColumn="name"
searchPlaceholder="Search streams..."
emptyMessage="No streams found"
/>
)}
</CardContent>
</Card>
@@ -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() {
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatNumber(consumer.num_pending || 0)}
{formatNumber(consumer.numPending || 0)}
</div>
<p className="text-xs text-muted-foreground">Messages waiting to be delivered</p>
</CardContent>
@@ -296,7 +296,7 @@ function ConsumerDetailContent() {
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatNumber(consumer.num_ack_pending || 0)}
{formatNumber(consumer.numAckPending || 0)}
</div>
<p className="text-xs text-muted-foreground">Delivered, awaiting acknowledgment</p>
</CardContent>
@@ -307,7 +307,7 @@ function ConsumerDetailContent() {
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatNumber(consumer.num_redelivered || 0)}
{formatNumber(consumer.numRedelivered || 0)}
</div>
<p className="text-xs text-muted-foreground">Messages redelivered</p>
</CardContent>
@@ -318,7 +318,7 @@ function ConsumerDetailContent() {
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatNumber(consumer.num_waiting || 0)}
{formatNumber(consumer.numWaiting || 0)}
</div>
<p className="text-xs text-muted-foreground">Pull requests waiting</p>
</CardContent>
@@ -335,28 +335,28 @@ function ConsumerDetailContent() {
<div className="flex justify-between">
<span className="text-muted-foreground">Type</span>
<span className={`px-2 py-1 text-xs rounded-full ${
consumer.config?.durable_name
consumer.config?.durableName
? 'bg-blue-100 text-blue-700'
: 'bg-gray-100 text-gray-700'
}`}>
{consumer.config?.durable_name ? 'Durable' : 'Ephemeral'}
{consumer.config?.durableName ? 'Durable' : 'Ephemeral'}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Deliver Policy</span>
<span className="capitalize">{consumer.config?.deliver_policy || 'all'}</span>
<span className="capitalize">{consumer.config?.deliverPolicy || 'all'}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Ack Policy</span>
<span className="capitalize">{consumer.config?.ack_policy || 'explicit'}</span>
<span className="capitalize">{consumer.config?.ackPolicy || 'explicit'}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Replay Policy</span>
<span className="capitalize">{consumer.config?.replay_policy || 'instant'}</span>
<span className="capitalize">{consumer.config?.replayPolicy || 'instant'}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Filter Subject</span>
<span className="font-mono text-sm">{consumer.config?.filter_subject || '*'}</span>
<span className="font-mono text-sm">{consumer.config?.filterSubject || '*'}</span>
</div>
</CardContent>
</Card>
@@ -368,24 +368,24 @@ function ConsumerDetailContent() {
<CardContent className="space-y-3">
<div className="flex justify-between">
<span className="text-muted-foreground">Ack Wait</span>
<span>{formatDuration(consumer.config?.ack_wait || 30000000000)}</span>
<span>{formatDuration(consumer.config?.ackWait || 30000000000)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Max Deliver</span>
<span>{consumer.config?.max_deliver === -1 ? 'Unlimited' : consumer.config?.max_deliver}</span>
<span>{consumer.config?.maxDeliver === -1 ? 'Unlimited' : consumer.config?.maxDeliver}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Max Ack Pending</span>
<span>{consumer.config?.max_ack_pending === -1 ? 'Unlimited' : formatNumber(consumer.config?.max_ack_pending || 0)}</span>
<span>{consumer.config?.maxAckPending === -1 ? 'Unlimited' : formatNumber(consumer.config?.maxAckPending || 0)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Max Waiting</span>
<span>{consumer.config?.max_waiting || 512}</span>
<span>{consumer.config?.maxWaiting || 512}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Deliver Subject</span>
<span className="font-mono text-sm truncate max-w-[200px]">
{consumer.config?.deliver_subject || '-'}
{consumer.config?.deliverSubject || '-'}
</span>
</div>
</CardContent>
@@ -404,20 +404,20 @@ function ConsumerDetailContent() {
<div className="flex justify-between mb-2">
<span className="text-sm font-medium">Pending Messages</span>
<span className="text-sm text-muted-foreground">
{formatNumber(consumer.num_pending || 0)}
{formatNumber(consumer.numPending || 0)}
</span>
</div>
<div className="h-4 bg-muted rounded-full overflow-hidden">
<div
className={`h-full transition-all ${
(consumer.num_pending || 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)}%`,
}}
/>
</div>
@@ -426,14 +426,14 @@ function ConsumerDetailContent() {
<div className="flex justify-between mb-2">
<span className="text-sm font-medium">Ack Pending</span>
<span className="text-sm text-muted-foreground">
{formatNumber(consumer.num_ack_pending || 0)}
{formatNumber(consumer.numAckPending || 0)}
</span>
</div>
<div className="h-4 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 transition-all"
style={{
width: `${Math.min(((consumer.num_ack_pending || 0) / 1000) * 100, 100)}%`,
width: `${Math.min(((consumer.numAckPending || 0) / 1000) * 100, 100)}%`,
}}
/>
</div>
+151 -102
View File
@@ -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<string>('__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<Consumer>[] = useMemo(() => {
const cols: ColumnDef<Consumer>[] = [
{
accessorKey: 'name',
header: 'Name',
cell: ({ row }) => {
const streamName = row.original.streamName || selectedStream;
return (
<Link
href={`/consumers/${selectedClusterId}/${streamName}/${row.original.name}`}
className="font-medium text-primary hover:underline"
>
{row.original.name}
</Link>
);
},
},
];
if (selectedStream === '__all__') {
cols.push({
accessorKey: 'streamName',
header: 'Stream',
cell: ({ row }) => {
const streamName = row.original.streamName || selectedStream;
return (
<Link
href={`/streams/${selectedClusterId}/${streamName}`}
className="text-muted-foreground hover:text-primary hover:underline"
>
{streamName}
</Link>
);
},
});
}
cols.push(
{
accessorKey: 'config.durableName',
header: 'Type',
cell: ({ row }) => (
<span
className={`px-2 py-1 text-xs rounded-full ${
row.original.config?.durableName
? 'bg-blue-100 text-blue-700'
: 'bg-gray-100 text-gray-700'
}`}
>
{row.original.config?.durableName ? 'Durable' : 'Ephemeral'}
</span>
),
},
{
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 }) => (
<span className="text-muted-foreground">
{formatDuration(row.original.config?.ackWait || 30000000000)}
</span>
),
meta: { align: 'right' as const },
},
{
id: 'health',
header: 'Health',
cell: ({ row }) => {
const health = getHealthStatus(row.original);
const HealthIcon = health.icon;
return <HealthIcon className={`h-5 w-5 mx-auto ${health.color}`} />;
},
meta: { align: 'center' as const },
enableSorting: false,
},
{
id: 'actions',
header: 'Actions',
cell: ({ row }) => {
const streamName = row.original.streamName || selectedStream;
return (
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setConsumerToDelete({ name: row.original.name, streamName });
}}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
);
},
meta: { align: 'center' as const },
enableSorting: false,
}
);
return cols;
}, [selectedClusterId, selectedStream]);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
@@ -155,15 +286,6 @@ export default function ConsumersPage() {
</option>
))}
</select>
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search consumers..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
</div>
{isClusterDisconnected && (
@@ -188,15 +310,15 @@ export default function ConsumersPage() {
</div>
)}
{!isLoading && isClusterConnected && filteredConsumers && filteredConsumers.length === 0 && (
{!isLoading && isClusterConnected && consumers.length === 0 && (
<Card>
<CardContent className="py-12 text-center">
<Users className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">No consumers found</h3>
<p className="text-muted-foreground mb-4">
{search ? 'No consumers match your search' : 'Create your first consumer to get started'}
Create your first consumer to get started
</p>
{!search && selectedStream !== '__all__' && (
{selectedStream !== '__all__' && (
<Button onClick={() => setShowCreateDialog(true)}>
<Plus className="h-4 w-4" />
Create Consumer
@@ -206,87 +328,14 @@ export default function ConsumersPage() {
</Card>
)}
{filteredConsumers && filteredConsumers.length > 0 && (
<div className="border rounded-lg">
<table className="w-full">
<thead className="bg-muted/50">
<tr>
<th className="text-left p-4 font-medium">Name</th>
{selectedStream === '__all__' && (
<th className="text-left p-4 font-medium">Stream</th>
)}
<th className="text-left p-4 font-medium">Type</th>
<th className="text-right p-4 font-medium">Pending</th>
<th className="text-right p-4 font-medium">Redelivered</th>
<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>
{filteredConsumers.map((consumer: any) => {
const health = getHealthStatus(consumer);
const HealthIcon = health.icon;
const streamName = consumer.streamName || selectedStream;
return (
<tr key={`${streamName}-${consumer.name}`} className="border-t hover:bg-muted/30">
<td className="p-4">
<Link
href={`/consumers/${selectedClusterId}/${streamName}/${consumer.name}`}
className="font-medium text-primary hover:underline flex items-center gap-1"
>
{consumer.name}
</Link>
</td>
{selectedStream === '__all__' && (
<td className="p-4">
<Link
href={`/streams/${selectedClusterId}/${streamName}`}
className="text-muted-foreground hover:text-primary hover:underline"
>
{streamName}
</Link>
</td>
)}
<td className="p-4">
<span
className={`px-2 py-1 text-xs rounded-full ${
consumer.config?.durable_name
? 'bg-blue-100 text-blue-700'
: 'bg-gray-100 text-gray-700'
}`}
>
{consumer.config?.durable_name ? 'Durable' : 'Ephemeral'}
</span>
</td>
<td className="p-4 text-right">{formatNumber(consumer.num_pending || 0)}</td>
<td className="p-4 text-right">{formatNumber(consumer.num_redelivered || 0)}</td>
<td className="p-4 text-right">{formatNumber(consumer.num_ack_pending || 0)}</td>
<td className="p-4 text-right text-muted-foreground">
{formatDuration(consumer.config?.ack_wait || 30000000000)}
</td>
<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({ name: consumer.name, streamName });
}}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{consumers.length > 0 && (
<DataTable
columns={columns}
data={consumers}
searchColumn="name"
searchPlaceholder="Search consumers..."
emptyMessage="No consumers found"
/>
)}
{/* Delete Confirmation Dialog */}
@@ -67,6 +67,9 @@ import {
import { formatBytes, formatNumber, formatDuration } from '@nats-console/shared';
import { LineChart } from '@/components/charts';
import { CreateStreamDialog } from '@/components/forms/create-stream-dialog';
import { CreateConsumerDialog } from '@/components/forms/create-consumer-dialog';
import { DataTable } from '@/components/ui/data-table';
import { ColumnDef } from '@tanstack/react-table';
const tabs: Tab[] = [
{ id: 'overview', label: 'Overview', icon: Database },
@@ -76,6 +79,17 @@ const tabs: Tab[] = [
{ id: 'config', label: 'Configuration', icon: Settings },
];
interface Consumer {
name: string;
config?: {
durableName?: string;
ackWait?: number;
};
numPending?: number;
numRedelivered?: number;
numAckPending?: number;
}
function StreamDetailContent() {
const params = useParams();
const router = useRouter();
@@ -178,7 +192,7 @@ function StreamDetailContent() {
const { data: messagesData, refetch: refetchMessages, isFetching: isLoadingMessages } = useQuery({
queryKey: ['messages', clusterId, streamName, currentPage, pageSize, activeSubjectFilter],
queryFn: () => {
const firstSeq = streamData?.stream?.state?.first_seq || 1;
const firstSeq = streamData?.stream?.state?.firstSeq || 1;
const startSeq = String(firstSeq + (currentPage - 1) * pageSize);
const params: Record<string, string> = { start_seq: startSeq, limit: String(pageSize) };
if (activeSubjectFilter) {
@@ -307,6 +321,66 @@ function StreamDetailContent() {
},
});
const consumers: Consumer[] = consumersData?.consumers || [];
const consumerColumns: ColumnDef<Consumer>[] = useMemo(() => [
{
id: 'name',
accessorFn: (row) => row.name,
header: 'Name',
cell: ({ row }) => (
<Link
href={`/consumers/${clusterId}/${streamName}/${row.original.name}`}
className="font-medium text-primary hover:underline"
>
{row.original.name}
</Link>
),
},
{
id: 'type',
accessorFn: (row) => row.config?.durableName ? 'Durable' : 'Ephemeral',
header: 'Type',
cell: ({ row }) => (
<span className={`px-2 py-1 text-xs rounded-full ${
row.original.config?.durableName
? 'bg-blue-100 text-blue-700'
: 'bg-gray-100 text-gray-700'
}`}>
{row.original.config?.durableName ? 'Durable' : 'Ephemeral'}
</span>
),
},
{
accessorKey: 'numPending',
header: 'Pending',
cell: ({ row }) => formatNumber(row.original.numPending || 0),
meta: { align: 'right' as const },
},
{
accessorKey: 'numAckPending',
header: 'Ack Pending',
cell: ({ row }) => formatNumber(row.original.numAckPending || 0),
meta: { align: 'right' as const },
},
{
accessorKey: 'numRedelivered',
header: 'Redelivered',
cell: ({ row }) => formatNumber(row.original.numRedelivered || 0),
meta: { align: 'right' as const },
},
{
accessorKey: 'config.ackWait',
header: 'Ack Wait',
cell: ({ row }) => (
<span className="text-muted-foreground">
{formatDuration(row.original.config?.ackWait || 30000000000)}
</span>
),
meta: { align: 'right' as const },
},
], [clusterId, streamName]);
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
@@ -587,7 +661,7 @@ function StreamDetailContent() {
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{stream.state?.consumer_count || 0}
{stream.state?.consumerCount || 0}
</div>
</CardContent>
</Card>
@@ -609,19 +683,19 @@ function StreamDetailContent() {
<CardContent className="space-y-2">
<div className="flex justify-between">
<span className="text-muted-foreground">First Sequence</span>
<span>{stream.state?.first_seq || 0}</span>
<span>{stream.state?.firstSeq || 0}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Last Sequence</span>
<span>{stream.state?.last_seq || 0}</span>
<span>{stream.state?.lastSeq || 0}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">First Time</span>
<span>{stream.state?.first_ts ? new Date(stream.state.first_ts).toLocaleString() : '-'}</span>
<span>{stream.state?.firstTs ? new Date(stream.state.firstTs).toLocaleString() : '-'}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Last Time</span>
<span>{stream.state?.last_ts ? new Date(stream.state.last_ts).toLocaleString() : '-'}</span>
<span>{stream.state?.lastTs ? new Date(stream.state.lastTs).toLocaleString() : '-'}</span>
</div>
</CardContent>
</Card>
@@ -637,15 +711,15 @@ function StreamDetailContent() {
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Max Messages</span>
<span>{stream.config.max_msgs === -1 ? 'Unlimited' : formatNumber(stream.config.max_msgs)}</span>
<span>{stream.config.maxMsgs === -1 ? 'Unlimited' : formatNumber(stream.config.maxMsgs)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Max Bytes</span>
<span>{stream.config.max_bytes === -1 ? 'Unlimited' : formatBytes(stream.config.max_bytes)}</span>
<span>{stream.config.maxBytes === -1 ? 'Unlimited' : formatBytes(stream.config.maxBytes)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Max Age</span>
<span>{stream.config.max_age === 0 ? 'Unlimited' : formatDuration(stream.config.max_age)}</span>
<span>{stream.config.maxAge === 0 ? 'Unlimited' : formatDuration(stream.config.maxAge)}</span>
</div>
</CardContent>
</Card>
@@ -1049,59 +1123,24 @@ function StreamDetailContent() {
{/* Consumers Tab */}
{activeTab === 'consumers' && (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Consumers</CardTitle>
<CardDescription>Consumers attached to this stream</CardDescription>
</div>
<Button size="sm">
<Users className="h-4 w-4" />
Add Consumer
</Button>
<CardHeader>
<CardTitle>Consumers</CardTitle>
<CardDescription>Consumers attached to this stream</CardDescription>
</CardHeader>
<CardContent>
{consumersData?.consumers?.length === 0 ? (
{consumers.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Users className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No consumers</p>
</div>
) : (
<div className="border rounded-lg">
<table className="w-full">
<thead className="bg-muted/50">
<tr>
<th className="text-left p-3 font-medium">Name</th>
<th className="text-left p-3 font-medium">Type</th>
<th className="text-right p-3 font-medium">Pending</th>
<th className="text-right p-3 font-medium">Ack Pending</th>
<th className="text-right p-3 font-medium">Redelivered</th>
</tr>
</thead>
<tbody>
{consumersData?.consumers?.map((consumer: any) => (
<tr
key={consumer.name}
className="border-t hover:bg-muted/30 cursor-pointer"
onClick={() => router.push(`/consumers/${clusterId}/${streamName}/${consumer.name}`)}
>
<td className="p-3 font-medium text-primary hover:underline">{consumer.name}</td>
<td className="p-3">
<span className={`px-2 py-1 text-xs rounded-full ${
consumer.config?.durable_name
? 'bg-blue-100 text-blue-700'
: 'bg-gray-100 text-gray-700'
}`}>
{consumer.config?.durable_name ? 'Durable' : 'Ephemeral'}
</span>
</td>
<td className="p-3 text-right">{formatNumber(consumer.num_pending || 0)}</td>
<td className="p-3 text-right">{formatNumber(consumer.num_ack_pending || 0)}</td>
<td className="p-3 text-right">{formatNumber(consumer.num_redelivered || 0)}</td>
</tr>
))}
</tbody>
</table>
</div>
<DataTable
columns={consumerColumns}
data={consumers}
searchColumn="name"
searchPlaceholder="Search consumers..."
emptyMessage="No consumers found"
/>
)}
</CardContent>
</Card>
+97 -71
View File
@@ -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<Stream>[] = useMemo(() => [
{
id: 'name',
accessorFn: (row) => row.config.name,
header: 'Name',
cell: ({ row }) => (
<Link
href={`/streams/${selectedClusterId}/${row.original.config.name}`}
className="font-medium text-primary hover:underline"
>
{row.original.config.name}
</Link>
),
},
{
accessorKey: 'config.subjects',
header: 'Subjects',
cell: ({ row }) => (
<span className="text-muted-foreground">
{row.original.config.subjects?.join(', ') || '-'}
</span>
),
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 }) => (
<span
className={`px-2 py-1 text-xs rounded-full ${
row.original.config.storage === 'file'
? 'bg-blue-100 text-blue-700'
: 'bg-purple-100 text-purple-700'
}`}
>
{row.original.config.storage}
</span>
),
meta: { align: 'right' as const },
},
], [selectedClusterId]);
return (
<div className="space-y-6">
@@ -97,15 +172,6 @@ export default function StreamsPage() {
</option>
))}
</select>
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search streams..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
</div>
{!selectedClusterId && (
@@ -140,70 +206,30 @@ export default function StreamsPage() {
</div>
)}
{selectedClusterId && isClusterConnected && filteredStreams && filteredStreams.length === 0 && (
{selectedClusterId && isClusterConnected && !isLoading && streams.length === 0 && (
<Card>
<CardContent className="py-12 text-center">
<Database className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">No streams found</h3>
<p className="text-muted-foreground mb-4">
{search ? 'No streams match your search' : 'Create your first stream to get started'}
Create your first stream to get started
</p>
{!search && (
<Button onClick={() => setShowCreateDialog(true)}>
<Plus className="h-4 w-4" />
Create Stream
</Button>
)}
<Button onClick={() => setShowCreateDialog(true)}>
<Plus className="h-4 w-4" />
Create Stream
</Button>
</CardContent>
</Card>
)}
{filteredStreams && filteredStreams.length > 0 && (
<div className="border rounded-lg">
<table className="w-full">
<thead className="bg-muted/50">
<tr>
<th className="text-left p-4 font-medium">Name</th>
<th className="text-left p-4 font-medium">Subjects</th>
<th className="text-right p-4 font-medium">Messages</th>
<th className="text-right p-4 font-medium">Size</th>
<th className="text-right p-4 font-medium">Consumers</th>
<th className="text-right p-4 font-medium">Storage</th>
</tr>
</thead>
<tbody>
{filteredStreams.map((stream: any) => (
<tr key={stream.config.name} className="border-t hover:bg-muted/30">
<td className="p-4">
<Link
href={`/streams/${selectedClusterId}/${stream.config.name}`}
className="font-medium text-primary hover:underline flex items-center gap-1"
>
{stream.config.name}
</Link>
</td>
<td className="p-4 text-muted-foreground">
{stream.config.subjects?.join(', ') || '-'}
</td>
<td className="p-4 text-right">{formatNumber(stream.state?.messages || 0)}</td>
<td className="p-4 text-right">{formatBytes(stream.state?.bytes || 0)}</td>
<td className="p-4 text-right">{stream.state?.consumer_count || 0}</td>
<td className="p-4 text-right">
<span
className={`px-2 py-1 text-xs rounded-full ${
stream.config.storage === 'file'
? 'bg-blue-100 text-blue-700'
: 'bg-purple-100 text-purple-700'
}`}
>
{stream.config.storage}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
{streams.length > 0 && (
<DataTable
columns={columns}
data={streams}
searchColumn="name"
searchPlaceholder="Search streams..."
emptyMessage="No streams found"
/>
)}
</div>
);
+190 -79
View File
@@ -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<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
searchColumn?: string;
searchPlaceholder?: string;
pageSize?: number;
showPagination?: boolean;
showSearch?: boolean;
onRowClick?: (row: TData) => void;
emptyMessage?: string;
}
export function DataTable<TData, TValue>({
// Wrapper component to handle Suspense for useSearchParams
export function DataTable<TData, TValue>(props: DataTableProps<TData, TValue>) {
return (
<Suspense fallback={<DataTableSkeleton />}>
<DataTableInner {...props} />
</Suspense>
);
}
function DataTableSkeleton() {
return (
<div className="space-y-4">
<div className="border rounded-lg">
<div className="h-12 bg-muted/50 animate-pulse" />
<div className="h-48 animate-pulse" />
</div>
</div>
);
}
function DataTableInner<TData, TValue>({
columns,
data,
searchColumn,
searchPlaceholder = 'Search...',
pageSize = 10,
showPagination = true,
showSearch = true,
onRowClick,
emptyMessage = 'No results.',
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
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<string, string | null>) => {
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 <ArrowUp className="h-4 w-4" />;
if (isSorted === 'desc') return <ArrowDown className="h-4 w-4" />;
return <ArrowUpDown className="h-4 w-4 opacity-50" />;
};
return (
<div className="space-y-4">
{searchColumn && (
{searchColumn && showSearch && (
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder={searchPlaceholder}
value={(table.getColumn(searchColumn)?.getFilterValue() as string) ?? ''}
onChange={(event) =>
table.getColumn(searchColumn)?.setFilterValue(event.target.value)
}
onChange={(e) => handleSearchChange(e.target.value)}
className="pl-9"
/>
</div>
</div>
)}
<div className="border">
<div className="border rounded-lg">
<table className="w-full">
<thead className="bg-muted/50">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
className="text-left p-4 font-medium text-sm"
style={{ width: header.getSize() !== 150 ? header.getSize() : undefined }}
>
{header.isPlaceholder ? null : (
<div
className={
header.column.getCanSort()
? 'flex items-center gap-2 cursor-pointer select-none hover:text-foreground'
: ''
}
onClick={header.column.getToggleSortingHandler()}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getCanSort() && (
<ChevronDown
className={`h-4 w-4 transition-transform ${
header.column.getIsSorted() === 'asc' ? 'rotate-180' : ''
} ${header.column.getIsSorted() ? 'opacity-100' : 'opacity-0'}`}
/>
)}
</div>
)}
</th>
))}
{headerGroup.headers.map((header) => {
const meta = header.column.columnDef.meta as { align?: 'left' | 'center' | 'right' } | undefined;
const align = meta?.align || 'left';
return (
<th
key={header.id}
className={`p-4 font-medium text-sm ${
align === 'right' ? 'text-right' : align === 'center' ? 'text-center' : 'text-left'
}`}
style={{ width: header.getSize() !== 150 ? header.getSize() : undefined }}
>
{header.isPlaceholder ? null : (
<div
className={`inline-flex items-center gap-2 ${
header.column.getCanSort()
? 'cursor-pointer select-none hover:text-foreground'
: ''
} ${align === 'right' ? 'flex-row-reverse' : ''}`}
onClick={() => header.column.getCanSort() && handleSortingChange(header.column.id)}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getCanSort() && getSortIcon(header.column.getIsSorted())}
</div>
)}
</th>
);
})}
</tr>
))}
</thead>
@@ -127,17 +223,26 @@ export function DataTable<TData, TValue>({
}`}
onClick={() => onRowClick?.(row.original)}
>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="p-4">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
{row.getVisibleCells().map((cell) => {
const meta = cell.column.columnDef.meta as { align?: 'left' | 'center' | 'right' } | undefined;
const align = meta?.align || 'left';
return (
<td
key={cell.id}
className={`p-4 ${
align === 'right' ? 'text-right' : align === 'center' ? 'text-center' : 'text-left'
}`}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
})}
</tr>
))
) : (
<tr>
<td colSpan={columns.length} className="h-24 text-center text-muted-foreground">
No results.
{emptyMessage}
</td>
</tr>
)}
@@ -145,27 +250,33 @@ export function DataTable<TData, TValue>({
</table>
</div>
{showPagination && (
{showPagination && table.getFilteredRowModel().rows.length > 0 && (
<div className="flex items-center justify-between px-2">
<div className="text-sm text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length > 0 && (
<span>
{table.getFilteredSelectedRowModel().rows.length} of{' '}
{table.getFilteredRowModel().rows.length} row(s) selected.
</span>
)}
{table.getFilteredSelectedRowModel().rows.length === 0 && (
<span>
Showing {table.getRowModel().rows.length} of{' '}
{table.getFilteredRowModel().rows.length} row(s).
</span>
)}
<div className="flex items-center gap-4">
<div className="text-sm text-muted-foreground">
Showing {table.getRowModel().rows.length} of{' '}
{table.getFilteredRowModel().rows.length} row(s).
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Rows per page:</span>
<select
value={table.getState().pagination.pageSize}
onChange={(e) => handlePageSizeChange(Number(e.target.value))}
className="h-8 rounded-md border border-input bg-background px-2 py-1 text-sm"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => table.setPageIndex(0)}
onClick={() => handlePageChange(0)}
disabled={!table.getCanPreviousPage()}
>
<ChevronsLeft className="h-4 w-4" />
@@ -173,18 +284,18 @@ export function DataTable<TData, TValue>({
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
onClick={() => handlePageChange(table.getState().pagination.pageIndex - 1)}
disabled={!table.getCanPreviousPage()}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm">
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount() || 1}
</span>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
onClick={() => handlePageChange(table.getState().pagination.pageIndex + 1)}
disabled={!table.getCanNextPage()}
>
<ChevronRight className="h-4 w-4" />
@@ -192,7 +303,7 @@ export function DataTable<TData, TValue>({
<Button
variant="outline"
size="sm"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
onClick={() => handlePageChange(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<ChevronsRight className="h-4 w-4" />
+21
View File
@@ -4,6 +4,27 @@ const nextConfig: NextConfig = {
output: 'standalone',
transpilePackages: ['@nats-console/shared'],
devIndicators: false,
// Proxy API requests to the backend
// In single-container mode: API is at localhost:3001
// In multi-container mode: API_URL env var points to the API service
async rewrites() {
const apiUrl = process.env.API_URL || 'http://localhost:3001';
return [
{
source: '/api/:path*',
destination: `${apiUrl}/api/:path*`,
},
{
source: '/health/:path*',
destination: `${apiUrl}/health/:path*`,
},
{
source: '/ws/:path*',
destination: `${apiUrl}/ws/:path*`,
},
];
},
};
export default nextConfig;
+22
View File
@@ -0,0 +1,22 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface TableState {
pageSize: number;
setPageSize: (size: number) => void;
}
export const PAGE_SIZE_OPTIONS = [10, 20, 50, 100] as const;
export const DEFAULT_PAGE_SIZE = 20;
export const useTableStore = create<TableState>()(
persist(
(set) => ({
pageSize: DEFAULT_PAGE_SIZE,
setPageSize: (size) => set({ pageSize: size }),
}),
{
name: 'table-preferences',
}
)
);
+40 -12
View File
@@ -1,4 +1,10 @@
# Build stage
# =============================================================================
# NATS Console Workers - 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/workers/package.json ./apps/workers/
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,21 +28,43 @@ COPY apps/workers ./apps/workers
RUN pnpm --filter @nats-console/shared build
RUN pnpm --filter @nats-console/workers 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/workers/package.json ./apps/workers/
COPY --from=builder /app/apps/workers/dist ./apps/workers/dist
COPY --from=builder /app/apps/workers/node_modules ./apps/workers/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/workers/package.json ./apps/workers/
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 Workers (built artifacts only)
COPY --from=builder /app/apps/workers/dist ./apps/workers/dist
COPY --from=builder /app/apps/workers/package.json ./apps/workers/
# Copy workspace config for module resolution
COPY package.json pnpm-workspace.yaml ./
WORKDIR /app/apps/workers
+2 -2
View File
@@ -78,7 +78,7 @@ init_postgres() {
su - postgres -c "psql -c \"GRANT ALL PRIVILEGES ON DATABASE nats_console TO nats_console;\""
echo "[PostgreSQL] Running Prisma migrations..."
cd /app/apps/api && DATABASE_URL="postgresql://nats_console:nats_console@localhost:5432/nats_console" npx prisma migrate deploy || true
cd /app/apps/api && DATABASE_URL="postgresql://nats_console:nats_console@localhost:5432/nats_console" node /app/node_modules/prisma/build/index.js migrate deploy || true
# Stop PostgreSQL (will be started by supervisord)
su - postgres -c "/usr/lib/postgresql/16/bin/pg_ctl -D $POSTGRES_DATA -w stop"
@@ -97,7 +97,7 @@ init_clickhouse() {
chown -R clickhouse:clickhouse "$CLICKHOUSE_DATA"
# Start ClickHouse temporarily
su - clickhouse -s /bin/bash -c "/usr/bin/clickhouse-server --config-file=/etc/clickhouse-server/config.xml --daemon"
su -s /bin/bash clickhouse -c "/usr/bin/clickhouse-server --config-file=/etc/clickhouse-server/config.xml --daemon"
sleep 5
# Wait for ClickHouse to be ready
+4 -4
View File
@@ -60,7 +60,7 @@ stderr_logfile=/var/log/nats-console/nats-error.log
[program:api]
command=/usr/bin/node /app/apps/api/dist/index.js
directory=/app/apps/api
environment=NODE_ENV="production",PORT="3001",DATABASE_URL="postgresql://nats_console:nats_console@localhost:5432/nats_console",REDIS_URL="redis://localhost:6379",CLICKHOUSE_URL="http://localhost:8123",CLICKHOUSE_DATABASE="nats_console",NATS_URL="nats://localhost:4222"
environment=NODE_ENV="production",PORT="3001",DATABASE_URL="postgresql://nats_console:nats_console@localhost:5432/nats_console",REDIS_URL="redis://localhost:6379",CLICKHOUSE_URL="http://localhost:8123",CLICKHOUSE_DATABASE="nats_console",NATS_URL="nats://localhost:4222",CORS_ORIGIN="*",JWT_SECRET="%(ENV_JWT_SECRET)s"
autostart=true
autorestart=true
priority=30
@@ -73,9 +73,9 @@ stderr_logfile=/var/log/nats-console/api-error.log
# NATS Console Web
# =============================================================================
[program:web]
command=/usr/bin/node /app/apps/web/.next/standalone/apps/web/server.js
directory=/app/apps/web
environment=NODE_ENV="production",PORT="3000",HOSTNAME="0.0.0.0",NEXT_PUBLIC_API_URL="http://localhost:3001/api/v1"
command=/usr/bin/node /app/apps/web-standalone/apps/web/server.js
directory=/app/apps/web-standalone/apps/web
environment=NODE_ENV="production",PORT="3000",HOSTNAME="0.0.0.0",NEXT_PUBLIC_API_URL="/api/v1"
autostart=true
autorestart=true
priority=40