Add initial project files
This commit is contained in:
+514
@@ -0,0 +1,514 @@
|
||||
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);
|
||||
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();
|
||||
Reference in New Issue
Block a user