wip: Fix ContainerDashboard
This commit is contained in:
+1
-1
@@ -48,7 +48,7 @@ function App() {
|
||||
};
|
||||
|
||||
checkHealth();
|
||||
const interval = setInterval(checkHealth, 30000); // Check every 30 seconds
|
||||
const interval = setInterval(checkHealth, 5000); // Check every 30 seconds
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -27,7 +27,7 @@ function Dashboard({ isOnline }) {
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const interval = setInterval(fetchData, 30000); // Refresh every 30 seconds
|
||||
const interval = setInterval(fetchData, 5000); // Refresh every 30 seconds
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isOnline]);
|
||||
|
||||
Reference in New Issue
Block a user