feat: implement centralized logging and historical metrics dashboard

This commit is contained in:
SaelixCode
2026-03-06 21:05:23 -05:00
parent 49cef7acfa
commit a4a5365da1
9 changed files with 922 additions and 231 deletions
+16 -2
View File
@@ -13,7 +13,7 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
import { Tabs, TabsList, TabsTrigger } from './ui/tabs';
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
import { Badge } from './ui/badge';
import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, LogOut, ExternalLink, Bell, Settings, MoreVertical, BellRing, Rocket, HardDrive, ScrollText } from 'lucide-react';
import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, LogOut, ExternalLink, Bell, Settings, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity } from 'lucide-react';
import { useAuth } from '@/context/AuthContext';
import { apiFetch } from '@/lib/api';
import { toast } from 'sonner';
@@ -30,6 +30,7 @@ import { SettingsModal } from './SettingsModal';
import { StackAlertSheet } from './StackAlertSheet';
import { AppStoreView } from './AppStoreView';
import { LogViewer } from './LogViewer';
import { GlobalObservabilityView } from './GlobalObservabilityView';
interface ContainerInfo {
Id: string;
@@ -79,7 +80,7 @@ export default function EditorLayout() {
}
return true; // Default to dark mode
});
const [activeView, setActiveView] = useState<'dashboard' | 'editor' | 'host-console' | 'resources' | 'templates'>('dashboard');
const [activeView, setActiveView] = useState<'dashboard' | 'editor' | 'host-console' | 'resources' | 'templates' | 'global-observability'>('dashboard');
const [isEditing, setIsEditing] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [stackStatuses, setStackStatuses] = useState<StackStatus>({});
@@ -793,6 +794,17 @@ export default function EditorLayout() {
<CloudDownload className="w-4 h-4 mr-2" />
App Store
</Button>
{/* Global Observability Toggle */}
<Button
variant="outline"
size="sm"
className="rounded-lg"
onClick={() => setActiveView('global-observability')}
title="Global Observability"
>
<Activity className="w-4 h-4 mr-2" />
Observability
</Button>
{/* Settings Modal Toggle */}
<Button
@@ -1146,6 +1158,8 @@ export default function EditorLayout() {
</Card>
</div>
</ErrorBoundary>
) : activeView === 'global-observability' ? (
<GlobalObservabilityView />
) : (
<HomeDashboard />
)}
@@ -0,0 +1,248 @@
import { useEffect, useState, useMemo } from 'react';
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts';
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { RefreshCw, Activity, Terminal } from 'lucide-react';
import { ScrollArea } from '@/components/ui/scroll-area';
interface Metric {
id: number;
container_id: string;
stack_name: string;
cpu_percent: number;
memory_mb: number;
net_rx_mb: number;
net_tx_mb: number;
timestamp: number;
}
interface LogEntry {
stackName: string;
containerName: string;
level: string;
message: string;
timestamp: number;
}
export function GlobalObservabilityView() {
const [metrics, setMetrics] = useState<Metric[]>([]);
const [logs, setLogs] = useState<LogEntry[]>([]);
const [loading, setLoading] = useState(true);
// Filters
const [selectedStack, setSelectedStack] = useState<string>('all');
const [selectedLevel, setSelectedLevel] = useState<string>('all');
const fetchData = async () => {
setLoading(true);
try {
const [metricsRes, logsRes] = await Promise.all([
fetch('/api/metrics/historical'),
fetch('/api/logs/global')
]);
if (metricsRes.ok) {
setMetrics(await metricsRes.json());
}
if (logsRes.ok) {
setLogs(await logsRes.json());
}
} catch (error) {
console.error('Failed to fetch observability data:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
const interval = setInterval(fetchData, 30000); // 30s auto-refresh
return () => clearInterval(interval);
}, []);
// Aggregate metrics by timestamp (minute buckets) for the chart
const chartData = useMemo(() => {
const buckets: Record<string, { time: string; timestamp: number; cpu: number; ram: number }> = {};
metrics.forEach(m => {
// Bucket by minute (ignoring seconds)
const date = new Date(m.timestamp);
date.setSeconds(0, 0);
const key = date.getTime() + '';
if (!buckets[key]) {
buckets[key] = {
time: date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
timestamp: date.getTime(),
cpu: 0,
ram: 0
};
}
buckets[key].cpu += m.cpu_percent;
buckets[key].ram += m.memory_mb;
});
return Object.values(buckets).sort((a, b) => a.timestamp - b.timestamp);
}, [metrics]);
const chartConfig = {
cpu: { label: 'CPU Usage (%)', color: 'var(--chart-1)' },
ram: { label: 'RAM Usage (MB)', color: 'var(--chart-2)' },
};
// Filter logs
const filteredLogs = useMemo(() => {
return logs.filter(log => {
if (selectedStack !== 'all' && log.stackName !== selectedStack) return false;
if (selectedLevel !== 'all' && log.level !== selectedLevel) return false;
return true;
});
}, [logs, selectedStack, selectedLevel]);
const uniqueStacks = useMemo(() => {
const stacks = new Set(logs.map(l => l.stackName));
return Array.from(stacks).sort();
}, [logs]);
return (
<div className="flex flex-col h-full bg-background text-foreground overflow-auto p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Global Observability</h1>
<p className="text-muted-foreground mt-1">24-hour historical metrics and centralized logging.</p>
</div>
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading}>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
{/* Charts Section */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Activity className="w-5 h-5 text-primary" />
<span>Aggregate CPU Usage</span>
</CardTitle>
<CardDescription>Total CPU percentage across all containers.</CardDescription>
</CardHeader>
<CardContent className="h-[300px]">
{chartData.length > 0 ? (
<ChartContainer config={chartConfig} className="w-full h-full">
<AreaChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="time" minTickGap={30} tickMargin={8} />
<YAxis tickFormatter={(val) => `${val}%`} />
<ChartTooltip content={<ChartTooltipContent />} />
<Area
type="monotone"
dataKey="cpu"
stroke="var(--color-cpu)"
fill="var(--color-cpu)"
fillOpacity={0.4}
/>
</AreaChart>
</ChartContainer>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground">
No historical CPU data available.
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Activity className="w-5 h-5 text-primary" />
<span>Aggregate Memory Usage</span>
</CardTitle>
<CardDescription>Total Memory (MB) allocated across all containers.</CardDescription>
</CardHeader>
<CardContent className="h-[300px]">
{chartData.length > 0 ? (
<ChartContainer config={chartConfig} className="w-full h-full">
<AreaChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="time" minTickGap={30} tickMargin={8} />
<YAxis tickFormatter={(val) => `${val}MB`} />
<ChartTooltip content={<ChartTooltipContent />} />
<Area
type="monotone"
dataKey="ram"
stroke="var(--color-ram)"
fill="var(--color-ram)"
fillOpacity={0.4}
/>
</AreaChart>
</ChartContainer>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground">
No historical Memory data available.
</div>
)}
</CardContent>
</Card>
</div>
{/* Global Logs Section */}
<Card className="flex-1 flex flex-col overflow-hidden min-h-[400px]">
<CardHeader className="py-4 border-b">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center space-y-4 sm:space-y-0">
<CardTitle className="flex items-center space-x-2">
<Terminal className="w-5 h-5 text-primary" />
<span>Unified Global Logs</span>
</CardTitle>
<div className="flex items-center space-x-4">
<Select value={selectedStack} onValueChange={setSelectedStack}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Filter by Stack" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Stacks</SelectItem>
{uniqueStacks.map(s => (
<SelectItem key={s} value={s}>{s}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={selectedLevel} onValueChange={setSelectedLevel}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Log Level" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Levels</SelectItem>
<SelectItem value="INFO">INFO</SelectItem>
<SelectItem value="WARN">WARN</SelectItem>
<SelectItem value="ERROR">ERROR</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardHeader>
<CardContent className="p-0 overflow-hidden bg-black text-gray-300 flex-1 flex flex-col">
<ScrollArea className="flex-1 p-4 font-mono text-xs sm:text-sm">
{filteredLogs.length > 0 ? (
filteredLogs.map((log, idx) => (
<div key={idx} className="mb-1 leading-relaxed whitespace-pre-wrap break-all hover:bg-white/5 px-2 py-0.5 rounded -mx-2">
<span className="text-gray-500 mr-2">[{new Date(log.timestamp).toLocaleTimeString()}]</span>
<span className="text-blue-400 font-semibold mr-2">[{log.stackName}/{log.containerName}]</span>
<span className={`mr-2 font-bold ${log.level === 'ERROR' ? 'text-red-500' : log.level === 'WARN' ? 'text-yellow-500' : 'text-green-500'}`}>
{log.level}:
</span>
<span className={log.level === 'ERROR' ? 'text-red-300' : 'text-gray-300'}>{log.message}</span>
</div>
))
) : (
<div className="text-gray-500 italic p-4 text-center">No logs found matching criteria.</div>
)}
</ScrollArea>
</CardContent>
</Card>
</div>
);
}
+367
View File
@@ -0,0 +1,367 @@
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
})
ChartContainer.displayName = "Chart"
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref
) => {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
)
ChartTooltipContent.displayName = "ChartTooltip"
const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}
>(
(
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
ref
) => {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
)
ChartLegendContent.displayName = "ChartLegend"
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}