mirror of
https://github.com/KLogicHQ/nats-console.git
synced 2026-10-14 08:40:20 +00:00
fix all build issues.
This commit is contained in:
+92
-32
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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, {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user