mirror of
https://github.com/selfhosters-cc/container-census.git
synced 2026-05-15 01:18:24 -05:00
Add Portainer-style table view for containers with inline charts
- Created new table view component with sortable columns - Added view toggle (cards/table) with localStorage persistence - Extracted InlineChart component for reusability - Added compact chart mode for table rows (40px height) - Implemented bulk selection with checkboxes - Created containerUtils.ts with shared formatting functions - Added responsive column hiding for smaller screens - Mobile devices force card view (<768px) - Improved error handling for container actions - All modals and features work in both views 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,265 +3,19 @@
|
||||
import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
|
||||
import { getContainers, getHosts, startContainer, stopContainer, restartContainer, removeContainer, getContainerLogs, getContainerStats, updateContainer, bulkCheckUpdates, bulkUpdate, getContainerLifecycleEvents, getContainerLifecycleSummaries } from '@/lib/api';
|
||||
import type { Container, Host, ContainerStatsPoint, ContainerLifecycleEvent, ContainerLifecycleSummary } from '@/types';
|
||||
import { formatUptime, extractImageTag, formatCpu, formatMemory as formatMemoryUtil, getStateIcon } from '@/lib/containerUtils';
|
||||
import InlineChart from '@/components/containers/InlineChart';
|
||||
import ViewToggle from '@/components/containers/ViewToggle';
|
||||
import ContainerTable from '@/components/containers/ContainerTable';
|
||||
|
||||
// Chart.js imports (will be loaded from CDN in production, this is for types)
|
||||
// Chart.js declaration for StatsModal
|
||||
declare const Chart: {
|
||||
new (ctx: CanvasRenderingContext2D, config: unknown): {
|
||||
destroy: () => void;
|
||||
update: () => void;
|
||||
};
|
||||
getChart: (id: string | HTMLCanvasElement) => { destroy: () => void } | undefined;
|
||||
};
|
||||
|
||||
function formatUptime(startedAt: string | undefined, endAt?: string): string {
|
||||
if (!startedAt) return '';
|
||||
|
||||
const end = endAt ? new Date(endAt) : new Date();
|
||||
const started = new Date(startedAt);
|
||||
|
||||
if (isNaN(started.getTime()) || started.getFullYear() < 2000) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const diff = end.getTime() - started.getTime();
|
||||
if (diff < 0) return '';
|
||||
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (days > 0) {
|
||||
const remainingHours = hours % 24;
|
||||
return `${days}d ${remainingHours}h`;
|
||||
}
|
||||
if (hours > 0) {
|
||||
const remainingMins = minutes % 60;
|
||||
return `${hours}h ${remainingMins}m`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
function extractImageTag(image: string, imageTags?: string[]): string {
|
||||
if (imageTags && imageTags.length > 0) {
|
||||
const tag = imageTags[0];
|
||||
const parts = tag.split(':');
|
||||
return parts[parts.length - 1] || 'latest';
|
||||
}
|
||||
const parts = image.split(':');
|
||||
return parts[parts.length - 1] || 'latest';
|
||||
}
|
||||
|
||||
// Inline sparkline chart component for container cards
|
||||
function InlineChart({ hostId, containerId }: { hostId: number; containerId: string }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const chartRef = useRef<ReturnType<typeof Chart.prototype.constructor> | null>(null);
|
||||
const [hasData, setHasData] = useState<boolean | null>(null); // null = loading
|
||||
const [chartLoaded, setChartLoaded] = useState(false);
|
||||
|
||||
// Wait for Chart.js to load
|
||||
useEffect(() => {
|
||||
let attempts = 0;
|
||||
const maxAttempts = 50; // 5 seconds total
|
||||
|
||||
const checkChartJs = () => {
|
||||
if (typeof Chart !== 'undefined') {
|
||||
console.log('[InlineChart] Chart.js loaded successfully');
|
||||
setChartLoaded(true);
|
||||
} else if (attempts < maxAttempts) {
|
||||
attempts++;
|
||||
setTimeout(checkChartJs, 100);
|
||||
} else {
|
||||
console.error('[InlineChart] Chart.js failed to load after 5 seconds');
|
||||
setHasData(false);
|
||||
}
|
||||
};
|
||||
checkChartJs();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chartLoaded) return;
|
||||
|
||||
let mounted = true;
|
||||
|
||||
const loadAndRender = async () => {
|
||||
try {
|
||||
const stats = await getContainerStats(hostId, containerId, '1h');
|
||||
console.log(`[InlineChart] Stats for ${containerId}:`, stats?.length || 0, 'points');
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (!stats || stats.length === 0) {
|
||||
console.log(`[InlineChart] No stats data for ${containerId}`);
|
||||
setHasData(false);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[InlineChart] Rendering chart for ${containerId}`);
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) {
|
||||
console.log(`[InlineChart] Canvas ref is null for ${containerId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Destroy existing chart
|
||||
if (chartRef.current) {
|
||||
chartRef.current.destroy();
|
||||
chartRef.current = null;
|
||||
}
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
console.log(`[InlineChart] Could not get 2d context for ${containerId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Take last 20 points for sparkline
|
||||
const recentStats = stats.slice(-20);
|
||||
const cpuData = recentStats.map((s: ContainerStatsPoint) => s.cpu_percent || 0);
|
||||
const memoryData = recentStats.map((s: ContainerStatsPoint) => (s.memory_usage || 0) / 1024 / 1024);
|
||||
|
||||
// Set canvas dimensions explicitly
|
||||
const parentWidth = canvas.parentElement?.offsetWidth || 500;
|
||||
canvas.width = parentWidth;
|
||||
canvas.height = 128;
|
||||
console.log(`[InlineChart] Canvas dimensions for ${containerId}: ${canvas.width}x${canvas.height}`);
|
||||
|
||||
chartRef.current = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: recentStats.map(() => ''),
|
||||
datasets: [
|
||||
{
|
||||
label: 'CPU %',
|
||||
data: cpuData,
|
||||
borderColor: 'rgb(75, 192, 192)',
|
||||
backgroundColor: 'rgba(75, 192, 192, 0.1)',
|
||||
borderWidth: 2,
|
||||
pointRadius: 0,
|
||||
tension: 0.4,
|
||||
yAxisID: 'y',
|
||||
fill: true,
|
||||
},
|
||||
{
|
||||
label: 'Memory MB',
|
||||
data: memoryData,
|
||||
borderColor: 'rgb(255, 99, 132)',
|
||||
backgroundColor: 'rgba(255, 99, 132, 0.1)',
|
||||
borderWidth: 2,
|
||||
pointRadius: 0,
|
||||
tension: 0.4,
|
||||
yAxisID: 'y1',
|
||||
fill: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top',
|
||||
labels: {
|
||||
boxWidth: 10,
|
||||
padding: 6,
|
||||
font: { size: 10 },
|
||||
color: '#94a3b8',
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
callbacks: {
|
||||
label: function(context: { dataset: { label?: string; yAxisID?: string }; parsed: { y: number | null } }) {
|
||||
let label = context.dataset.label || '';
|
||||
if (label) label += ': ';
|
||||
if (context.parsed.y !== null) {
|
||||
label += context.parsed.y.toFixed(2);
|
||||
if (context.dataset.yAxisID === 'y') {
|
||||
label += '%';
|
||||
} else {
|
||||
label += ' MB';
|
||||
}
|
||||
}
|
||||
return label;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: { display: false },
|
||||
y: {
|
||||
display: true,
|
||||
beginAtZero: true,
|
||||
position: 'left',
|
||||
title: { display: true, text: 'CPU %', font: { size: 9 }, color: '#94a3b8' },
|
||||
ticks: { font: { size: 8 }, color: '#94a3b8' },
|
||||
grid: { color: 'rgba(148, 163, 184, 0.1)' },
|
||||
},
|
||||
y1: {
|
||||
display: true,
|
||||
beginAtZero: true,
|
||||
position: 'right',
|
||||
title: { display: true, text: 'Memory MB', font: { size: 9 }, color: '#94a3b8' },
|
||||
ticks: { font: { size: 8 }, color: '#94a3b8' },
|
||||
grid: { drawOnChartArea: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`[InlineChart] Chart created successfully for ${containerId}`);
|
||||
setHasData(true);
|
||||
} catch (error) {
|
||||
console.error('Error loading inline chart:', error);
|
||||
if (mounted) setHasData(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadAndRender();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
if (chartRef.current) {
|
||||
chartRef.current.destroy();
|
||||
chartRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [chartLoaded, hostId, containerId]);
|
||||
|
||||
return (
|
||||
<div className="h-32 relative">
|
||||
<canvas ref={canvasRef} className="w-full h-full"></canvas>
|
||||
{!chartLoaded && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-xs text-[var(--text-secondary)] bg-[var(--bg-tertiary)]">
|
||||
Loading Chart.js...
|
||||
</div>
|
||||
)}
|
||||
{chartLoaded && hasData === null && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-xs text-[var(--text-secondary)] bg-[var(--bg-tertiary)]">
|
||||
Loading chart data...
|
||||
</div>
|
||||
)}
|
||||
{chartLoaded && hasData === false && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-xs text-[var(--text-secondary)] bg-[var(--bg-tertiary)]">
|
||||
No stats data available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Logs Modal Component
|
||||
function LogsModal({ container, onClose }: { container: Container | null; onClose: () => void }) {
|
||||
const [logs, setLogs] = useState('');
|
||||
@@ -1420,6 +1174,54 @@ export default function ContainersPage() {
|
||||
const [updateContainer, setUpdateContainer] = useState<Container | null>(null);
|
||||
const [showBulkUpdateModal, setShowBulkUpdateModal] = useState(false);
|
||||
|
||||
// View toggle state
|
||||
const [view, setView] = useState<'cards' | 'table'>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return (localStorage.getItem('containerView') as 'cards' | 'table') || 'cards';
|
||||
}
|
||||
return 'cards';
|
||||
});
|
||||
|
||||
// Bulk selection state
|
||||
const [selectedContainers, setSelectedContainers] = useState<Set<string>>(new Set());
|
||||
|
||||
// Mobile detection
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => setIsMobile(window.innerWidth < 768);
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
const handleViewChange = (newView: 'cards' | 'table') => {
|
||||
setView(newView);
|
||||
localStorage.setItem('containerView', newView);
|
||||
};
|
||||
|
||||
const toggleSelection = (containerId: string) => {
|
||||
setSelectedContainers(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(containerId)) {
|
||||
next.delete(containerId);
|
||||
} else {
|
||||
next.add(containerId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedContainers.size === filteredContainers.length) {
|
||||
setSelectedContainers(new Set());
|
||||
} else {
|
||||
setSelectedContainers(new Set(filteredContainers.map(c => c.id)));
|
||||
}
|
||||
};
|
||||
|
||||
const clearSelection = () => setSelectedContainers(new Set());
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [containersData, hostsData, lifecycleData] = await Promise.all([
|
||||
@@ -1488,17 +1290,23 @@ export default function ContainersPage() {
|
||||
};
|
||||
}, [containers]);
|
||||
|
||||
// Create a lookup map for lifecycle data by container name and host
|
||||
// Create a lookup map for lifecycle data by container id and host
|
||||
const lifecycleMap = useMemo(() => {
|
||||
const map = new Map<string, ContainerLifecycleSummary>();
|
||||
if (Array.isArray(lifecycleSummaries)) {
|
||||
lifecycleSummaries.forEach(summary => {
|
||||
const key = `${summary.host_id}-${summary.container_name}`;
|
||||
map.set(key, summary);
|
||||
// Try to find matching container by name and host
|
||||
const matchingContainer = containers.find(
|
||||
c => c.name === summary.container_name && c.host_id === summary.host_id
|
||||
);
|
||||
if (matchingContainer) {
|
||||
const key = `${matchingContainer.id}-${matchingContainer.host_id}`;
|
||||
map.set(key, summary);
|
||||
}
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}, [lifecycleSummaries]);
|
||||
}, [lifecycleSummaries, containers]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -1529,61 +1337,107 @@ export default function ContainersPage() {
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search containers..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="flex-1 min-w-[200px] bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg px-4 py-2 text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent)]"
|
||||
/>
|
||||
<select
|
||||
value={hostFilter}
|
||||
onChange={(e) => setHostFilter(e.target.value)}
|
||||
className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg px-4 py-2 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
|
||||
>
|
||||
<option value="">All Hosts</option>
|
||||
{hosts.map(host => (
|
||||
<option key={host.id} value={host.id.toString()}>{host.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={stateFilter}
|
||||
onChange={(e) => setStateFilter(e.target.value)}
|
||||
className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg px-4 py-2 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
|
||||
>
|
||||
<option value="">All States</option>
|
||||
<option value="running">Running</option>
|
||||
<option value="exited">Stopped</option>
|
||||
<option value="paused">Paused</option>
|
||||
</select>
|
||||
<div className="flex flex-wrap gap-4 items-center justify-between">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search containers..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="flex-1 min-w-[200px] bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg px-4 py-2 text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent)]"
|
||||
/>
|
||||
<select
|
||||
value={hostFilter}
|
||||
onChange={(e) => setHostFilter(e.target.value)}
|
||||
className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg px-4 py-2 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
|
||||
>
|
||||
<option value="">All Hosts</option>
|
||||
{hosts.map(host => (
|
||||
<option key={host.id} value={host.id.toString()}>{host.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={stateFilter}
|
||||
onChange={(e) => setStateFilter(e.target.value)}
|
||||
className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg px-4 py-2 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
|
||||
>
|
||||
<option value="">All States</option>
|
||||
<option value="running">Running</option>
|
||||
<option value="exited">Stopped</option>
|
||||
<option value="paused">Paused</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* View Toggle - hide on mobile */}
|
||||
{!isMobile && (
|
||||
<ViewToggle view={view} onChange={handleViewChange} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Container Grid */}
|
||||
{/* Container Grid or Table */}
|
||||
{filteredContainers.length === 0 ? (
|
||||
<div className="text-center py-12 text-[var(--text-tertiary)]">
|
||||
No containers found
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{filteredContainers.map(container => {
|
||||
const lifecycleKey = `${container.host_id}-${container.name}`;
|
||||
const lifecycleSummary = lifecycleMap.get(lifecycleKey);
|
||||
<>
|
||||
{/* Effective view - force cards on mobile */}
|
||||
{(!isMobile && view === 'table') ? (
|
||||
<ContainerTable
|
||||
containers={filteredContainers}
|
||||
lifecycleSummaries={lifecycleMap}
|
||||
selectedContainers={selectedContainers}
|
||||
onToggleSelection={toggleSelection}
|
||||
onToggleSelectAll={toggleSelectAll}
|
||||
onShowLogs={setLogsContainer}
|
||||
onShowStats={setStatsContainer}
|
||||
onShowHistory={setHistoryContainer}
|
||||
onAction={async (container, action) => {
|
||||
try {
|
||||
if (action === 'start') {
|
||||
await startContainer(container.host_id, container.id);
|
||||
await loadData();
|
||||
} else if (action === 'stop') {
|
||||
await stopContainer(container.host_id, container.id);
|
||||
await loadData();
|
||||
} else if (action === 'restart') {
|
||||
await restartContainer(container.host_id, container.id);
|
||||
await loadData();
|
||||
} else if (action === 'remove') {
|
||||
if (confirm(`Are you sure you want to remove container "${container.name}"?`)) {
|
||||
await removeContainer(container.host_id, container.id);
|
||||
await loadData();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to ${action} container "${container.name}":`, error);
|
||||
alert(`Failed to ${action} container: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}}
|
||||
onUpdate={setUpdateContainer}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{filteredContainers.map(container => {
|
||||
const lifecycleKey = `${container.host_id}-${container.name}`;
|
||||
const lifecycleSummary = lifecycleMap.get(lifecycleKey);
|
||||
|
||||
return (
|
||||
<ContainerCard
|
||||
key={`${container.host_id}-${container.id}`}
|
||||
container={container}
|
||||
lifecycleSummary={lifecycleSummary}
|
||||
onAction={loadData}
|
||||
onViewLogs={setLogsContainer}
|
||||
onViewStats={setStatsContainer}
|
||||
onViewHistory={setHistoryContainer}
|
||||
onViewUpdate={setUpdateContainer}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
return (
|
||||
<ContainerCard
|
||||
key={`${container.host_id}-${container.id}`}
|
||||
container={container}
|
||||
lifecycleSummary={lifecycleSummary}
|
||||
onAction={loadData}
|
||||
onViewLogs={setLogsContainer}
|
||||
onViewStats={setStatsContainer}
|
||||
onViewHistory={setHistoryContainer}
|
||||
onViewUpdate={setUpdateContainer}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Modals */}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
--bg-primary: #0f172a;
|
||||
--bg-secondary: #1e293b;
|
||||
--bg-tertiary: #334155;
|
||||
--bg-hover: rgba(59, 130, 246, 0.05);
|
||||
--text-primary: #f8fafc;
|
||||
--text-secondary: #cbd5e1;
|
||||
--text-tertiary: #94a3b8;
|
||||
@@ -559,3 +560,86 @@ body {
|
||||
border-color: var(--border);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Container Table Styles */
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.table-container table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
min-width: 1200px;
|
||||
}
|
||||
|
||||
.table-container th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg-secondary);
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
font-size: 0.875rem;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.table-container th.sortable {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.table-container tbody tr {
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.table-container tbody tr:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* Checkbox styles */
|
||||
.checkbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
accent-color: #3b82f6;
|
||||
}
|
||||
|
||||
/* Button icon styles */
|
||||
.btn-icon {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
transition: transform 0.1s;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
.btn-icon:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Responsive column hiding for table */
|
||||
@media (max-width: 1599px) {
|
||||
.col-lifetime {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1199px) {
|
||||
.col-uptime,
|
||||
.col-ports,
|
||||
.col-cpu,
|
||||
.col-memory {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, Fragment } from 'react';
|
||||
import type { Container, ContainerLifecycleSummary } from '@/types';
|
||||
import {
|
||||
formatPorts,
|
||||
formatMemory,
|
||||
formatUptime,
|
||||
formatLifetime,
|
||||
formatCpu,
|
||||
getStateBadgeClass,
|
||||
getStateIcon,
|
||||
extractImageTag,
|
||||
} from '@/lib/containerUtils';
|
||||
import InlineChart from './InlineChart';
|
||||
|
||||
interface ContainerTableProps {
|
||||
containers: Container[];
|
||||
lifecycleSummaries: Map<string, ContainerLifecycleSummary>;
|
||||
selectedContainers: Set<string>;
|
||||
onToggleSelection: (id: string) => void;
|
||||
onToggleSelectAll: () => void;
|
||||
onShowLogs: (container: Container) => void;
|
||||
onShowStats: (container: Container) => void;
|
||||
onShowHistory: (container: Container) => void;
|
||||
onAction: (container: Container, action: 'start' | 'stop' | 'restart' | 'remove') => void;
|
||||
onUpdate: (container: Container) => void;
|
||||
}
|
||||
|
||||
type SortKey = 'name' | 'state' | 'host' | 'image' | 'uptime' | 'lifetime' | 'cpu' | 'memory';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
export default function ContainerTable({
|
||||
containers,
|
||||
lifecycleSummaries,
|
||||
selectedContainers,
|
||||
onToggleSelection,
|
||||
onToggleSelectAll,
|
||||
onShowLogs,
|
||||
onShowStats,
|
||||
onShowHistory,
|
||||
onAction,
|
||||
onUpdate,
|
||||
}: ContainerTableProps) {
|
||||
const [sortConfig, setSortConfig] = useState<{ key: SortKey; direction: SortDirection } | null>(null);
|
||||
|
||||
const handleSort = (key: SortKey) => {
|
||||
setSortConfig((prev) => {
|
||||
if (prev?.key === key) {
|
||||
// Toggle direction
|
||||
return { key, direction: prev.direction === 'asc' ? 'desc' : 'asc' };
|
||||
}
|
||||
// New sort key, default to ascending
|
||||
return { key, direction: 'asc' };
|
||||
});
|
||||
};
|
||||
|
||||
const sortedContainers = useMemo(() => {
|
||||
if (!sortConfig) return containers;
|
||||
|
||||
const sorted = [...containers].sort((a, b) => {
|
||||
let aVal: string | number | undefined;
|
||||
let bVal: string | number | undefined;
|
||||
|
||||
switch (sortConfig.key) {
|
||||
case 'name':
|
||||
aVal = a.name.toLowerCase();
|
||||
bVal = b.name.toLowerCase();
|
||||
break;
|
||||
case 'state':
|
||||
// running > paused > exited
|
||||
const stateOrder: Record<string, number> = { running: 0, paused: 1, exited: 2, restarting: 3, dead: 4 };
|
||||
aVal = stateOrder[a.state] ?? 99;
|
||||
bVal = stateOrder[b.state] ?? 99;
|
||||
break;
|
||||
case 'host':
|
||||
aVal = a.host_name.toLowerCase();
|
||||
bVal = b.host_name.toLowerCase();
|
||||
break;
|
||||
case 'image':
|
||||
aVal = a.image.toLowerCase();
|
||||
bVal = b.image.toLowerCase();
|
||||
break;
|
||||
case 'uptime':
|
||||
// Calculate uptime in seconds
|
||||
aVal = a.started_at ? new Date().getTime() - new Date(a.started_at).getTime() : 0;
|
||||
bVal = b.started_at ? new Date().getTime() - new Date(b.started_at).getTime() : 0;
|
||||
break;
|
||||
case 'lifetime':
|
||||
// Get from lifecycle summary
|
||||
const aLifetime = lifecycleSummaries.get(`${a.id}-${a.host_id}`);
|
||||
const bLifetime = lifecycleSummaries.get(`${b.id}-${b.host_id}`);
|
||||
aVal = aLifetime?.first_seen ? new Date().getTime() - new Date(aLifetime.first_seen).getTime() : 0;
|
||||
bVal = bLifetime?.first_seen ? new Date().getTime() - new Date(bLifetime.first_seen).getTime() : 0;
|
||||
break;
|
||||
case 'cpu':
|
||||
aVal = a.cpu_percent ?? -1; // Sort nulls to end
|
||||
bVal = b.cpu_percent ?? -1;
|
||||
break;
|
||||
case 'memory':
|
||||
aVal = a.memory_usage ?? -1; // Sort nulls to end
|
||||
bVal = b.memory_usage ?? -1;
|
||||
break;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (typeof aVal === 'string' && typeof bVal === 'string') {
|
||||
return sortConfig.direction === 'asc'
|
||||
? aVal.localeCompare(bVal)
|
||||
: bVal.localeCompare(aVal);
|
||||
}
|
||||
|
||||
if (typeof aVal === 'number' && typeof bVal === 'number') {
|
||||
return sortConfig.direction === 'asc' ? aVal - bVal : bVal - aVal;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
return sorted;
|
||||
}, [containers, sortConfig, lifecycleSummaries]);
|
||||
|
||||
const SortHeader = ({ column, children }: { column: SortKey; children: React.ReactNode }) => (
|
||||
<th
|
||||
onClick={() => handleSort(column)}
|
||||
className="px-4 py-3 text-left cursor-pointer hover:bg-[var(--bg-hover)] transition-colors select-none"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{children}
|
||||
{sortConfig?.key === column && (
|
||||
<span className="text-xs">{sortConfig.direction === 'asc' ? '↑' : '↓'}</span>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="table-container border border-[var(--border)] rounded-lg overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[1200px]">
|
||||
<thead className="bg-[var(--bg-secondary)] border-b border-[var(--border)] sticky top-0 z-10">
|
||||
<tr>
|
||||
<th className="w-10 px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedContainers.size === containers.length && containers.length > 0}
|
||||
onChange={onToggleSelectAll}
|
||||
className="checkbox cursor-pointer"
|
||||
/>
|
||||
</th>
|
||||
<SortHeader column="name">Name</SortHeader>
|
||||
<SortHeader column="state">State</SortHeader>
|
||||
<SortHeader column="host">Host</SortHeader>
|
||||
<SortHeader column="image">Image</SortHeader>
|
||||
<SortHeader column="uptime">Uptime</SortHeader>
|
||||
<SortHeader column="lifetime">
|
||||
<span className="hidden xl:inline">Lifetime</span>
|
||||
<span className="xl:hidden">Life</span>
|
||||
</SortHeader>
|
||||
<th className="px-4 py-3 text-left">
|
||||
<span className="hidden lg:inline">Ports</span>
|
||||
<span className="lg:hidden">P</span>
|
||||
</th>
|
||||
<SortHeader column="cpu">
|
||||
<span className="hidden lg:inline">CPU %</span>
|
||||
<span className="lg:hidden">CPU</span>
|
||||
</SortHeader>
|
||||
<SortHeader column="memory">
|
||||
<span className="hidden lg:inline">Memory</span>
|
||||
<span className="lg:hidden">Mem</span>
|
||||
</SortHeader>
|
||||
<th className="px-4 py-3 text-left">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedContainers.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={11} className="px-4 py-8 text-center text-[var(--text-secondary)]">
|
||||
No containers found matching current filters
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
sortedContainers.map((container) => {
|
||||
const lifecycle = lifecycleSummaries.get(`${container.id}-${container.host_id}`);
|
||||
const isSelected = selectedContainers.has(container.id);
|
||||
const isRunning = container.state === 'running';
|
||||
|
||||
return (
|
||||
<Fragment key={`${container.id}-${container.host_id}`}>
|
||||
{/* Main data row */}
|
||||
<tr className="border-b border-[var(--border)] hover:bg-[var(--bg-hover)] transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => onToggleSelection(container.id)}
|
||||
className="checkbox cursor-pointer"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-medium truncate max-w-[200px]" title={container.name}>
|
||||
{container.name}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium ${getStateBadgeClass(
|
||||
container.state
|
||||
)}`}
|
||||
>
|
||||
<span>{getStateIcon(container.state)}</span>
|
||||
<span>{container.state}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 truncate max-w-[150px]" title={container.host_name}>
|
||||
{container.host_name}
|
||||
</td>
|
||||
<td className="px-4 py-3 truncate max-w-[250px]" title={container.image}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate">{container.image}</span>
|
||||
{container.update_available && (
|
||||
<button
|
||||
onClick={() => onUpdate(container)}
|
||||
className="text-xs bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400 px-2 py-0.5 rounded hover:bg-blue-200 dark:hover:bg-blue-900/50 transition-colors flex-shrink-0"
|
||||
title="Update available"
|
||||
>
|
||||
⬆️ Update
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">{formatUptime(container.started_at)}</td>
|
||||
<td className="px-4 py-3 text-sm hidden xl:table-cell">
|
||||
{lifecycle ? formatUptime(lifecycle.first_seen) : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm hidden lg:table-cell truncate max-w-[180px]" title={formatPorts(container.ports, 10)}>
|
||||
{formatPorts(container.ports)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm hidden lg:table-cell">{formatCpu(container.cpu_percent)}</td>
|
||||
<td className="px-4 py-3 text-sm hidden lg:table-cell" title={formatMemory(container.memory_usage, container.memory_limit)}>
|
||||
{formatMemory(container.memory_usage, container.memory_limit)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => onShowLogs(container)}
|
||||
className="btn-icon"
|
||||
title="View logs"
|
||||
>
|
||||
📄
|
||||
</button>
|
||||
{isRunning && (
|
||||
<button
|
||||
onClick={() => onShowStats(container)}
|
||||
className="btn-icon"
|
||||
title="View stats"
|
||||
>
|
||||
📊
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onShowHistory(container)}
|
||||
className="btn-icon"
|
||||
title="View history"
|
||||
>
|
||||
📜
|
||||
</button>
|
||||
{isRunning ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => onAction(container, 'restart')}
|
||||
className="btn-icon"
|
||||
title="Restart"
|
||||
>
|
||||
🔄
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onAction(container, 'stop')}
|
||||
className="btn-icon"
|
||||
title="Stop"
|
||||
>
|
||||
⏹️
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => onAction(container, 'start')}
|
||||
className="btn-icon"
|
||||
title="Start"
|
||||
>
|
||||
▶️
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onAction(container, 'remove')}
|
||||
className="btn-icon text-red-600 dark:text-red-400"
|
||||
title="Remove"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Chart row - only for running containers */}
|
||||
{isRunning && (
|
||||
<tr className="border-b border-[var(--border)] bg-[var(--bg-primary)]">
|
||||
<td colSpan={11} className="p-2">
|
||||
<InlineChart hostId={container.host_id} containerId={container.id} compact={true} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { getContainerStats } from '@/lib/api';
|
||||
import type { ContainerStatsPoint } from '@/types';
|
||||
|
||||
// Chart.js imports (loaded from CDN)
|
||||
declare const Chart: {
|
||||
new (ctx: CanvasRenderingContext2D, config: unknown): {
|
||||
destroy: () => void;
|
||||
update: () => void;
|
||||
};
|
||||
};
|
||||
|
||||
interface InlineChartProps {
|
||||
hostId: number;
|
||||
containerId: string;
|
||||
compact?: boolean; // true for table view (40px), false for card view (128px)
|
||||
}
|
||||
|
||||
export default function InlineChart({ hostId, containerId, compact = false }: InlineChartProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const chartRef = useRef<ReturnType<typeof Chart.prototype.constructor> | null>(null);
|
||||
const [hasData, setHasData] = useState<boolean | null>(null); // null = loading
|
||||
const [chartLoaded, setChartLoaded] = useState(false);
|
||||
|
||||
const height = compact ? 40 : 128;
|
||||
|
||||
// Wait for Chart.js to load
|
||||
useEffect(() => {
|
||||
let attempts = 0;
|
||||
const maxAttempts = 50; // 5 seconds total
|
||||
|
||||
const checkChartJs = () => {
|
||||
if (typeof Chart !== 'undefined') {
|
||||
console.log('[InlineChart] Chart.js loaded successfully');
|
||||
setChartLoaded(true);
|
||||
} else if (attempts < maxAttempts) {
|
||||
attempts++;
|
||||
setTimeout(checkChartJs, 100);
|
||||
} else {
|
||||
console.error('[InlineChart] Chart.js failed to load after 5 seconds');
|
||||
setHasData(false);
|
||||
}
|
||||
};
|
||||
checkChartJs();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chartLoaded) return;
|
||||
|
||||
let mounted = true;
|
||||
|
||||
const loadAndRender = async () => {
|
||||
try {
|
||||
const stats = await getContainerStats(hostId, containerId, '1h');
|
||||
console.log(`[InlineChart] Stats for ${containerId}:`, stats?.length || 0, 'points');
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (!stats || stats.length === 0) {
|
||||
console.log(`[InlineChart] No stats data for ${containerId}`);
|
||||
setHasData(false);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[InlineChart] Rendering chart for ${containerId}`);
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) {
|
||||
console.log(`[InlineChart] Canvas ref is null for ${containerId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Destroy existing chart
|
||||
if (chartRef.current) {
|
||||
chartRef.current.destroy();
|
||||
chartRef.current = null;
|
||||
}
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
console.log(`[InlineChart] Could not get 2d context for ${containerId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Take last 20 points for sparkline
|
||||
const recentStats = stats.slice(-20);
|
||||
const cpuData = recentStats.map((s: ContainerStatsPoint) => s.cpu_percent || 0);
|
||||
const memoryData = recentStats.map((s: ContainerStatsPoint) => (s.memory_usage || 0) / 1024 / 1024);
|
||||
|
||||
// Set canvas dimensions explicitly
|
||||
const parentWidth = canvas.parentElement?.offsetWidth || 500;
|
||||
canvas.width = parentWidth;
|
||||
canvas.height = height;
|
||||
console.log(`[InlineChart] Canvas dimensions for ${containerId}: ${canvas.width}x${canvas.height}`);
|
||||
|
||||
chartRef.current = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: recentStats.map(() => ''),
|
||||
datasets: [
|
||||
{
|
||||
label: 'CPU %',
|
||||
data: cpuData,
|
||||
borderColor: 'rgb(75, 192, 192)',
|
||||
backgroundColor: 'rgba(75, 192, 192, 0.1)',
|
||||
borderWidth: compact ? 1.5 : 2,
|
||||
pointRadius: 0,
|
||||
tension: 0.4,
|
||||
yAxisID: 'y',
|
||||
fill: true,
|
||||
},
|
||||
{
|
||||
label: 'Memory MB',
|
||||
data: memoryData,
|
||||
borderColor: 'rgb(255, 99, 132)',
|
||||
backgroundColor: 'rgba(255, 99, 132, 0.1)',
|
||||
borderWidth: compact ? 1.5 : 2,
|
||||
pointRadius: 0,
|
||||
tension: 0.4,
|
||||
yAxisID: 'y1',
|
||||
fill: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: !compact, // Hide legend in compact mode
|
||||
position: 'top',
|
||||
labels: {
|
||||
boxWidth: 10,
|
||||
padding: 6,
|
||||
font: { size: 10 },
|
||||
color: '#94a3b8',
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
callbacks: {
|
||||
label: function(context: { dataset: { label?: string; yAxisID?: string }; parsed: { y: number | null } }) {
|
||||
let label = context.dataset.label || '';
|
||||
if (label) label += ': ';
|
||||
if (context.parsed.y !== null) {
|
||||
label += context.parsed.y.toFixed(2);
|
||||
if (context.dataset.yAxisID === 'y') {
|
||||
label += '%';
|
||||
} else {
|
||||
label += ' MB';
|
||||
}
|
||||
}
|
||||
return label;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: { display: false },
|
||||
y: {
|
||||
display: !compact, // Hide axis labels in compact mode
|
||||
beginAtZero: true,
|
||||
position: 'left',
|
||||
title: {
|
||||
display: !compact,
|
||||
text: 'CPU %',
|
||||
font: { size: 9 },
|
||||
color: '#94a3b8'
|
||||
},
|
||||
ticks: {
|
||||
display: !compact,
|
||||
font: { size: 8 },
|
||||
color: '#94a3b8'
|
||||
},
|
||||
grid: {
|
||||
display: !compact,
|
||||
color: 'rgba(148, 163, 184, 0.1)'
|
||||
},
|
||||
},
|
||||
y1: {
|
||||
display: !compact, // Hide axis labels in compact mode
|
||||
beginAtZero: true,
|
||||
position: 'right',
|
||||
title: {
|
||||
display: !compact,
|
||||
text: 'Memory MB',
|
||||
font: { size: 9 },
|
||||
color: '#94a3b8'
|
||||
},
|
||||
ticks: {
|
||||
display: !compact,
|
||||
font: { size: 8 },
|
||||
color: '#94a3b8'
|
||||
},
|
||||
grid: { drawOnChartArea: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`[InlineChart] Chart created successfully for ${containerId}`);
|
||||
setHasData(true);
|
||||
} catch (error) {
|
||||
console.error('Error loading inline chart:', error);
|
||||
if (mounted) setHasData(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadAndRender();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
if (chartRef.current) {
|
||||
chartRef.current.destroy();
|
||||
chartRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [chartLoaded, hostId, containerId, height, compact]);
|
||||
|
||||
return (
|
||||
<div className={`relative ${compact ? 'h-10' : 'h-32'}`}>
|
||||
<canvas ref={canvasRef} className="w-full h-full"></canvas>
|
||||
{!chartLoaded && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-xs text-[var(--text-secondary)] bg-[var(--bg-tertiary)]">
|
||||
Loading Chart.js...
|
||||
</div>
|
||||
)}
|
||||
{chartLoaded && hasData === null && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-xs text-[var(--text-secondary)] bg-[var(--bg-tertiary)]">
|
||||
Loading chart data...
|
||||
</div>
|
||||
)}
|
||||
{chartLoaded && hasData === false && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-xs text-[var(--text-secondary)] bg-[var(--bg-tertiary)]">
|
||||
No stats data available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
interface ViewToggleProps {
|
||||
view: 'cards' | 'table';
|
||||
onChange: (view: 'cards' | 'table') => void;
|
||||
}
|
||||
|
||||
export default function ViewToggle({ view, onChange }: ViewToggleProps) {
|
||||
return (
|
||||
<div className="flex gap-1 bg-[var(--bg-secondary)] rounded-lg p-1 border border-[var(--border)]">
|
||||
<button
|
||||
onClick={() => onChange('cards')}
|
||||
className={`px-4 py-2 rounded-md transition-colors text-sm font-medium ${
|
||||
view === 'cards'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-[var(--text-primary)] hover:bg-[var(--bg-hover)]'
|
||||
}`}
|
||||
>
|
||||
📊 Cards
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onChange('table')}
|
||||
className={`px-4 py-2 rounded-md transition-colors text-sm font-medium ${
|
||||
view === 'table'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-[var(--text-primary)] hover:bg-[var(--bg-hover)]'
|
||||
}`}
|
||||
>
|
||||
📋 Table
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { Port } from '@/types';
|
||||
|
||||
/**
|
||||
* Format container uptime from started timestamp
|
||||
*/
|
||||
export function formatUptime(startedAt: string | undefined, endAt?: string): string {
|
||||
if (!startedAt) return '-';
|
||||
|
||||
const end = endAt ? new Date(endAt) : new Date();
|
||||
const started = new Date(startedAt);
|
||||
|
||||
if (isNaN(started.getTime()) || started.getFullYear() < 2000) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
const diff = end.getTime() - started.getTime();
|
||||
if (diff < 0) return '-';
|
||||
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (days > 0) {
|
||||
const remainingHours = hours % 24;
|
||||
return `${days}d ${remainingHours}h`;
|
||||
}
|
||||
if (hours > 0) {
|
||||
const remainingMins = minutes % 60;
|
||||
return `${hours}h ${remainingMins}m`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract image tag from image string or image tags array
|
||||
*/
|
||||
export function extractImageTag(image: string, imageTags?: string[]): string {
|
||||
if (imageTags && imageTags.length > 0) {
|
||||
const tag = imageTags[0];
|
||||
const parts = tag.split(':');
|
||||
return parts[parts.length - 1] || 'latest';
|
||||
}
|
||||
const parts = image.split(':');
|
||||
return parts[parts.length - 1] || 'latest';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format container ports for display
|
||||
*/
|
||||
export function formatPorts(ports: Port[] | undefined, maxDisplay = 3): string {
|
||||
if (!ports || ports.length === 0) return '-';
|
||||
|
||||
const formatted = ports.slice(0, maxDisplay).map(p => {
|
||||
if (p.public_port) {
|
||||
return `${p.public_port}:${p.private_port}`;
|
||||
}
|
||||
return `${p.private_port}`;
|
||||
});
|
||||
|
||||
if (ports.length > maxDisplay) {
|
||||
formatted.push(`+${ports.length - maxDisplay} more`);
|
||||
}
|
||||
|
||||
return formatted.join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format memory usage with limit and percentage
|
||||
*/
|
||||
export function formatMemory(usage?: number, limit?: number): string {
|
||||
if (!usage) return '-';
|
||||
|
||||
const usageMB = Math.round(usage / 1024 / 1024);
|
||||
|
||||
if (!limit) return `${usageMB}M`;
|
||||
|
||||
const limitMB = Math.round(limit / 1024 / 1024);
|
||||
const percent = Math.round((usage / limit) * 100);
|
||||
|
||||
return `${usageMB}M / ${limitMB}M (${percent}%)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format lifetime duration string
|
||||
*/
|
||||
export function formatLifetime(lifetime?: string): string {
|
||||
return lifetime || '-';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Tailwind CSS classes for state badge
|
||||
*/
|
||||
export function getStateBadgeClass(state: string): string {
|
||||
const stateClasses: Record<string, string> = {
|
||||
running: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
|
||||
exited: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-400',
|
||||
paused: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
|
||||
restarting: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
dead: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
|
||||
created: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400',
|
||||
removing: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400',
|
||||
};
|
||||
|
||||
return stateClasses[state.toLowerCase()] || stateClasses.exited;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get emoji icon for container state
|
||||
*/
|
||||
export function getStateIcon(state: string): string {
|
||||
const icons: Record<string, string> = {
|
||||
running: '🟢',
|
||||
exited: '🔴',
|
||||
paused: '⏸️',
|
||||
restarting: '🔄',
|
||||
dead: '💀',
|
||||
created: '⚪',
|
||||
removing: '🗑️',
|
||||
};
|
||||
|
||||
return icons[state.toLowerCase()] || '⚪';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format CPU percentage
|
||||
*/
|
||||
export function formatCpu(cpuPercent?: number): string {
|
||||
if (cpuPercent === undefined || cpuPercent === null) return '-';
|
||||
return `${cpuPercent.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format memory percentage
|
||||
*/
|
||||
export function formatMemoryPercent(memoryPercent?: number): string {
|
||||
if (memoryPercent === undefined || memoryPercent === null) return '-';
|
||||
return `${memoryPercent.toFixed(1)}%`;
|
||||
}
|
||||
Reference in New Issue
Block a user