5314651aa2
Docker API always returns Created as Unix seconds (number), so the type-checking branches were dead code. Also fixes the missing * 1000 conversion that caused incorrect relative times. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
511 lines
17 KiB
JavaScript
511 lines
17 KiB
JavaScript
const API_BASE = '/api';
|
|
|
|
const state = {
|
|
tab: 'dashboard',
|
|
containers: [],
|
|
images: [],
|
|
info: null,
|
|
connected: false,
|
|
logsContainerId: null,
|
|
};
|
|
|
|
function init() {
|
|
setupEventListeners();
|
|
fetchInitialData();
|
|
setInterval(fetchInitialData, 30000);
|
|
}
|
|
|
|
function setupEventListeners() {
|
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
|
e.target.classList.add('active');
|
|
state.tab = e.target.dataset.tab;
|
|
renderTabs();
|
|
});
|
|
});
|
|
|
|
document.querySelector('.modal-close').addEventListener('click', closeLogsModal);
|
|
document.getElementById('logs-modal').addEventListener('click', (e) => {
|
|
if (e.target.id === 'logs-modal') closeLogsModal();
|
|
});
|
|
|
|
document.getElementById('logs-refresh').addEventListener('click', refreshLogs);
|
|
document.getElementById('logs-tail').addEventListener('change', refreshLogs);
|
|
|
|
document.getElementById('confirm-cancel').addEventListener('click', closeConfirmModal);
|
|
document.getElementById('confirm-ok').addEventListener('click', confirmAction);
|
|
document.getElementById('confirm-modal').addEventListener('click', (e) => {
|
|
if (e.target.id === 'confirm-modal') closeConfirmModal();
|
|
});
|
|
|
|
document.getElementById('pull-btn').addEventListener('click', pullImage);
|
|
document.getElementById('pull-input').addEventListener('keypress', (e) => {
|
|
if (e.key === 'Enter') pullImage();
|
|
});
|
|
}
|
|
|
|
async function fetchInitialData() {
|
|
try {
|
|
await Promise.all([
|
|
fetchInfo(),
|
|
fetchContainers(),
|
|
fetchImages(),
|
|
]);
|
|
state.connected = true;
|
|
updateStatusIndicator();
|
|
} catch (err) {
|
|
state.connected = false;
|
|
updateStatusIndicator();
|
|
}
|
|
renderTabs();
|
|
}
|
|
|
|
async function fetchInfo() {
|
|
const res = await fetch(`${API_BASE}/info`);
|
|
if (!res.ok) throw new Error('Failed to fetch info');
|
|
state.info = await res.json();
|
|
}
|
|
|
|
async function fetchContainers() {
|
|
const res = await fetch(`${API_BASE}/containers`);
|
|
if (!res.ok) throw new Error('Failed to fetch containers');
|
|
state.containers = (await res.json()) || [];
|
|
}
|
|
|
|
async function fetchImages() {
|
|
const res = await fetch(`${API_BASE}/images`);
|
|
if (!res.ok) throw new Error('Failed to fetch images');
|
|
state.images = (await res.json()) || [];
|
|
}
|
|
|
|
function updateStatusIndicator() {
|
|
const indicator = document.querySelector('.status-indicator');
|
|
const statusText = document.querySelector('.status-text');
|
|
if (state.connected) {
|
|
indicator.classList.add('connected');
|
|
statusText.textContent = 'Connected';
|
|
statusText.classList.add('connected');
|
|
statusText.classList.remove('disconnected');
|
|
} else {
|
|
indicator.classList.remove('connected');
|
|
statusText.textContent = 'Disconnected';
|
|
statusText.classList.add('disconnected');
|
|
statusText.classList.remove('connected');
|
|
}
|
|
}
|
|
|
|
function renderTabs() {
|
|
document.querySelectorAll('.tab-content').forEach(tab => {
|
|
tab.classList.remove('active');
|
|
});
|
|
document.getElementById(state.tab).classList.add('active');
|
|
|
|
if (state.tab === 'dashboard') {
|
|
renderDashboard();
|
|
} else if (state.tab === 'containers') {
|
|
renderContainers();
|
|
} else if (state.tab === 'images') {
|
|
renderImages();
|
|
}
|
|
}
|
|
|
|
function renderDashboard() {
|
|
const running = state.containers.filter(c => c.State === 'running').length;
|
|
const stopped = state.containers.filter(c => c.State === 'exited').length;
|
|
const total = state.containers.length;
|
|
|
|
document.getElementById('containers-total-count').textContent = total;
|
|
document.getElementById('containers-running-count').textContent = running;
|
|
document.getElementById('containers-stopped-count').textContent = stopped;
|
|
document.getElementById('images-count').textContent = state.images.length;
|
|
|
|
document.getElementById('docker-version').textContent = state.info?.ServerVersion || '-';
|
|
document.getElementById('docker-os').textContent =
|
|
`${state.info?.Os || '-'} / ${state.info?.Architecture || '-'}`;
|
|
document.getElementById('docker-cpus').textContent =
|
|
state.info?.NCPU ? `${state.info.NCPU}` : '-';
|
|
document.getElementById('docker-memory').textContent =
|
|
state.info?.MemTotal ? formatBytes(state.info.MemTotal) : '-';
|
|
document.getElementById('docker-kernel').textContent =
|
|
state.info?.KernelVersion || '-';
|
|
document.getElementById('docker-server').textContent =
|
|
state.info?.ServerVersion || '-';
|
|
}
|
|
|
|
function renderContainers() {
|
|
const container = document.getElementById('containers-list');
|
|
|
|
if (!state.containers.length) {
|
|
container.innerHTML = '<div class="empty-state"><div class="empty-icon">📦</div><p>No containers found</p></div>';
|
|
return;
|
|
}
|
|
|
|
const html = `
|
|
<table class="data-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Image</th>
|
|
<th>Status</th>
|
|
<th>Ports</th>
|
|
<th>Created</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${state.containers.map(c => {
|
|
const name = (c.Names || []).map(n => n.replace(/^\//,'')).join(', ') || c.Id.slice(0, 12);
|
|
const image = c.Image || '-';
|
|
const status = getStatusBadge(c.State);
|
|
const ports = getPortsHtml(c.Ports);
|
|
const created = formatRelativeTime(c.Created);
|
|
const actions = getContainerActions(c);
|
|
|
|
return `
|
|
<tr>
|
|
<td>${escapeHtml(name)}</td>
|
|
<td>${escapeHtml(image)}</td>
|
|
<td>${status}</td>
|
|
<td>${ports}</td>
|
|
<td>${created}</td>
|
|
<td>${actions}</td>
|
|
</tr>
|
|
`;
|
|
}).join('')}
|
|
</tbody>
|
|
</table>
|
|
`;
|
|
|
|
container.innerHTML = html;
|
|
attachContainerEventListeners();
|
|
}
|
|
|
|
function getPortsHtml(ports) {
|
|
if (!ports || !ports.length) return '<span class="text-muted">No ports</span>';
|
|
const unique = new Map();
|
|
ports.forEach(p => {
|
|
const key = `${p.PrivatePort}/${p.Type}`;
|
|
if (!unique.has(key)) {
|
|
unique.set(key, p.PublicPort || '-');
|
|
}
|
|
});
|
|
return Array.from(unique.entries())
|
|
.map(([priv, pub]) => `<span class="port-chip">${pub}→${priv}</span>`)
|
|
.join('');
|
|
}
|
|
|
|
function getContainerActions(container) {
|
|
const isRunning = container.State === 'running';
|
|
return `
|
|
<div class="action-buttons">
|
|
${isRunning ? `
|
|
<button class="btn btn-sm btn-warning" data-action="stop" data-id="${container.Id}">Stop</button>
|
|
<button class="btn btn-sm btn-warning" data-action="restart" data-id="${container.Id}">Restart</button>
|
|
` : `
|
|
<button class="btn btn-sm btn-success" data-action="start" data-id="${container.Id}">Start</button>
|
|
`}
|
|
<button class="btn btn-sm" data-action="logs" data-id="${container.Id}">Logs</button>
|
|
<button class="btn btn-sm btn-danger" data-action="remove" data-id="${container.Id}">Remove</button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function attachContainerEventListeners() {
|
|
document.querySelectorAll('[data-action]').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
const action = e.target.closest('[data-action]').dataset.action;
|
|
const id = e.target.closest('[data-action]').dataset.id;
|
|
handleContainerAction(action, id);
|
|
});
|
|
});
|
|
}
|
|
|
|
function getStatusBadge(state) {
|
|
const badges = {
|
|
'running': { class: 'badge-running', label: 'running' },
|
|
'paused': { class: 'badge-paused', label: 'paused' },
|
|
'exited': { class: 'badge-exited', label: 'exited' },
|
|
'created': { class: 'badge-created', label: 'created' },
|
|
};
|
|
const badge = badges[state] || { class: 'badge-exited', label: state };
|
|
return `<span class="badge ${badge.class}">${badge.label}</span>`;
|
|
}
|
|
|
|
async function handleContainerAction(action, id) {
|
|
try {
|
|
if (action === 'logs') {
|
|
state.logsContainerId = id;
|
|
await fetchAndShowLogs();
|
|
return;
|
|
}
|
|
|
|
if (action === 'remove') {
|
|
showConfirmModal(
|
|
'Remove Container',
|
|
'Are you sure you want to remove this container?',
|
|
async () => {
|
|
await executeContainerAction(action, id);
|
|
}
|
|
);
|
|
return;
|
|
}
|
|
|
|
await executeContainerAction(action, id);
|
|
} catch (err) {
|
|
showToast(`Error: ${err.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
async function executeContainerAction(action, id) {
|
|
let url = '';
|
|
let method = 'POST';
|
|
|
|
switch (action) {
|
|
case 'start':
|
|
url = `${API_BASE}/containers/${id}/start`;
|
|
break;
|
|
case 'stop':
|
|
url = `${API_BASE}/containers/${id}/stop`;
|
|
break;
|
|
case 'restart':
|
|
url = `${API_BASE}/containers/${id}/restart`;
|
|
break;
|
|
case 'remove':
|
|
url = `${API_BASE}/containers/${id}`;
|
|
method = 'DELETE';
|
|
break;
|
|
default:
|
|
return;
|
|
}
|
|
|
|
const res = await fetch(url, { method });
|
|
if (!res.ok) throw new Error(`Failed to ${action} container`);
|
|
|
|
showToast(`Container ${action}ed successfully`, 'success');
|
|
await Promise.all([fetchContainers(), fetchInitialData()]);
|
|
renderTabs();
|
|
}
|
|
|
|
async function fetchAndShowLogs() {
|
|
try {
|
|
const tail = document.getElementById('logs-tail').value;
|
|
const res = await fetch(`${API_BASE}/containers/${state.logsContainerId}/logs?tail=${tail}`);
|
|
if (!res.ok) throw new Error('Failed to fetch logs');
|
|
const data = await res.json();
|
|
|
|
let logs = data.logs || 'No logs available';
|
|
logs = stripAnsi(logs);
|
|
|
|
document.getElementById('logs-content').textContent = logs;
|
|
document.getElementById('logs-modal').style.display = 'flex';
|
|
} catch (err) {
|
|
showToast(`Error: ${err.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
function stripAnsi(text) {
|
|
return text.replace(/\x1b\[[0-9;]*m/g, '');
|
|
}
|
|
|
|
function refreshLogs() {
|
|
if (state.logsContainerId) {
|
|
fetchAndShowLogs();
|
|
}
|
|
}
|
|
|
|
function closeLogsModal() {
|
|
document.getElementById('logs-modal').style.display = 'none';
|
|
}
|
|
|
|
function renderImages() {
|
|
const container = document.getElementById('images-list');
|
|
|
|
if (!state.images.length) {
|
|
container.innerHTML = '<div class="empty-state"><div class="empty-icon">🖼</div><p>No images found</p></div>';
|
|
return;
|
|
}
|
|
|
|
const rows = [];
|
|
state.images.forEach((img) => {
|
|
const tags = img.RepoTags || ['untagged'];
|
|
const size = formatBytes(img.Size || 0);
|
|
const created = formatRelativeTime(img.Created);
|
|
|
|
tags.forEach((tag, tagIdx) => {
|
|
const isFirstTag = tagIdx === 0;
|
|
const actions = isFirstTag ? `<button class="btn btn-sm btn-danger" data-action="remove-image" data-id="${img.Id}">Remove</button>` : '';
|
|
|
|
rows.push(`
|
|
<tr>
|
|
<td>${escapeHtml(tag.split(':')[0] || '-')}</td>
|
|
<td>${escapeHtml(tag.split(':')[1] || '-')}</td>
|
|
<td>${escapeHtml(img.Id.slice(0, 12))}</td>
|
|
<td>${isFirstTag ? size : ''}</td>
|
|
<td>${isFirstTag ? created : ''}</td>
|
|
<td>${actions}</td>
|
|
</tr>
|
|
`);
|
|
});
|
|
});
|
|
|
|
const html = `
|
|
<table class="data-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Repository</th>
|
|
<th>Tag</th>
|
|
<th>ID</th>
|
|
<th>Size</th>
|
|
<th>Created</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${rows.join('')}
|
|
</tbody>
|
|
</table>
|
|
`;
|
|
|
|
container.innerHTML = html;
|
|
attachImageEventListeners();
|
|
}
|
|
|
|
function attachImageEventListeners() {
|
|
document.querySelectorAll('[data-action="remove-image"]').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
const id = e.target.dataset.id;
|
|
showConfirmModal(
|
|
'Remove Image',
|
|
'Are you sure you want to remove this image?',
|
|
async () => {
|
|
try {
|
|
const res = await fetch(`${API_BASE}/images/${id}`, { method: 'DELETE' });
|
|
if (!res.ok) throw new Error('Failed to remove image');
|
|
showToast('Image removed successfully', 'success');
|
|
await fetchImages();
|
|
renderTabs();
|
|
} catch (err) {
|
|
showToast(`Error: ${err.message}`, 'error');
|
|
}
|
|
}
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
async function pullImage() {
|
|
const input = document.getElementById('pull-input');
|
|
const name = input.value.trim();
|
|
|
|
if (!name) {
|
|
showToast('Please enter an image name', 'error');
|
|
return;
|
|
}
|
|
|
|
const progressDiv = document.getElementById('pull-progress');
|
|
progressDiv.style.display = 'block';
|
|
progressDiv.innerHTML = '';
|
|
const layerStates = {};
|
|
|
|
try {
|
|
const res = await fetch(`${API_BASE}/images/pull?name=${encodeURIComponent(name)}`);
|
|
if (!res.ok) throw new Error('Failed to pull image');
|
|
|
|
const reader = res.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
|
|
const lines = decoder.decode(value).split('\n').filter(l => l);
|
|
for (const line of lines) {
|
|
try {
|
|
const data = JSON.parse(line);
|
|
if (data.id && data.status) {
|
|
const layerId = data.id;
|
|
if (!layerStates[layerId]) {
|
|
layerStates[layerId] = document.createElement('div');
|
|
layerStates[layerId].className = 'pull-progress-item';
|
|
layerStates[layerId].dataset.layerId = layerId;
|
|
progressDiv.appendChild(layerStates[layerId]);
|
|
}
|
|
layerStates[layerId].textContent = `${layerId.slice(0, 12)}: ${data.status}`;
|
|
progressDiv.scrollTop = progressDiv.scrollHeight;
|
|
}
|
|
} catch (e) {
|
|
// Not JSON, skip
|
|
}
|
|
}
|
|
}
|
|
|
|
showToast('Image pulled successfully', 'success');
|
|
await fetchImages();
|
|
renderTabs();
|
|
input.value = '';
|
|
progressDiv.style.display = 'none';
|
|
} catch (err) {
|
|
showToast(`Error: ${err.message}`, 'error');
|
|
progressDiv.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
function showConfirmModal(title, message, onConfirm) {
|
|
window.pendingConfirmAction = onConfirm;
|
|
document.getElementById('confirm-title').textContent = title;
|
|
document.getElementById('confirm-message').textContent = message;
|
|
document.getElementById('confirm-modal').style.display = 'flex';
|
|
}
|
|
|
|
function closeConfirmModal() {
|
|
window.pendingConfirmAction = null;
|
|
document.getElementById('confirm-modal').style.display = 'none';
|
|
}
|
|
|
|
function confirmAction() {
|
|
if (window.pendingConfirmAction) {
|
|
window.pendingConfirmAction();
|
|
}
|
|
closeConfirmModal();
|
|
}
|
|
|
|
function formatBytes(bytes) {
|
|
if (!bytes) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
|
|
}
|
|
|
|
function formatRelativeTime(timestamp) {
|
|
if (!timestamp) return '-';
|
|
const date = new Date(timestamp * 1000);
|
|
const seconds = Math.floor((Date.now() - date) / 1000);
|
|
const minutes = Math.floor(seconds / 60);
|
|
const hours = Math.floor(minutes / 60);
|
|
const days = Math.floor(hours / 24);
|
|
if (seconds < 60) return `${seconds}s ago`;
|
|
if (minutes < 60) return `${minutes}m ago`;
|
|
if (hours < 24) return `${hours}h ago`;
|
|
return `${days}d ago`;
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function showToast(message, type = 'info') {
|
|
const toast = document.getElementById('toast');
|
|
toast.textContent = message;
|
|
toast.className = `toast show ${type}`;
|
|
|
|
setTimeout(() => {
|
|
toast.classList.remove('show');
|
|
}, 3000);
|
|
}
|
|
|
|
init();
|