|
|
|
@@ -1,6 +1,26 @@
|
|
|
|
|
import React, { useEffect, useState } from 'react';
|
|
|
|
|
import { containerAPI } from '../services/api';
|
|
|
|
|
import { vaultAPI } from '../services/api';
|
|
|
|
|
import {
|
|
|
|
|
Play,
|
|
|
|
|
Square,
|
|
|
|
|
RotateCcw,
|
|
|
|
|
Trash2,
|
|
|
|
|
Eye,
|
|
|
|
|
BarChart3,
|
|
|
|
|
Plus,
|
|
|
|
|
X,
|
|
|
|
|
Download,
|
|
|
|
|
HardDrive,
|
|
|
|
|
Key,
|
|
|
|
|
Container as ContainerIcon,
|
|
|
|
|
Image as ImageIcon,
|
|
|
|
|
Database,
|
|
|
|
|
AlertCircle,
|
|
|
|
|
CheckCircle,
|
|
|
|
|
Clock,
|
|
|
|
|
Activity
|
|
|
|
|
} from 'lucide-react';
|
|
|
|
|
|
|
|
|
|
const ContainerDashboard = () => {
|
|
|
|
|
const [containers, setContainers] = useState([]);
|
|
|
|
@@ -19,9 +39,13 @@ const ContainerDashboard = () => {
|
|
|
|
|
const [secrets, setSecrets] = useState([]);
|
|
|
|
|
const [newSecret, setNewSecret] = useState({ name: '', value: '' });
|
|
|
|
|
const [selectedSecrets, setSelectedSecrets] = useState([]);
|
|
|
|
|
// Auto-refresh states
|
|
|
|
|
const [lastRefresh, setLastRefresh] = useState(new Date());
|
|
|
|
|
const [refreshCountdown, setRefreshCountdown] = useState(30);
|
|
|
|
|
const [autoRefresh, setAutoRefresh] = useState(true);
|
|
|
|
|
|
|
|
|
|
const fetchAll = async () => {
|
|
|
|
|
setLoading(true);
|
|
|
|
|
const fetchAll = async (showLoading = true) => {
|
|
|
|
|
if (showLoading) setLoading(true);
|
|
|
|
|
setError('');
|
|
|
|
|
try {
|
|
|
|
|
const [cRes, iRes, vRes] = await Promise.all([
|
|
|
|
@@ -32,10 +56,12 @@ const ContainerDashboard = () => {
|
|
|
|
|
setContainers(cRes.data);
|
|
|
|
|
setImages(iRes.data);
|
|
|
|
|
setVolumes(vRes.data);
|
|
|
|
|
setLastRefresh(new Date());
|
|
|
|
|
setRefreshCountdown(30); // Reset countdown after successful refresh
|
|
|
|
|
} catch (e) {
|
|
|
|
|
setError('Failed to load data');
|
|
|
|
|
}
|
|
|
|
|
setLoading(false);
|
|
|
|
|
if (showLoading) setLoading(false);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const fetchSecrets = async () => {
|
|
|
|
@@ -52,6 +78,29 @@ const ContainerDashboard = () => {
|
|
|
|
|
fetchSecrets();
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// Auto-refresh effect
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!autoRefresh) return;
|
|
|
|
|
|
|
|
|
|
const interval = setInterval(() => {
|
|
|
|
|
setRefreshCountdown(prev => {
|
|
|
|
|
if (prev <= 1) {
|
|
|
|
|
fetchAll(false); // Silent refresh
|
|
|
|
|
return 30;
|
|
|
|
|
}
|
|
|
|
|
return prev - 1;
|
|
|
|
|
});
|
|
|
|
|
}, 1000);
|
|
|
|
|
|
|
|
|
|
return () => clearInterval(interval);
|
|
|
|
|
}, [autoRefresh]);
|
|
|
|
|
|
|
|
|
|
// Manual refresh function
|
|
|
|
|
const handleManualRefresh = () => {
|
|
|
|
|
fetchAll();
|
|
|
|
|
fetchSecrets();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleAction = async (action, name) => {
|
|
|
|
|
setError('');
|
|
|
|
|
setActionLoading(true);
|
|
|
|
@@ -209,118 +258,603 @@ const ContainerDashboard = () => {
|
|
|
|
|
setActionLoading(false);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getStatusIcon = (status) => {
|
|
|
|
|
switch (status) {
|
|
|
|
|
case 'running':
|
|
|
|
|
return <CheckCircle className="h-4 w-4 text-green-500" />;
|
|
|
|
|
case 'exited':
|
|
|
|
|
return <X className="h-4 w-4 text-red-500" />;
|
|
|
|
|
default:
|
|
|
|
|
return <Clock className="h-4 w-4 text-yellow-500" />;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getStatusColor = (status) => {
|
|
|
|
|
switch (status) {
|
|
|
|
|
case 'running':
|
|
|
|
|
return 'bg-green-100 text-green-800 border-green-200';
|
|
|
|
|
case 'exited':
|
|
|
|
|
return 'bg-red-100 text-red-800 border-red-200';
|
|
|
|
|
default:
|
|
|
|
|
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div style={{ padding: 24 }}>
|
|
|
|
|
<h2>Container Management Dashboard</h2>
|
|
|
|
|
{loading ? <p>Loading...</p> : null}
|
|
|
|
|
{error && <p style={{ color: 'red' }}>{error}</p>}
|
|
|
|
|
<h3>Secrets</h3>
|
|
|
|
|
<form onSubmit={handleAddSecret} style={{ marginBottom: 8 }}>
|
|
|
|
|
<strong>Add Secret:</strong>
|
|
|
|
|
<input required placeholder="Name" value={newSecret.name} onChange={e => setNewSecret({ ...newSecret, name: e.target.value })} />
|
|
|
|
|
<input required placeholder="Value" value={newSecret.value} onChange={e => setNewSecret({ ...newSecret, value: e.target.value })} />
|
|
|
|
|
<button type="submit" disabled={actionLoading}>Add</button>
|
|
|
|
|
</form>
|
|
|
|
|
<ul>
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
{/* Header */}
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div>
|
|
|
|
|
<h1 className="text-3xl font-bold text-gray-900">Container Management</h1>
|
|
|
|
|
<p className="mt-2 text-gray-600">Manage Docker containers, images, volumes, and secrets</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center space-x-6">
|
|
|
|
|
{/* Auto-refresh controls */}
|
|
|
|
|
<div className="flex items-center space-x-3">
|
|
|
|
|
<div className="flex items-center space-x-2">
|
|
|
|
|
<Activity className={`h-4 w-4 ${autoRefresh ? 'text-green-500' : 'text-gray-400'}`} />
|
|
|
|
|
<span className="text-sm text-gray-600">
|
|
|
|
|
{autoRefresh ? `Auto-refresh in ${refreshCountdown}s` : 'Auto-refresh off'}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setAutoRefresh(!autoRefresh)}
|
|
|
|
|
className={`inline-flex items-center px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
|
|
|
|
autoRefresh
|
|
|
|
|
? 'bg-green-100 text-green-800 hover:bg-green-200'
|
|
|
|
|
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{autoRefresh ? 'Pause' : 'Resume'}
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleManualRefresh}
|
|
|
|
|
disabled={loading}
|
|
|
|
|
className="inline-flex items-center px-3 py-1.5 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50"
|
|
|
|
|
>
|
|
|
|
|
<RotateCcw className={`h-4 w-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
|
|
|
|
|
Refresh
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Statistics */}
|
|
|
|
|
<div className="flex items-center space-x-4">
|
|
|
|
|
<div className="flex items-center space-x-2">
|
|
|
|
|
<ContainerIcon className="h-5 w-5 text-gray-400" />
|
|
|
|
|
<span className="text-sm text-gray-600">{containers.length} containers</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center space-x-2">
|
|
|
|
|
<ImageIcon className="h-5 w-5 text-gray-400" />
|
|
|
|
|
<span className="text-sm text-gray-600">{images.length} images</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center space-x-2">
|
|
|
|
|
<Database className="h-5 w-5 text-gray-400" />
|
|
|
|
|
<span className="text-sm text-gray-600">{volumes.length} volumes</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Error Alert */}
|
|
|
|
|
{error && (
|
|
|
|
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
|
|
|
|
<div className="flex">
|
|
|
|
|
<AlertCircle className="h-5 w-5 text-red-400" />
|
|
|
|
|
<div className="ml-3">
|
|
|
|
|
<h3 className="text-sm font-medium text-red-800">Error</h3>
|
|
|
|
|
<div className="mt-2 text-sm text-red-700">
|
|
|
|
|
<p>{error}</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Loading State */}
|
|
|
|
|
{loading && (
|
|
|
|
|
<div className="flex items-center justify-center py-12">
|
|
|
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
|
|
|
|
<span className="ml-3 text-gray-600">Loading containers...</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Last Refresh Info */}
|
|
|
|
|
{!loading && (
|
|
|
|
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div className="flex items-center space-x-2">
|
|
|
|
|
<Clock className="h-4 w-4 text-blue-500" />
|
|
|
|
|
<span className="text-sm text-blue-700">
|
|
|
|
|
Last updated: {lastRefresh.toLocaleTimeString()}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center space-x-2">
|
|
|
|
|
<div className={`w-2 h-2 rounded-full ${autoRefresh ? 'bg-green-500 animate-pulse' : 'bg-gray-400'}`}></div>
|
|
|
|
|
<span className="text-xs text-blue-600">
|
|
|
|
|
{autoRefresh ? 'Auto-refresh active' : 'Auto-refresh paused'}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Main Content Grid */}
|
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
|
|
|
{/* Containers Section */}
|
|
|
|
|
<div className="lg:col-span-2 space-y-6">
|
|
|
|
|
{/* Container Actions */}
|
|
|
|
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
|
|
|
|
<div className="flex items-center justify-between mb-4">
|
|
|
|
|
<h2 className="text-lg font-semibold text-gray-900 flex items-center">
|
|
|
|
|
<ContainerIcon className="h-5 w-5 mr-2" />
|
|
|
|
|
Containers
|
|
|
|
|
</h2>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Container Creation Form */}
|
|
|
|
|
<form onSubmit={handleCreateContainer} className="space-y-4">
|
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Image *</label>
|
|
|
|
|
<input
|
|
|
|
|
required
|
|
|
|
|
placeholder="nginx:latest"
|
|
|
|
|
value={newContainer.image}
|
|
|
|
|
onChange={e => setNewContainer({ ...newContainer, image: e.target.value })}
|
|
|
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
|
|
|
|
<input
|
|
|
|
|
placeholder="my-container"
|
|
|
|
|
value={newContainer.name}
|
|
|
|
|
onChange={e => setNewContainer({ ...newContainer, name: e.target.value })}
|
|
|
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Environment Variables</label>
|
|
|
|
|
<input
|
|
|
|
|
placeholder="KEY1=value1,KEY2=value2"
|
|
|
|
|
value={newContainer.env}
|
|
|
|
|
onChange={e => setNewContainer({ ...newContainer, env: e.target.value })}
|
|
|
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Ports</label>
|
|
|
|
|
<input
|
|
|
|
|
placeholder="8080:80,3000:3000"
|
|
|
|
|
value={newContainer.ports}
|
|
|
|
|
onChange={e => setNewContainer({ ...newContainer, ports: e.target.value })}
|
|
|
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Volumes</label>
|
|
|
|
|
<input
|
|
|
|
|
placeholder="/host/path:/container/path"
|
|
|
|
|
value={newContainer.volumes}
|
|
|
|
|
onChange={e => setNewContainer({ ...newContainer, volumes: e.target.value })}
|
|
|
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Command</label>
|
|
|
|
|
<input
|
|
|
|
|
placeholder="nginx -g 'daemon off;'"
|
|
|
|
|
value={newContainer.command}
|
|
|
|
|
onChange={e => setNewContainer({ ...newContainer, command: e.target.value })}
|
|
|
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{secrets.length > 0 && (
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">Attach Secrets</label>
|
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
|
|
|
{secrets.map((s) => (
|
|
|
|
|
<li key={s}>
|
|
|
|
|
{s}
|
|
|
|
|
<button onClick={() => handleDeleteSecret(s)} disabled={actionLoading} style={{ marginLeft: 8 }}>Delete</button>
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
<h3>Containers</h3>
|
|
|
|
|
<form onSubmit={handleCreateContainer} style={{ marginBottom: 16 }}>
|
|
|
|
|
<strong>Create Container:</strong>
|
|
|
|
|
<input required placeholder="Image" value={newContainer.image} onChange={e => setNewContainer({ ...newContainer, image: e.target.value })} />
|
|
|
|
|
<input placeholder="Name" value={newContainer.name} onChange={e => setNewContainer({ ...newContainer, name: e.target.value })} />
|
|
|
|
|
<input placeholder="Env (KEY=VAL,...)" value={newContainer.env} onChange={e => setNewContainer({ ...newContainer, env: e.target.value })} />
|
|
|
|
|
<input placeholder="Ports (host:container,...)" value={newContainer.ports} onChange={e => setNewContainer({ ...newContainer, ports: e.target.value })} />
|
|
|
|
|
<input placeholder="Volumes (host:container,...)" value={newContainer.volumes} onChange={e => setNewContainer({ ...newContainer, volumes: e.target.value })} />
|
|
|
|
|
<input placeholder="Command" value={newContainer.command} onChange={e => setNewContainer({ ...newContainer, command: e.target.value })} />
|
|
|
|
|
<div style={{ margin: '8px 0' }}>
|
|
|
|
|
<strong>Attach Secrets:</strong>
|
|
|
|
|
{secrets.map((s) => (
|
|
|
|
|
<label key={s} style={{ marginLeft: 8 }}>
|
|
|
|
|
<input type="checkbox" value={s} checked={selectedSecrets.includes(s)} onChange={handleSecretSelect} /> {s}
|
|
|
|
|
<label key={s} className="inline-flex items-center">
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
value={s}
|
|
|
|
|
checked={selectedSecrets.includes(s)}
|
|
|
|
|
onChange={handleSecretSelect}
|
|
|
|
|
className="rounded border-gray-300 text-primary-600 shadow-sm focus:border-primary-300 focus:ring focus:ring-primary-200 focus:ring-opacity-50"
|
|
|
|
|
/>
|
|
|
|
|
<span className="ml-2 text-sm text-gray-700">{s}</span>
|
|
|
|
|
</label>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
<button type="submit" disabled={actionLoading}>Create</button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
type="submit"
|
|
|
|
|
disabled={actionLoading}
|
|
|
|
|
className="w-full inline-flex justify-center items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
|
|
|
>
|
|
|
|
|
{actionLoading ? (
|
|
|
|
|
<>
|
|
|
|
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
|
|
|
|
Creating...
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
|
|
|
Create Container
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
</form>
|
|
|
|
|
<table border="1" cellPadding="6">
|
|
|
|
|
<thead>
|
|
|
|
|
<tr>
|
|
|
|
|
<th>Name</th>
|
|
|
|
|
<th>Status</th>
|
|
|
|
|
<th>Image</th>
|
|
|
|
|
<th>Actions</th>
|
|
|
|
|
<th>Logs</th>
|
|
|
|
|
<th>Stats</th>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Containers Table */}
|
|
|
|
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
|
|
|
|
<div className="px-6 py-4 border-b border-gray-200">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<h3 className="text-lg font-medium text-gray-900">Running Containers</h3>
|
|
|
|
|
<div className="flex items-center space-x-2">
|
|
|
|
|
{autoRefresh && (
|
|
|
|
|
<div className="flex items-center space-x-1 text-sm text-gray-500">
|
|
|
|
|
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
|
|
|
|
|
<span>Live</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<span className="text-sm text-gray-500">
|
|
|
|
|
{containers.filter(c => c.status === 'running').length} running
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="overflow-x-auto">
|
|
|
|
|
<table className="min-w-full divide-y divide-gray-200">
|
|
|
|
|
<thead className="bg-gray-50">
|
|
|
|
|
<tr>
|
|
|
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Container</th>
|
|
|
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
|
|
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Image</th>
|
|
|
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
<tbody className="bg-white divide-y divide-gray-200">
|
|
|
|
|
{containers.map((c) => (
|
|
|
|
|
<tr key={c.id}>
|
|
|
|
|
<td>{c.name}</td>
|
|
|
|
|
<td>{c.status}</td>
|
|
|
|
|
<td>{c.image && c.image.join(', ')}</td>
|
|
|
|
|
<td>
|
|
|
|
|
<button onClick={() => handleAction('start', c.name)} disabled={actionLoading}>Start</button>
|
|
|
|
|
<button onClick={() => handleAction('stop', c.name)} disabled={actionLoading}>Stop</button>
|
|
|
|
|
<button onClick={() => handleAction('restart', c.name)} disabled={actionLoading}>Restart</button>
|
|
|
|
|
<button onClick={() => handleAction('remove', c.name)} disabled={actionLoading}>Remove</button>
|
|
|
|
|
<tr key={c.id} className="hover:bg-gray-50">
|
|
|
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
|
|
|
<div className="flex items-center">
|
|
|
|
|
<ContainerIcon className="h-5 w-5 text-gray-400 mr-3" />
|
|
|
|
|
<div>
|
|
|
|
|
<div className="text-sm font-medium text-gray-900">{c.name}</div>
|
|
|
|
|
<div className="text-sm text-gray-500">{c.id.substring(0, 12)}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</td>
|
|
|
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
|
|
|
<div className="flex items-center">
|
|
|
|
|
{getStatusIcon(c.status)}
|
|
|
|
|
<span className={`ml-2 inline-flex px-2 py-1 text-xs font-semibold rounded-full border ${getStatusColor(c.status)}`}>
|
|
|
|
|
{c.status}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
</td>
|
|
|
|
|
<td>
|
|
|
|
|
<button onClick={() => handleShowLogs(c.name)}>Show Logs</button>
|
|
|
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
|
|
|
|
{c.image && c.image.join(', ')}
|
|
|
|
|
</td>
|
|
|
|
|
<td>
|
|
|
|
|
<button onClick={() => handleShowStats(c.name)}>Show Stats</button>
|
|
|
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
|
|
|
|
<div className="flex items-center space-x-2">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => handleAction('start', c.name)}
|
|
|
|
|
disabled={actionLoading || c.status === 'running'}
|
|
|
|
|
className="text-green-600 hover:text-green-900 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
|
|
|
title="Start"
|
|
|
|
|
>
|
|
|
|
|
<Play className="h-4 w-4" />
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => handleAction('stop', c.name)}
|
|
|
|
|
disabled={actionLoading || c.status !== 'running'}
|
|
|
|
|
className="text-red-600 hover:text-red-900 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
|
|
|
title="Stop"
|
|
|
|
|
>
|
|
|
|
|
<Square className="h-4 w-4" />
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => handleAction('restart', c.name)}
|
|
|
|
|
disabled={actionLoading}
|
|
|
|
|
className="text-blue-600 hover:text-blue-900 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
|
|
|
title="Restart"
|
|
|
|
|
>
|
|
|
|
|
<RotateCcw className="h-4 w-4" />
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => handleShowLogs(c.name)}
|
|
|
|
|
className="text-gray-600 hover:text-gray-900"
|
|
|
|
|
title="View Logs"
|
|
|
|
|
>
|
|
|
|
|
<Eye className="h-4 w-4" />
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => handleShowStats(c.name)}
|
|
|
|
|
className="text-purple-600 hover:text-purple-900"
|
|
|
|
|
title="View Stats"
|
|
|
|
|
>
|
|
|
|
|
<BarChart3 className="h-4 w-4" />
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => handleAction('remove', c.name)}
|
|
|
|
|
disabled={actionLoading}
|
|
|
|
|
className="text-red-600 hover:text-red-900 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
|
|
|
title="Remove"
|
|
|
|
|
>
|
|
|
|
|
<Trash2 className="h-4 w-4" />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
))}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
{selectedContainer && logs && (
|
|
|
|
|
<div style={{ marginTop: 16 }}>
|
|
|
|
|
<h4>Logs for {selectedContainer}</h4>
|
|
|
|
|
<pre style={{ background: '#222', color: '#eee', padding: 12, maxHeight: 300, overflow: 'auto' }}>{logs}</pre>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Sidebar */}
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
{/* Secrets Section */}
|
|
|
|
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
|
|
|
|
<div className="flex items-center justify-between mb-4">
|
|
|
|
|
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
|
|
|
|
<Key className="h-5 w-5 mr-2" />
|
|
|
|
|
Secrets
|
|
|
|
|
</h3>
|
|
|
|
|
<span className="text-sm text-gray-500">{secrets.length}</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<form onSubmit={handleAddSecret} className="space-y-3">
|
|
|
|
|
<div>
|
|
|
|
|
<input
|
|
|
|
|
required
|
|
|
|
|
placeholder="Secret name"
|
|
|
|
|
value={newSecret.name}
|
|
|
|
|
onChange={e => setNewSecret({ ...newSecret, name: e.target.value })}
|
|
|
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 text-sm"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<input
|
|
|
|
|
required
|
|
|
|
|
placeholder="Secret value"
|
|
|
|
|
type="password"
|
|
|
|
|
value={newSecret.value}
|
|
|
|
|
onChange={e => setNewSecret({ ...newSecret, value: e.target.value })}
|
|
|
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 text-sm"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
type="submit"
|
|
|
|
|
disabled={actionLoading}
|
|
|
|
|
className="w-full inline-flex justify-center items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50"
|
|
|
|
|
>
|
|
|
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
|
|
|
Add Secret
|
|
|
|
|
</button>
|
|
|
|
|
</form>
|
|
|
|
|
|
|
|
|
|
<div className="mt-4 space-y-2">
|
|
|
|
|
{secrets.map((s) => (
|
|
|
|
|
<div key={s} className="flex items-center justify-between p-2 bg-gray-50 rounded-md">
|
|
|
|
|
<span className="text-sm text-gray-700">{s}</span>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => handleDeleteSecret(s)}
|
|
|
|
|
disabled={actionLoading}
|
|
|
|
|
className="text-red-600 hover:text-red-900 disabled:opacity-50"
|
|
|
|
|
>
|
|
|
|
|
<X className="h-4 w-4" />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Images Section */}
|
|
|
|
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
|
|
|
|
<div className="flex items-center justify-between mb-4">
|
|
|
|
|
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
|
|
|
|
<ImageIcon className="h-5 w-5 mr-2" />
|
|
|
|
|
Images
|
|
|
|
|
</h3>
|
|
|
|
|
<span className="text-sm text-gray-500">{images.length}</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<form onSubmit={handlePullImage} className="space-y-3">
|
|
|
|
|
<div>
|
|
|
|
|
<input
|
|
|
|
|
required
|
|
|
|
|
placeholder="Image name"
|
|
|
|
|
value={pullImageName}
|
|
|
|
|
onChange={e => setPullImageName(e.target.value)}
|
|
|
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 text-sm"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
type="submit"
|
|
|
|
|
disabled={actionLoading}
|
|
|
|
|
className="w-full inline-flex justify-center items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50"
|
|
|
|
|
>
|
|
|
|
|
<Download className="h-4 w-4 mr-2" />
|
|
|
|
|
Pull Image
|
|
|
|
|
</button>
|
|
|
|
|
</form>
|
|
|
|
|
|
|
|
|
|
<div className="mt-4 space-y-2 max-h-48 overflow-y-auto">
|
|
|
|
|
{images.map((img) => (
|
|
|
|
|
<div key={img.id} className="flex items-center justify-between p-2 bg-gray-50 rounded-md">
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
<div className="text-sm text-gray-900 truncate">
|
|
|
|
|
{img.tags && img.tags.join(', ')}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-xs text-gray-500">{img.short_id}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => handleRemoveImage(img.id)}
|
|
|
|
|
disabled={actionLoading}
|
|
|
|
|
className="text-red-600 hover:text-red-900 disabled:opacity-50 ml-2"
|
|
|
|
|
>
|
|
|
|
|
<Trash2 className="h-4 w-4" />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Volumes Section */}
|
|
|
|
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
|
|
|
|
<div className="flex items-center justify-between mb-4">
|
|
|
|
|
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
|
|
|
|
<Database className="h-5 w-5 mr-2" />
|
|
|
|
|
Volumes
|
|
|
|
|
</h3>
|
|
|
|
|
<span className="text-sm text-gray-500">{volumes.length}</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<form onSubmit={handleCreateVolume} className="space-y-3">
|
|
|
|
|
<div>
|
|
|
|
|
<input
|
|
|
|
|
required
|
|
|
|
|
placeholder="Volume name"
|
|
|
|
|
value={newVolumeName}
|
|
|
|
|
onChange={e => setNewVolumeName(e.target.value)}
|
|
|
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 text-sm"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
type="submit"
|
|
|
|
|
disabled={actionLoading}
|
|
|
|
|
className="w-full inline-flex justify-center items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50"
|
|
|
|
|
>
|
|
|
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
|
|
|
Create Volume
|
|
|
|
|
</button>
|
|
|
|
|
</form>
|
|
|
|
|
|
|
|
|
|
<div className="mt-4 space-y-2 max-h-48 overflow-y-auto">
|
|
|
|
|
{volumes.map((v) => (
|
|
|
|
|
<div key={v.name} className="flex items-center justify-between p-2 bg-gray-50 rounded-md">
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
<div className="text-sm text-gray-900 truncate">{v.name}</div>
|
|
|
|
|
<div className="text-xs text-gray-500 truncate">{v.mountpoint}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => handleRemoveVolume(v.name)}
|
|
|
|
|
disabled={actionLoading}
|
|
|
|
|
className="text-red-600 hover:text-red-900 disabled:opacity-50 ml-2"
|
|
|
|
|
>
|
|
|
|
|
<Trash2 className="h-4 w-4" />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Logs and Stats Modal */}
|
|
|
|
|
{selectedContainer && (logs || stats) && (
|
|
|
|
|
<div
|
|
|
|
|
className="fixed inset-0 bg-black bg-opacity-50 overflow-y-auto h-full w-full z-50 flex items-center justify-center p-4"
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
// Close modal when clicking on backdrop
|
|
|
|
|
if (e.target === e.currentTarget) {
|
|
|
|
|
setSelectedContainer(null);
|
|
|
|
|
setLogs('');
|
|
|
|
|
setStats(null);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<div className="relative w-full max-w-6xl max-h-[90vh] bg-white rounded-lg shadow-xl">
|
|
|
|
|
{/* Modal Header */}
|
|
|
|
|
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
|
|
|
|
<h3 className="text-xl font-semibold text-gray-900 flex items-center">
|
|
|
|
|
{logs ? (
|
|
|
|
|
<>
|
|
|
|
|
<Activity className="h-5 w-5 mr-2 text-blue-500" />
|
|
|
|
|
Logs for {selectedContainer}
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<BarChart3 className="h-5 w-5 mr-2 text-purple-500" />
|
|
|
|
|
Stats for {selectedContainer}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</h3>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setSelectedContainer(null);
|
|
|
|
|
setLogs('');
|
|
|
|
|
setStats(null);
|
|
|
|
|
}}
|
|
|
|
|
className="text-gray-400 hover:text-gray-600 transition-colors p-1 rounded-md hover:bg-gray-100"
|
|
|
|
|
>
|
|
|
|
|
<X className="h-6 w-6" />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Modal Content */}
|
|
|
|
|
<div className="p-6">
|
|
|
|
|
<div className="bg-gray-900 rounded-lg p-4 max-h-[60vh] overflow-auto">
|
|
|
|
|
<pre className="text-green-400 text-sm font-mono whitespace-pre-wrap leading-relaxed">
|
|
|
|
|
{logs || (typeof stats === 'string' ? stats : JSON.stringify(stats, null, 2))}
|
|
|
|
|
</pre>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Modal Footer */}
|
|
|
|
|
<div className="mt-4 flex items-center justify-between">
|
|
|
|
|
<div className="text-sm text-gray-500">
|
|
|
|
|
{logs ? 'Container logs' : 'Container statistics'}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex space-x-2">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setSelectedContainer(null);
|
|
|
|
|
setLogs('');
|
|
|
|
|
setStats(null);
|
|
|
|
|
}}
|
|
|
|
|
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
|
|
|
|
>
|
|
|
|
|
Close
|
|
|
|
|
</button>
|
|
|
|
|
{logs && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => {
|
|
|
|
|
const logText = logs;
|
|
|
|
|
const blob = new Blob([logText], { type: 'text/plain' });
|
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
|
const a = document.createElement('a');
|
|
|
|
|
a.href = url;
|
|
|
|
|
a.download = `${selectedContainer}-logs.txt`;
|
|
|
|
|
document.body.appendChild(a);
|
|
|
|
|
a.click();
|
|
|
|
|
document.body.removeChild(a);
|
|
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
|
}}
|
|
|
|
|
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 border border-transparent rounded-md hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
|
|
|
|
>
|
|
|
|
|
Download Logs
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{selectedContainer && stats && (
|
|
|
|
|
<div style={{ marginTop: 16 }}>
|
|
|
|
|
<h4>Stats for {selectedContainer}</h4>
|
|
|
|
|
<pre style={{ background: '#222', color: '#eee', padding: 12, maxHeight: 300, overflow: 'auto' }}>{typeof stats === 'string' ? stats : JSON.stringify(stats, null, 2) }</pre>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<h3>Images</h3>
|
|
|
|
|
<form onSubmit={handlePullImage} style={{ marginBottom: 8 }}>
|
|
|
|
|
<strong>Pull Image:</strong>
|
|
|
|
|
<input required placeholder="Image name" value={pullImageName} onChange={e => setPullImageName(e.target.value)} />
|
|
|
|
|
<button type="submit" disabled={actionLoading}>Pull</button>
|
|
|
|
|
</form>
|
|
|
|
|
<ul>
|
|
|
|
|
{images.map((img) => (
|
|
|
|
|
<li key={img.id}>
|
|
|
|
|
{img.tags && img.tags.join(', ')} ({img.short_id})
|
|
|
|
|
<button onClick={() => handleRemoveImage(img.id)} disabled={actionLoading} style={{ marginLeft: 8 }}>Remove</button>
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
<h3>Volumes</h3>
|
|
|
|
|
<form onSubmit={handleCreateVolume} style={{ marginBottom: 8 }}>
|
|
|
|
|
<strong>Create Volume:</strong>
|
|
|
|
|
<input required placeholder="Volume name" value={newVolumeName} onChange={e => setNewVolumeName(e.target.value)} />
|
|
|
|
|
<button type="submit" disabled={actionLoading}>Create</button>
|
|
|
|
|
</form>
|
|
|
|
|
<ul>
|
|
|
|
|
{volumes.map((v) => (
|
|
|
|
|
<li key={v.name}>
|
|
|
|
|
{v.name} ({v.mountpoint})
|
|
|
|
|
<button onClick={() => handleRemoveVolume(v.name)} disabled={actionLoading} style={{ marginLeft: 8 }}>Remove</button>
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|