Files
snStatus/frontend/src/pages/DockerManager.jsx
2026-01-27 16:55:03 +09:00

375 lines
16 KiB
JavaScript

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;