mirror of
https://github.com/ElppaDev/snStatus.git
synced 2026-01-29 09:35:36 +00:00
최초 배포
This commit is contained in:
374
frontend/src/pages/DockerManager.jsx
Normal file
374
frontend/src/pages/DockerManager.jsx
Normal file
@@ -0,0 +1,374 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Play,
|
||||
Square,
|
||||
Terminal as TerminalIcon,
|
||||
MoreHorizontal,
|
||||
RefreshCw,
|
||||
RotateCw,
|
||||
FileText,
|
||||
Search,
|
||||
X,
|
||||
Loader2
|
||||
} from 'lucide-react';
|
||||
import TerminalComponent from '../components/TerminalComponent';
|
||||
|
||||
const API_DOCKER_CONTAINERS = '/api/docker/containers';
|
||||
|
||||
const formatBytes = (bytes, decimals = 2) => {
|
||||
if (!+bytes) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
const DockerManager = () => {
|
||||
const [containers, setContainers] = useState([]);
|
||||
// ... (rest of state items are same)
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [activeTerminal, setActiveTerminal] = useState(null); // containerId
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showLogs, setShowLogs] = useState(null); // containerId
|
||||
const [logsContent, setLogsContent] = useState('');
|
||||
const [actionLoading, setActionLoading] = useState(null); // containerId being acted upon
|
||||
|
||||
const fetchContainers = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(API_DOCKER_CONTAINERS);
|
||||
if (!response.ok) throw new Error('Failed to fetch containers');
|
||||
const data = await response.json();
|
||||
setContainers(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError('Could not load Docker containers.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ... (rest of useEffect and handlers existing)
|
||||
|
||||
useEffect(() => {
|
||||
fetchContainers();
|
||||
// Poll for updates every 5 seconds to keep stats fresh
|
||||
const interval = setInterval(fetchContainers, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Docker Actions
|
||||
const handleContainerAction = async (id, action) => {
|
||||
setActionLoading(id);
|
||||
try {
|
||||
const response = await fetch(`/api/docker/containers/${id}/${action}`, { method: 'POST' });
|
||||
if (!response.ok) throw new Error(`Failed to ${action} container`);
|
||||
await fetchContainers(); // Refresh list
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFetchLogs = async (id) => {
|
||||
setLogsContent('Loading logs...');
|
||||
setShowLogs(id);
|
||||
try {
|
||||
const response = await fetch(`/api/docker/containers/${id}/logs`);
|
||||
if (!response.ok) throw new Error('Failed to fetch logs');
|
||||
const data = await response.text();
|
||||
setLogsContent(data);
|
||||
} catch (err) {
|
||||
setLogsContent(`Error: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="docker-container">
|
||||
{/* ... (header code) */}
|
||||
<header className="page-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
<h2>도커 컨테이너</h2>
|
||||
</div>
|
||||
{!activeTerminal && (
|
||||
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
|
||||
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
|
||||
<Search size={16} style={{ position: 'absolute', left: '10px', color: 'var(--text-secondary)' }} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="검색..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
style={{
|
||||
padding: '0.5rem 1rem 0.5rem 2.2rem',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
color: 'inherit',
|
||||
outline: 'none',
|
||||
width: '200px',
|
||||
fontSize: '0.9rem'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={fetchContainers} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<RefreshCw size={16} /> 새로고침
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{error && (
|
||||
<div className="glass-panel error-banner" style={{ color: 'var(--error-color)', padding: '1rem', marginBottom: '1rem' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Terminal View */}
|
||||
{activeTerminal ? (
|
||||
<div className="terminal-view glass-panel" style={{ height: '70vh', display: 'flex', flexDirection: 'column' }}>
|
||||
<div className="terminal-header" style={{ padding: '0.5rem 1rem', borderBottom: '1px solid var(--glass-border)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>터미널 - {containers.find(c => c.Id === activeTerminal)?.Names[0] || activeTerminal}</span>
|
||||
<button className="btn" onClick={() => setActiveTerminal(null)} style={{ background: 'rgba(255,255,255,0.1)' }}>닫기</button>
|
||||
</div>
|
||||
<div style={{ flex: 1, position: 'relative' }}>
|
||||
<TerminalComponent containerId={activeTerminal} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="containers-grid">
|
||||
{loading && containers.length === 0 ? (
|
||||
<div style={{
|
||||
gridColumn: '1 / -1',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '200px',
|
||||
color: 'var(--text-secondary)'
|
||||
}}>
|
||||
<Loader2 className="spin-animation" size={48} />
|
||||
<style>{`
|
||||
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||
.spin-animation { animation: spin 1s linear infinite; }
|
||||
`}</style>
|
||||
</div>
|
||||
) : containers
|
||||
.filter(c =>
|
||||
c.Names[0].toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
c.Image.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
.map(container => (
|
||||
<div key={container.Id} className="glass-card container-card">
|
||||
<div className="card-header-compact">
|
||||
<div className={`status-indicator ${container.State}`} />
|
||||
<h3 className="container-name" title={container.Names[0]}>{container.Names[0].replace(/^\//, '')}</h3>
|
||||
</div>
|
||||
<div className="container-info">
|
||||
<div className="info-row">
|
||||
<span className="label">이미지:</span>
|
||||
<span className="value" title={container.Image}>{container.Image.length > 20 ? container.Image.substring(0, 20) + '...' : container.Image}</span>
|
||||
</div>
|
||||
<div className="info-row">
|
||||
<span className="label">상태:</span>
|
||||
<span className={`value state-${container.State}`}>{container.State}</span>
|
||||
</div>
|
||||
|
||||
{/* Stats Display */}
|
||||
{container.stats && container.State === 'running' && (
|
||||
<>
|
||||
<div className="info-row">
|
||||
<span className="label">CPU:</span>
|
||||
<span className="value" style={{ color: '#3b82f6', fontWeight: 'bold' }}>
|
||||
{container.stats.cpu.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="info-row">
|
||||
<span className="label">Memory:</span>
|
||||
<span className="value" style={{ color: '#3b82f6', fontWeight: 'bold' }}>
|
||||
{formatBytes(container.stats.memory)}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="card-actions">
|
||||
{container.State === 'running' ? (
|
||||
<>
|
||||
<button className="btn-icon-action stop" title="중지" onClick={() => handleContainerAction(container.Id, 'stop')} disabled={actionLoading === container.Id}>
|
||||
<Square size={18} fill="currentColor" />
|
||||
</button>
|
||||
<button className="btn-icon-action restart" title="재시작" onClick={() => handleContainerAction(container.Id, 'restart')} disabled={actionLoading === container.Id}>
|
||||
<RotateCw size={18} />
|
||||
</button>
|
||||
<button className="btn-icon-action terminal" title="터미널" onClick={() => setActiveTerminal(container.Id)}>
|
||||
<TerminalIcon size={18} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button className="btn-icon-action start" title="시작" onClick={() => handleContainerAction(container.Id, 'start')} disabled={actionLoading === container.Id}>
|
||||
<Play size={18} fill="currentColor" />
|
||||
</button>
|
||||
)}
|
||||
<button className="btn-icon-action logs" title="로그" onClick={() => handleFetchLogs(container.Id)}>
|
||||
<FileText size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logs Modal */}
|
||||
{showLogs && (
|
||||
<div className="modal-overlay">
|
||||
<div className="glass-panel modal-content">
|
||||
<div className="modal-header">
|
||||
<h3>로그</h3>
|
||||
<button className="btn-icon" onClick={() => setShowLogs(null)}><X size={20} /></button>
|
||||
</div>
|
||||
<pre className="logs-viewer">
|
||||
{logsContent}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.containers-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.container-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.card-header-compact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.status-indicator {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-secondary);
|
||||
}
|
||||
.status-indicator.running { background: var(--success-color); box-shadow: 0 0 8px var(--success-color); }
|
||||
.status-indicator.exited { background: var(--error-color); }
|
||||
|
||||
.container-name {
|
||||
font-size: 1.1rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.container-info {
|
||||
flex: 1;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.label { color: var(--text-secondary); }
|
||||
.state-running { color: var(--success-color); }
|
||||
.state-exited { color: var(--error-color); }
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--glass-border);
|
||||
}
|
||||
.btn-icon-action {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.btn-icon-action:hover:not(:disabled) {
|
||||
background: rgba(255,255,255,0.1);
|
||||
color: var(--text-primary);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
.btn-icon-action:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-icon-action.start { color: var(--success-color); }
|
||||
.btn-icon-action.stop { color: var(--error-color); }
|
||||
.btn-icon-action.restart { color: var(--warning-color); }
|
||||
.btn-icon-action.terminal { color: var(--accent-color); }
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 2rem;
|
||||
}
|
||||
.modal-content {
|
||||
width: 90vw;
|
||||
max-width: 90vw;
|
||||
height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
.modal-header {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.logs-viewer {
|
||||
flex: 1;
|
||||
padding: 1rem;
|
||||
overflow: auto;
|
||||
background: #1e1e1e;
|
||||
color: #f0f0f0;
|
||||
font-family: monospace;
|
||||
font-size: 0.9rem;
|
||||
white-space: pre-wrap;
|
||||
border-bottom-left-radius: 16px;
|
||||
border-bottom-right-radius: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
margin-bottom: 1rem !important;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DockerManager;
|
||||
Reference in New Issue
Block a user