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 = '
';
return;
}
const html = `
| Name |
Image |
Status |
Ports |
Created |
Actions |
${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 `
| ${escapeHtml(name)} |
${escapeHtml(image)} |
${status} |
${ports} |
${created} |
${actions} |
`;
}).join('')}
`;
container.innerHTML = html;
attachContainerEventListeners();
}
function getPortsHtml(ports) {
if (!ports || !ports.length) return 'No ports';
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]) => `${pub}→${priv}`)
.join('');
}
function getContainerActions(container) {
const isRunning = container.State === 'running';
return `
${isRunning ? `
` : `
`}
`;
}
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 `${badge.label}`;
}
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 = '';
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 ? `` : '';
rows.push(`
| ${escapeHtml(tag.split(':')[0] || '-')} |
${escapeHtml(tag.split(':')[1] || '-')} |
${escapeHtml(img.Id.slice(0, 12))} |
${isFirstTag ? size : ''} |
${isFirstTag ? created : ''} |
${actions} |
`);
});
});
const html = `
| Repository |
Tag |
ID |
Size |
Created |
Actions |
${rows.join('')}
`;
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);
const now = new Date();
const diff = now - date;
const seconds = Math.floor(diff / 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();