mirror of
https://github.com/ElppaDev/snStatus.git
synced 2026-01-29 09:35:36 +00:00
375 lines
16 KiB
JavaScript
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;
|