feat: Add automatic Docker visibility
This commit is contained in:
33
src/App.tsx
33
src/App.tsx
@@ -2,12 +2,13 @@ import { Toaster } from "@/components/ui/toaster";
|
|||||||
import { Toaster as Sonner } from "@/components/ui/sonner";
|
import { Toaster as Sonner } from "@/components/ui/sonner";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||||
import Dashboard from "./pages/Dashboard";
|
import Dashboard from "./pages/Dashboard";
|
||||||
import Login from "./pages/Login";
|
import Login from "./pages/Login";
|
||||||
import ContainerDetails from "./pages/ContainerDetails";
|
import ContainerDetails from "./pages/ContainerDetails";
|
||||||
import Settings from "./pages/Settings";
|
import Settings from "./pages/Settings";
|
||||||
import NotFound from "./pages/NotFound";
|
import NotFound from "./pages/NotFound";
|
||||||
|
import { ProtectedRoute } from "./components/ProtectedRoute";
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
@@ -18,10 +19,32 @@ const App = () => (
|
|||||||
<Sonner />
|
<Sonner />
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Login />} />
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
<Route path="/dashboard" element={<Dashboard />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/container/:id" element={<ContainerDetails />} />
|
<Route
|
||||||
<Route path="/settings" element={<Settings />} />
|
path="/dashboard"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Dashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/container/:id"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<ContainerDetails />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/settings"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Settings />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
|
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
38
src/components/ProtectedRoute.tsx
Normal file
38
src/components/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Navigate } from 'react-router-dom';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
|
||||||
|
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [authenticated, setAuthenticated] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkAuth = async () => {
|
||||||
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
|
setAuthenticated(!!session);
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
checkAuth();
|
||||||
|
|
||||||
|
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
|
||||||
|
setAuthenticated(!!session);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => subscription.unsubscribe();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background dark flex items-center justify-center">
|
||||||
|
<div className="text-muted-foreground">Loading...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authenticated) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
96
src/hooks/useDockerContainers.ts
Normal file
96
src/hooks/useDockerContainers.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
|
||||||
|
export interface DockerContainer {
|
||||||
|
Id: string;
|
||||||
|
Names: string[];
|
||||||
|
Image: string;
|
||||||
|
State: string;
|
||||||
|
Status: string;
|
||||||
|
Created: number;
|
||||||
|
Ports?: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDockerContainers() {
|
||||||
|
const [containers, setContainers] = useState<DockerContainer[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const fetchContainers = async () => {
|
||||||
|
try {
|
||||||
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
|
if (!session) return;
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${import.meta.env.VITE_SUPABASE_URL}/functions/v1/docker-api/containers`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${session.access_token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch containers');
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setContainers(data);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error fetching containers:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to load containers',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const controlContainer = async (containerId: string, action: 'start' | 'stop' | 'restart') => {
|
||||||
|
try {
|
||||||
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
|
if (!session) return;
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${import.meta.env.VITE_SUPABASE_URL}/functions/v1/docker-api/containers/${containerId}/${action}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${session.access_token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`Failed to ${action} container`);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Success',
|
||||||
|
description: `Container ${action}ed successfully`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refresh container list
|
||||||
|
await fetchContainers();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`Error ${action}ing container:`, error);
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: error.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchContainers();
|
||||||
|
|
||||||
|
// Poll for updates every 5 seconds as backup to WebSocket
|
||||||
|
const interval = setInterval(fetchContainers, 5000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { containers, loading, controlContainer, refetch: fetchContainers };
|
||||||
|
}
|
||||||
109
src/hooks/useDockerMetrics.ts
Normal file
109
src/hooks/useDockerMetrics.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
|
||||||
|
interface ContainerMetrics {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
cpu: string;
|
||||||
|
memory: {
|
||||||
|
used: number;
|
||||||
|
limit: number;
|
||||||
|
percent: string;
|
||||||
|
};
|
||||||
|
network: {
|
||||||
|
rx: number;
|
||||||
|
tx: number;
|
||||||
|
};
|
||||||
|
blockIO: {
|
||||||
|
read: number;
|
||||||
|
write: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HostMetrics {
|
||||||
|
cpu: { usage: string };
|
||||||
|
memory: {
|
||||||
|
total: number;
|
||||||
|
free: number;
|
||||||
|
available: number;
|
||||||
|
used: number;
|
||||||
|
usage: string;
|
||||||
|
};
|
||||||
|
uptime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetricsData {
|
||||||
|
type: string;
|
||||||
|
timestamp: string;
|
||||||
|
host: HostMetrics;
|
||||||
|
containers: ContainerMetrics[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDockerMetrics() {
|
||||||
|
const [metrics, setMetrics] = useState<MetricsData | null>(null);
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let ws: WebSocket | null = null;
|
||||||
|
|
||||||
|
const connect = async () => {
|
||||||
|
try {
|
||||||
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
|
if (!session) {
|
||||||
|
setError('Not authenticated');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wsUrl = `${import.meta.env.VITE_SUPABASE_URL.replace('https://', 'wss://')}/functions/v1/docker-metrics`;
|
||||||
|
ws = new WebSocket(wsUrl, ['Bearer', session.access_token]);
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
console.log('WebSocket connected');
|
||||||
|
setConnected(true);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
setMetrics(data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse metrics:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (e) => {
|
||||||
|
console.error('WebSocket error:', e);
|
||||||
|
setError('Connection error');
|
||||||
|
setConnected(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
console.log('WebSocket closed');
|
||||||
|
setConnected(false);
|
||||||
|
// Attempt to reconnect after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!ws || ws.readyState === WebSocket.CLOSED) {
|
||||||
|
connect();
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to connect:', e);
|
||||||
|
setError('Failed to connect');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (ws) {
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { metrics, connected, error };
|
||||||
|
}
|
||||||
@@ -1,15 +1,37 @@
|
|||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Activity, Server, HardDrive, Network, Play, Square, RotateCw, AlertTriangle } from "lucide-react";
|
import { Activity, Server, HardDrive, Network, Play, Square, RotateCw, AlertTriangle, Wifi, WifiOff } from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { useDockerMetrics } from "@/hooks/useDockerMetrics";
|
||||||
|
import { useDockerContainers } from "@/hooks/useDockerContainers";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
|
|
||||||
const Dashboard = () => {
|
const Dashboard = () => {
|
||||||
const containers = [
|
const { metrics, connected } = useDockerMetrics();
|
||||||
{ id: "abc123", name: "redis-prod", status: "running", cpu: "2.5%", memory: "128 MB", uptime: "3d 14h" },
|
const { containers, loading, controlContainer } = useDockerContainers();
|
||||||
{ id: "def456", name: "postgres-main", status: "running", cpu: "5.2%", memory: "512 MB", uptime: "7d 2h" },
|
const navigate = useNavigate();
|
||||||
{ id: "ghi789", name: "nginx-proxy", status: "running", cpu: "0.8%", memory: "64 MB", uptime: "12d 8h" },
|
|
||||||
{ id: "jkl012", name: "api-backend", status: "stopped", cpu: "0%", memory: "0 MB", uptime: "-" },
|
const handleLogout = async () => {
|
||||||
];
|
await supabase.auth.signOut();
|
||||||
|
navigate('/login');
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatBytes = (bytes: number) => {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getContainerMetrics = (containerId: string) => {
|
||||||
|
if (!metrics) return null;
|
||||||
|
return metrics.containers.find(c => c.id.startsWith(containerId.substring(0, 12)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const runningCount = containers.filter(c => c.State === 'running').length;
|
||||||
|
const stoppedCount = containers.filter(c => c.State !== 'running').length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background dark">
|
<div className="min-h-screen bg-background dark">
|
||||||
@@ -22,12 +44,24 @@ const Dashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-foreground">Docker WebUI</h1>
|
<h1 className="text-xl font-bold text-foreground">Docker WebUI</h1>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<p className="text-sm text-muted-foreground">docker-node-01</p>
|
<p className="text-sm text-muted-foreground">docker-node-01</p>
|
||||||
|
{connected ? (
|
||||||
|
<Wifi className="w-3 h-3 text-success" />
|
||||||
|
) : (
|
||||||
|
<WifiOff className="w-3 h-3 text-destructive" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={() => window.location.href = '/settings'}>
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => navigate('/settings')}>
|
||||||
Settings
|
Settings
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleLogout}>
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -40,12 +74,17 @@ const Dashboard = () => {
|
|||||||
<Activity className="w-5 h-5 text-primary" />
|
<Activity className="w-5 h-5 text-primary" />
|
||||||
<h3 className="font-semibold text-card-foreground">CPU</h3>
|
<h3 className="font-semibold text-card-foreground">CPU</h3>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-2xl font-bold text-primary">24%</span>
|
<span className="text-2xl font-bold text-primary">
|
||||||
|
{metrics?.host?.cpu?.usage || '0'}%
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2 bg-secondary rounded-full overflow-hidden">
|
<div className="h-2 bg-secondary rounded-full overflow-hidden">
|
||||||
<div className="h-full bg-primary rounded-full" style={{ width: "24%" }} />
|
<div
|
||||||
|
className="h-full bg-primary rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${metrics?.host?.cpu?.usage || 0}%` }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-2">4 cores @ 3.2 GHz</p>
|
<p className="text-xs text-muted-foreground mt-2">Host CPU usage</p>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="p-6 bg-card border-border">
|
<Card className="p-6 bg-card border-border">
|
||||||
@@ -54,41 +93,49 @@ const Dashboard = () => {
|
|||||||
<Server className="w-5 h-5 text-accent" />
|
<Server className="w-5 h-5 text-accent" />
|
||||||
<h3 className="font-semibold text-card-foreground">Memory</h3>
|
<h3 className="font-semibold text-card-foreground">Memory</h3>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-2xl font-bold text-accent">8.2 GB</span>
|
<span className="text-2xl font-bold text-accent">
|
||||||
|
{metrics?.host?.memory ? formatBytes(metrics.host.memory.used * 1024) : '0 MB'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2 bg-secondary rounded-full overflow-hidden">
|
<div className="h-2 bg-secondary rounded-full overflow-hidden">
|
||||||
<div className="h-full bg-accent rounded-full" style={{ width: "51%" }} />
|
<div
|
||||||
|
className="h-full bg-accent rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${metrics?.host?.memory?.usage || 0}%` }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-2">51% of 16 GB</p>
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
{metrics?.host?.memory?.usage || '0'}% of {metrics?.host?.memory ? formatBytes(metrics.host.memory.total * 1024) : '0 GB'}
|
||||||
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="p-6 bg-card border-border">
|
<Card className="p-6 bg-card border-border">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<HardDrive className="w-5 h-5 text-warning" />
|
<HardDrive className="w-5 h-5 text-warning" />
|
||||||
<h3 className="font-semibold text-card-foreground">Disk</h3>
|
<h3 className="font-semibold text-card-foreground">Containers</h3>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-2xl font-bold text-warning">124 GB</span>
|
<span className="text-2xl font-bold text-warning">{containers.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2 bg-secondary rounded-full overflow-hidden">
|
<div className="flex gap-2 text-xs text-muted-foreground">
|
||||||
<div className="h-full bg-warning rounded-full" style={{ width: "62%" }} />
|
<span className="text-success">{runningCount} running</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span className="text-destructive">{stoppedCount} stopped</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-2">62% of 200 GB</p>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="p-6 bg-card border-border">
|
<Card className="p-6 bg-card border-border">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Network className="w-5 h-5 text-success" />
|
<Network className="w-5 h-5 text-success" />
|
||||||
<h3 className="font-semibold text-card-foreground">Network</h3>
|
<h3 className="font-semibold text-card-foreground">Uptime</h3>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-2xl font-bold text-success">2.4 MB/s</span>
|
<span className="text-2xl font-bold text-success">
|
||||||
</div>
|
{metrics?.host?.uptime ? Math.floor(metrics.host.uptime / 86400) + 'd' : '0d'}
|
||||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
</span>
|
||||||
<span>↓ 1.8 MB/s</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>↑ 0.6 MB/s</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{metrics?.host?.uptime ? new Date(Date.now() - metrics.host.uptime * 1000).toLocaleString() : 'Loading...'}
|
||||||
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -97,12 +144,12 @@ const Dashboard = () => {
|
|||||||
<h2 className="text-2xl font-bold text-foreground">Containers</h2>
|
<h2 className="text-2xl font-bold text-foreground">Containers</h2>
|
||||||
<div className="flex gap-4 text-sm">
|
<div className="flex gap-4 text-sm">
|
||||||
<span className="text-success flex items-center gap-1">
|
<span className="text-success flex items-center gap-1">
|
||||||
<div className="w-2 h-2 rounded-full bg-success" />
|
<div className="w-2 h-2 rounded-full bg-success animate-pulse" />
|
||||||
3 Running
|
{runningCount} Running
|
||||||
</span>
|
</span>
|
||||||
<span className="text-destructive flex items-center gap-1">
|
<span className="text-destructive flex items-center gap-1">
|
||||||
<div className="w-2 h-2 rounded-full bg-destructive" />
|
<div className="w-2 h-2 rounded-full bg-destructive" />
|
||||||
1 Stopped
|
{stoppedCount} Stopped
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -110,72 +157,129 @@ const Dashboard = () => {
|
|||||||
{/* Container List */}
|
{/* Container List */}
|
||||||
<Card className="bg-card border-border">
|
<Card className="bg-card border-border">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-8 text-center text-muted-foreground">Loading containers...</div>
|
||||||
|
) : containers.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-muted-foreground">
|
||||||
|
No containers found. Create one using Docker CLI and it will appear here automatically.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border">
|
<tr className="border-b border-border">
|
||||||
<th className="px-6 py-4 text-left text-sm font-semibold text-muted-foreground">Status</th>
|
<th className="px-6 py-4 text-left text-sm font-semibold text-muted-foreground">Status</th>
|
||||||
<th className="px-6 py-4 text-left text-sm font-semibold text-muted-foreground">Name</th>
|
<th className="px-6 py-4 text-left text-sm font-semibold text-muted-foreground">Name</th>
|
||||||
<th className="px-6 py-4 text-left text-sm font-semibold text-muted-foreground">ID</th>
|
<th className="px-6 py-4 text-left text-sm font-semibold text-muted-foreground">Image</th>
|
||||||
<th className="px-6 py-4 text-left text-sm font-semibold text-muted-foreground">CPU</th>
|
<th className="px-6 py-4 text-left text-sm font-semibold text-muted-foreground">CPU</th>
|
||||||
<th className="px-6 py-4 text-left text-sm font-semibold text-muted-foreground">Memory</th>
|
<th className="px-6 py-4 text-left text-sm font-semibold text-muted-foreground">Memory</th>
|
||||||
<th className="px-6 py-4 text-left text-sm font-semibold text-muted-foreground">Uptime</th>
|
<th className="px-6 py-4 text-left text-sm font-semibold text-muted-foreground">Network</th>
|
||||||
<th className="px-6 py-4 text-right text-sm font-semibold text-muted-foreground">Actions</th>
|
<th className="px-6 py-4 text-right text-sm font-semibold text-muted-foreground">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{containers.map((container) => (
|
{containers.map((container) => {
|
||||||
<tr key={container.id} className="border-b border-border hover:bg-secondary/50 transition-colors">
|
const containerMetrics = getContainerMetrics(container.Id);
|
||||||
|
const isRunning = container.State === 'running';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={container.Id} className="border-b border-border hover:bg-secondary/50 transition-colors">
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<Badge variant={container.status === "running" ? "default" : "destructive"} className={container.status === "running" ? "bg-success hover:bg-success" : ""}>
|
<Badge
|
||||||
{container.status}
|
variant={isRunning ? "default" : "destructive"}
|
||||||
|
className={isRunning ? "bg-success hover:bg-success" : ""}
|
||||||
|
>
|
||||||
|
{container.State}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 font-medium text-foreground">{container.name}</td>
|
<td className="px-6 py-4">
|
||||||
<td className="px-6 py-4 font-mono text-sm text-muted-foreground">{container.id}</td>
|
<div>
|
||||||
<td className="px-6 py-4 text-sm text-foreground">{container.cpu}</td>
|
<p className="font-medium text-foreground">{container.Names[0].replace(/^\//, '')}</p>
|
||||||
<td className="px-6 py-4 text-sm text-foreground">{container.memory}</td>
|
<p className="text-xs text-muted-foreground font-mono">{container.Id.substring(0, 12)}</p>
|
||||||
<td className="px-6 py-4 text-sm text-muted-foreground">{container.uptime}</td>
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-foreground">{container.Image}</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-foreground">
|
||||||
|
{containerMetrics?.cpu || '0'}%
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-foreground">
|
||||||
|
{containerMetrics?.memory ? formatBytes(containerMetrics.memory.used) : '0 MB'}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-xs text-muted-foreground">
|
||||||
|
{containerMetrics?.network ? (
|
||||||
|
<div>
|
||||||
|
<div>↓ {formatBytes(containerMetrics.network.rx)}</div>
|
||||||
|
<div>↑ {formatBytes(containerMetrics.network.tx)}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
'-'
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
{container.status === "running" ? (
|
{isRunning ? (
|
||||||
<>
|
<>
|
||||||
<Button size="sm" variant="outline" className="h-8 w-8 p-0">
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => controlContainer(container.Id, 'stop')}
|
||||||
|
title="Stop"
|
||||||
|
>
|
||||||
<Square className="w-4 h-4" />
|
<Square className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="outline" className="h-8 w-8 p-0">
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => controlContainer(container.Id, 'restart')}
|
||||||
|
title="Restart"
|
||||||
|
>
|
||||||
<RotateCw className="w-4 h-4" />
|
<RotateCw className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Button size="sm" variant="outline" className="h-8 w-8 p-0">
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => controlContainer(container.Id, 'start')}
|
||||||
|
title="Start"
|
||||||
|
>
|
||||||
<Play className="w-4 h-4" />
|
<Play className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => navigate(`/container/${container.Id}`)}
|
||||||
|
>
|
||||||
|
Details
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Active Alerts */}
|
{/* Connection Status */}
|
||||||
<Card className="mt-8 p-6 bg-card border-border">
|
{!connected && (
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<Card className="mt-8 p-6 bg-card border-border border-warning">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<AlertTriangle className="w-5 h-5 text-warning" />
|
<AlertTriangle className="w-5 h-5 text-warning" />
|
||||||
<h3 className="text-lg font-semibold text-foreground">Recent Alerts</h3>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between p-3 rounded-lg bg-destructive/10 border border-destructive/20">
|
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-foreground">api-backend stopped</p>
|
<h3 className="text-lg font-semibold text-foreground">Metrics Disconnected</h3>
|
||||||
<p className="text-sm text-muted-foreground">2 minutes ago</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
</div>
|
Real-time metrics are temporarily unavailable. Attempting to reconnect...
|
||||||
<Badge variant="destructive">Container Stopped</Badge>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user