refactorisation pour correction de sécurité
Tests / Backend Tests (Python) (3.10) (push) Has been cancelled
Tests / Backend Tests (Python) (3.11) (push) Has been cancelled
Tests / Backend Tests (Python) (3.12) (push) Has been cancelled
Tests / Frontend Tests (JS) (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / All Tests Passed (push) Has been cancelled
Tests / Backend Tests (Python) (3.10) (push) Has been cancelled
Tests / Backend Tests (Python) (3.11) (push) Has been cancelled
Tests / Backend Tests (Python) (3.12) (push) Has been cancelled
Tests / Frontend Tests (JS) (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / All Tests Passed (push) Has been cancelled
This commit is contained in:
File diff suppressed because it is too large
Load Diff
+109
-109
@@ -13,12 +13,12 @@ const containersPage = {
|
||||
inspectData: null,
|
||||
_initialized: false,
|
||||
_initPromise: null,
|
||||
|
||||
|
||||
// View settings
|
||||
viewMode: 'comfortable', // 'comfortable', 'compact', 'grouped'
|
||||
currentPage: 1,
|
||||
perPage: 50,
|
||||
|
||||
|
||||
// Filter state
|
||||
filters: {
|
||||
search: '',
|
||||
@@ -30,10 +30,10 @@ const containersPage = {
|
||||
favoritesOnly: false,
|
||||
|
||||
// ========== INITIALIZATION ==========
|
||||
|
||||
|
||||
async init() {
|
||||
if (this._initPromise) return await this._initPromise;
|
||||
|
||||
|
||||
this._initPromise = (async () => {
|
||||
this.setupEventListeners();
|
||||
this.setupKeyboardShortcuts();
|
||||
@@ -43,7 +43,7 @@ const containersPage = {
|
||||
await this.loadData();
|
||||
this._initialized = true;
|
||||
})();
|
||||
|
||||
|
||||
return await this._initPromise;
|
||||
},
|
||||
|
||||
@@ -150,7 +150,7 @@ const containersPage = {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Only active when on containers page
|
||||
if (currentPage !== 'docker-containers') return;
|
||||
|
||||
|
||||
// Ignore if in input
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT' || e.target.tagName === 'TEXTAREA') {
|
||||
// Escape clears search
|
||||
@@ -184,7 +184,7 @@ const containersPage = {
|
||||
|
||||
async loadData() {
|
||||
this.showLoading();
|
||||
|
||||
|
||||
try {
|
||||
const [containersRes, hostsRes] = await Promise.all([
|
||||
this.fetchAPI('/api/docker/containers'),
|
||||
@@ -193,16 +193,16 @@ const containersPage = {
|
||||
|
||||
this.containers = containersRes.containers || [];
|
||||
this.hosts = hostsRes.hosts || [];
|
||||
|
||||
|
||||
// Update host filter dropdown
|
||||
this.populateHostFilter();
|
||||
|
||||
|
||||
// Update stats
|
||||
this.updateStats(containersRes);
|
||||
|
||||
|
||||
// Apply filters and render
|
||||
this.applyFilters();
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading containers:', error);
|
||||
this.showError(error.message);
|
||||
@@ -212,9 +212,9 @@ const containersPage = {
|
||||
async refresh() {
|
||||
const icon = document.getElementById('containers-refresh-icon');
|
||||
if (icon) icon.classList.add('fa-spin');
|
||||
|
||||
|
||||
await this.loadData();
|
||||
|
||||
|
||||
if (icon) icon.classList.remove('fa-spin');
|
||||
this.showToast('Données actualisées', 'success');
|
||||
},
|
||||
@@ -225,16 +225,16 @@ const containersPage = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
};
|
||||
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
|
||||
const response = await fetch(`${window.location.origin}${endpoint}`, {
|
||||
...options,
|
||||
headers
|
||||
});
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
this.showToast('Session expirée', 'error');
|
||||
@@ -243,7 +243,7 @@ const containersPage = {
|
||||
}
|
||||
throw new Error(`API Error: ${response.status}`);
|
||||
}
|
||||
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
@@ -251,22 +251,22 @@ const containersPage = {
|
||||
|
||||
applyFilters() {
|
||||
let result = [...this.containers];
|
||||
|
||||
|
||||
// Text search with smart tokens
|
||||
if (this.filters.search) {
|
||||
result = this.smartSearch(result, this.filters.search);
|
||||
}
|
||||
|
||||
|
||||
// Status filter
|
||||
if (this.filters.status) {
|
||||
result = result.filter(c => c.state === this.filters.status);
|
||||
}
|
||||
|
||||
|
||||
// Host filter
|
||||
if (this.filters.host) {
|
||||
result = result.filter(c => c.host_id === this.filters.host);
|
||||
}
|
||||
|
||||
|
||||
// Health filter
|
||||
if (this.filters.health) {
|
||||
if (this.filters.health === 'none') {
|
||||
@@ -280,10 +280,10 @@ const containersPage = {
|
||||
if (this.favoritesOnly && window.favoritesManager) {
|
||||
result = result.filter(c => window.favoritesManager.isFavorite(c.host_id, c.container_id));
|
||||
}
|
||||
|
||||
|
||||
// Sort
|
||||
result = this.sortContainers(result, this.sortBy);
|
||||
|
||||
|
||||
this.filteredContainers = result;
|
||||
this.currentPage = 1;
|
||||
this.updateActiveFilters();
|
||||
@@ -292,7 +292,7 @@ const containersPage = {
|
||||
|
||||
smartSearch(containers, query) {
|
||||
const tokens = this.parseSearchTokens(query);
|
||||
|
||||
|
||||
return containers.filter(c => {
|
||||
// Check token filters
|
||||
for (const token of tokens.filters) {
|
||||
@@ -319,7 +319,7 @@ const containersPage = {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Free text search
|
||||
if (tokens.freeText) {
|
||||
const searchStr = tokens.freeText.toLowerCase();
|
||||
@@ -330,10 +330,10 @@ const containersPage = {
|
||||
c.compose_project,
|
||||
c.container_id?.substring(0, 12)
|
||||
].filter(Boolean).join(' ').toLowerCase();
|
||||
|
||||
|
||||
if (!searchable.includes(searchStr)) return false;
|
||||
}
|
||||
|
||||
|
||||
return true;
|
||||
});
|
||||
},
|
||||
@@ -341,16 +341,16 @@ const containersPage = {
|
||||
parseSearchTokens(query) {
|
||||
const filters = [];
|
||||
let freeText = query;
|
||||
|
||||
|
||||
// Match tokens like "host:value" or "status:running"
|
||||
const tokenRegex = /(\w+):(\S+)/g;
|
||||
let match;
|
||||
|
||||
|
||||
while ((match = tokenRegex.exec(query)) !== null) {
|
||||
filters.push({ key: match[1], value: match[2] });
|
||||
freeText = freeText.replace(match[0], '').trim();
|
||||
}
|
||||
|
||||
|
||||
return { filters, freeText };
|
||||
},
|
||||
|
||||
@@ -363,10 +363,10 @@ const containersPage = {
|
||||
sortContainers(containers, sortBy) {
|
||||
const [field, direction] = sortBy.split('-');
|
||||
const mult = direction === 'desc' ? -1 : 1;
|
||||
|
||||
|
||||
return containers.sort((a, b) => {
|
||||
let valA, valB;
|
||||
|
||||
|
||||
switch (field) {
|
||||
case 'name':
|
||||
valA = a.name.toLowerCase();
|
||||
@@ -389,7 +389,7 @@ const containersPage = {
|
||||
valA = a.name.toLowerCase();
|
||||
valB = b.name.toLowerCase();
|
||||
}
|
||||
|
||||
|
||||
if (valA < valB) return -1 * mult;
|
||||
if (valA > valB) return 1 * mult;
|
||||
return 0;
|
||||
@@ -403,10 +403,10 @@ const containersPage = {
|
||||
const empty = document.getElementById('containers-empty');
|
||||
const error = document.getElementById('containers-error');
|
||||
const pagination = document.getElementById('containers-pagination');
|
||||
|
||||
|
||||
// Hide error
|
||||
error?.classList.add('hidden');
|
||||
|
||||
|
||||
// Check empty
|
||||
if (this.filteredContainers.length === 0) {
|
||||
list.innerHTML = '';
|
||||
@@ -414,24 +414,24 @@ const containersPage = {
|
||||
pagination?.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
empty?.classList.add('hidden');
|
||||
|
||||
|
||||
// Paginate
|
||||
const start = (this.currentPage - 1) * this.perPage;
|
||||
const end = Math.min(start + this.perPage, this.filteredContainers.length);
|
||||
const pageContainers = this.filteredContainers.slice(start, end);
|
||||
|
||||
|
||||
// Render based on view mode
|
||||
if (this.viewMode === 'grouped') {
|
||||
list.innerHTML = this.renderGroupedView(pageContainers);
|
||||
} else {
|
||||
list.innerHTML = pageContainers.map(c => this.renderContainerRow(c)).join('');
|
||||
}
|
||||
|
||||
|
||||
// Update pagination
|
||||
this.updatePagination(start, end);
|
||||
|
||||
|
||||
// Update search clear button visibility
|
||||
const clearBtn = document.getElementById('containers-search-clear');
|
||||
if (clearBtn) {
|
||||
@@ -462,19 +462,19 @@ const containersPage = {
|
||||
const iconHtml = iconKey
|
||||
? `<span class="w-5 h-5 rounded flex items-center justify-center border border-gray-700" style="${bgStyle}"><span class="iconify text-sm" data-icon="${this.escapeHtml(iconKey)}" style="color:${this.escapeHtml(iconColor)}"></span></span>`
|
||||
: '';
|
||||
|
||||
|
||||
const healthBadge = c.health && c.health !== 'none' ? `
|
||||
<span class="px-2 py-0.5 rounded text-xs bg-${c.health === 'healthy' ? 'green' : 'red'}-500/20 text-${c.health === 'healthy' ? 'green' : 'red'}-400">
|
||||
${c.health}
|
||||
</span>
|
||||
` : '';
|
||||
|
||||
|
||||
const projectBadge = c.compose_project ? `
|
||||
<span class="px-2 py-0.5 rounded text-xs bg-purple-500/20 text-purple-400">${this.escapeHtml(c.compose_project)}</span>
|
||||
` : '';
|
||||
|
||||
|
||||
const portLinks = this.renderPortLinks(c);
|
||||
|
||||
|
||||
if (isCompact) {
|
||||
return `
|
||||
<div class="bg-gray-800/40 rounded-lg px-3 py-2 flex items-center gap-3 hover:bg-gray-800/60 transition-colors cursor-pointer"
|
||||
@@ -499,7 +499,7 @@ const containersPage = {
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
return `
|
||||
<div class="bg-gray-800/40 rounded-lg p-4 hover:bg-gray-800/60 transition-colors cursor-pointer"
|
||||
onclick="containersPage.openDrawer('${c.host_id}', '${c.container_id}')"
|
||||
@@ -566,7 +566,7 @@ const containersPage = {
|
||||
}
|
||||
groups[c.host_id].containers.push(c);
|
||||
});
|
||||
|
||||
|
||||
return Object.entries(groups).map(([hostId, group]) => `
|
||||
<div class="glass-card overflow-hidden mb-4">
|
||||
<div class="bg-gray-800/60 px-4 py-3 flex items-center justify-between border-b border-gray-700">
|
||||
@@ -586,7 +586,7 @@ const containersPage = {
|
||||
|
||||
renderQuickActions(c) {
|
||||
const isRunning = c.state === 'running';
|
||||
|
||||
|
||||
return `
|
||||
${!isRunning ? `
|
||||
<button onclick="event.stopPropagation(); containersPage.containerAction('${c.host_id}', '${c.container_id}', 'start')"
|
||||
@@ -612,26 +612,26 @@ const containersPage = {
|
||||
|
||||
renderPortLinks(c) {
|
||||
if (!c.ports) return '';
|
||||
|
||||
|
||||
const portStr = c.ports.raw || (typeof c.ports === 'string' ? c.ports : '');
|
||||
if (!portStr) return '';
|
||||
|
||||
|
||||
const portRegex = /(?:([\d.]+):)?(\d+)->\d+\/tcp/g;
|
||||
const links = [];
|
||||
const seenPorts = new Set();
|
||||
let match;
|
||||
|
||||
|
||||
while ((match = portRegex.exec(portStr)) !== null) {
|
||||
const bindIp = match[1] || '0.0.0.0';
|
||||
const hostPort = match[2];
|
||||
|
||||
|
||||
if (bindIp === '127.0.0.1' || bindIp === '::1') continue;
|
||||
if (seenPorts.has(hostPort)) continue;
|
||||
seenPorts.add(hostPort);
|
||||
|
||||
|
||||
const protocol = ['443', '8443', '9443'].includes(hostPort) ? 'https' : 'http';
|
||||
const url = `${protocol}://${c.host_ip}:${hostPort}`;
|
||||
|
||||
|
||||
links.push(`
|
||||
<a href="${url}" target="_blank" rel="noopener noreferrer"
|
||||
onclick="event.stopPropagation()"
|
||||
@@ -640,7 +640,7 @@ const containersPage = {
|
||||
</a>
|
||||
`);
|
||||
}
|
||||
|
||||
|
||||
return links.slice(0, 3).join(''); // Limit to 3 port links
|
||||
},
|
||||
|
||||
@@ -675,17 +675,17 @@ const containersPage = {
|
||||
const elem = document.getElementById(id);
|
||||
if (elem) elem.textContent = val;
|
||||
};
|
||||
|
||||
|
||||
el('containers-total', data.total || 0);
|
||||
el('containers-running', data.running || 0);
|
||||
el('containers-stopped', data.stopped || 0);
|
||||
el('containers-paused', data.paused || 0);
|
||||
el('containers-hosts-count', data.hosts_count || 0);
|
||||
|
||||
|
||||
// Update last update time
|
||||
if (data.last_update) {
|
||||
const date = new Date(data.last_update);
|
||||
document.getElementById('containers-last-update').textContent =
|
||||
document.getElementById('containers-last-update').textContent =
|
||||
`Mis à jour ${this.formatRelativeTime(date)}`;
|
||||
}
|
||||
},
|
||||
@@ -693,12 +693,12 @@ const containersPage = {
|
||||
updatePagination(start, end) {
|
||||
const pagination = document.getElementById('containers-pagination');
|
||||
if (!pagination) return;
|
||||
|
||||
|
||||
if (this.filteredContainers.length <= this.perPage) {
|
||||
pagination.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
pagination.classList.remove('hidden');
|
||||
document.getElementById('containers-showing-start').textContent = start + 1;
|
||||
document.getElementById('containers-showing-end').textContent = end;
|
||||
@@ -708,9 +708,9 @@ const containersPage = {
|
||||
updateActiveFilters() {
|
||||
const container = document.getElementById('containers-active-filters');
|
||||
if (!container) return;
|
||||
|
||||
|
||||
const activeFilters = [];
|
||||
|
||||
|
||||
if (this.filters.status) {
|
||||
activeFilters.push({ key: 'status', value: this.filters.status, label: `Status: ${this.filters.status}` });
|
||||
}
|
||||
@@ -721,12 +721,12 @@ const containersPage = {
|
||||
if (this.filters.health) {
|
||||
activeFilters.push({ key: 'health', value: this.filters.health, label: `Health: ${this.filters.health}` });
|
||||
}
|
||||
|
||||
|
||||
if (activeFilters.length === 0) {
|
||||
container.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
container.classList.remove('hidden');
|
||||
container.innerHTML = activeFilters.map(f => `
|
||||
<span class="inline-flex items-center gap-1 px-2 py-1 bg-purple-500/20 text-purple-300 rounded text-sm">
|
||||
@@ -745,10 +745,10 @@ const containersPage = {
|
||||
populateHostFilter() {
|
||||
const select = document.getElementById('containers-filter-host');
|
||||
if (!select) return;
|
||||
|
||||
|
||||
// Keep first option
|
||||
select.innerHTML = '<option value="">Tous les hosts</option>';
|
||||
|
||||
|
||||
this.hosts.forEach(host => {
|
||||
const option = document.createElement('option');
|
||||
option.value = host.host_id;
|
||||
@@ -759,7 +759,7 @@ const containersPage = {
|
||||
|
||||
setViewMode(mode) {
|
||||
this.viewMode = mode;
|
||||
|
||||
|
||||
// Update button states
|
||||
['comfortable', 'compact', 'grouped'].forEach(m => {
|
||||
const btn = document.getElementById(`containers-view-${m}`);
|
||||
@@ -769,7 +769,7 @@ const containersPage = {
|
||||
btn.setAttribute('aria-pressed', m === mode ? 'true' : 'false');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
this.render();
|
||||
},
|
||||
|
||||
@@ -794,11 +794,11 @@ const containersPage = {
|
||||
async containerAction(hostId, containerId, action) {
|
||||
try {
|
||||
this.showToast(`${action}...`, 'info');
|
||||
|
||||
|
||||
const response = await this.fetchAPI(`/api/docker/containers/${hostId}/${containerId}/${action}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
|
||||
if (response.success) {
|
||||
this.showToast(`Container ${action} réussi`, 'success');
|
||||
// Refresh after a short delay
|
||||
@@ -816,12 +816,12 @@ const containersPage = {
|
||||
this.showToast('Aucun container sélectionné', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const confirmMsg = `Êtes-vous sûr de vouloir ${action} ${this.selectedIds.size} container(s) ?`;
|
||||
if (!confirm(confirmMsg)) return;
|
||||
|
||||
|
||||
this.showToast(`${action} en cours...`, 'info');
|
||||
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
Array.from(this.selectedIds).map(id => {
|
||||
const [hostId, containerId] = id.split('::');
|
||||
@@ -830,11 +830,11 @@ const containersPage = {
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
const successful = results.filter(r => r.status === 'fulfilled' && r.value.success).length;
|
||||
this.showToast(`${successful}/${this.selectedIds.size} container(s) ${action}`,
|
||||
successful === this.selectedIds.size ? 'success' : 'warning');
|
||||
|
||||
this.showToast(`${successful}/${this.selectedIds.size} container(s) ${action}`,
|
||||
successful === this.selectedIds.size ? 'success' : 'warning');
|
||||
|
||||
this.selectedIds.clear();
|
||||
this.updateBulkActionsBar();
|
||||
setTimeout(() => this.refresh(), 1000);
|
||||
@@ -865,7 +865,7 @@ const containersPage = {
|
||||
const bar = document.getElementById('containers-bulk-actions');
|
||||
const count = document.getElementById('containers-selected-count');
|
||||
const selectAll = document.getElementById('containers-select-all');
|
||||
|
||||
|
||||
if (bar) {
|
||||
bar.classList.toggle('hidden', this.selectedIds.size === 0);
|
||||
}
|
||||
@@ -873,8 +873,8 @@ const containersPage = {
|
||||
count.textContent = this.selectedIds.size;
|
||||
}
|
||||
if (selectAll) {
|
||||
selectAll.checked = this.selectedIds.size > 0 &&
|
||||
this.selectedIds.size === this.filteredContainers.length;
|
||||
selectAll.checked = this.selectedIds.size > 0 &&
|
||||
this.selectedIds.size === this.filteredContainers.length;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -883,34 +883,34 @@ const containersPage = {
|
||||
async openDrawer(hostId, containerId, tab = 'overview') {
|
||||
const wantedHostId = String(hostId);
|
||||
const wantedContainerId = String(containerId);
|
||||
|
||||
|
||||
if (!this._initialized || !this.containers || this.containers.length === 0) {
|
||||
await this.ensureInit();
|
||||
}
|
||||
|
||||
|
||||
this.currentContainer = this.containers.find(
|
||||
c => String(c.host_id) === wantedHostId && String(c.container_id) === wantedContainerId
|
||||
);
|
||||
|
||||
|
||||
if (!this.currentContainer) {
|
||||
this.showToast('Container non trouvé', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const drawer = document.getElementById('container-drawer');
|
||||
const backdrop = document.getElementById('container-drawer-backdrop');
|
||||
|
||||
|
||||
// Populate drawer
|
||||
this.populateDrawer();
|
||||
|
||||
|
||||
// Show drawer
|
||||
drawer.classList.remove('translate-x-full');
|
||||
backdrop.classList.remove('hidden');
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
|
||||
// Switch to requested tab
|
||||
this.switchDrawerTab(tab);
|
||||
|
||||
|
||||
// Load tab-specific data
|
||||
if (tab === 'logs') {
|
||||
await this.loadLogs();
|
||||
@@ -922,11 +922,11 @@ const containersPage = {
|
||||
closeDrawer() {
|
||||
const drawer = document.getElementById('container-drawer');
|
||||
const backdrop = document.getElementById('container-drawer-backdrop');
|
||||
|
||||
|
||||
drawer.classList.add('translate-x-full');
|
||||
backdrop.classList.add('hidden');
|
||||
document.body.style.overflow = '';
|
||||
|
||||
|
||||
this.currentContainer = null;
|
||||
this.inspectData = null;
|
||||
},
|
||||
@@ -939,7 +939,7 @@ const containersPage = {
|
||||
populateDrawer() {
|
||||
const c = this.currentContainer;
|
||||
if (!c) return;
|
||||
|
||||
|
||||
const stateColors = {
|
||||
running: 'bg-green-500',
|
||||
exited: 'bg-red-500',
|
||||
@@ -948,10 +948,10 @@ const containersPage = {
|
||||
restarting: 'bg-orange-500',
|
||||
dead: 'bg-red-500'
|
||||
};
|
||||
|
||||
|
||||
// Header
|
||||
document.getElementById('drawer-container-name').textContent = c.name;
|
||||
document.getElementById('drawer-container-state').className =
|
||||
document.getElementById('drawer-container-state').className =
|
||||
`w-3 h-3 rounded-full ${stateColors[c.state] || 'bg-gray-500'}`;
|
||||
|
||||
const custom = c.customization || window.containerCustomizationsManager?.get(c.host_id, c.container_id);
|
||||
@@ -965,7 +965,7 @@ const containersPage = {
|
||||
? `<span class="w-6 h-6 rounded flex items-center justify-center border border-gray-700" style="${bgStyle}"><span class="iconify text-base" data-icon="${this.escapeHtml(iconKey)}" style="color:${this.escapeHtml(iconColor)}"></span></span>`
|
||||
: '';
|
||||
}
|
||||
|
||||
|
||||
// Overview
|
||||
document.getElementById('drawer-host-name').textContent = c.host_name;
|
||||
document.getElementById('drawer-host-ip').textContent = c.host_ip;
|
||||
@@ -973,7 +973,7 @@ const containersPage = {
|
||||
document.getElementById('drawer-health').textContent = c.health || 'No healthcheck';
|
||||
document.getElementById('drawer-image').textContent = c.image || '—';
|
||||
document.getElementById('drawer-container-id').textContent = c.container_id;
|
||||
|
||||
|
||||
// Ports
|
||||
const portsEl = document.getElementById('drawer-ports');
|
||||
if (c.ports) {
|
||||
@@ -981,7 +981,7 @@ const containersPage = {
|
||||
} else {
|
||||
portsEl.innerHTML = '<span class="text-gray-500">Aucun port exposé</span>';
|
||||
}
|
||||
|
||||
|
||||
// Labels
|
||||
const labelsEl = document.getElementById('drawer-labels');
|
||||
if (c.labels && Object.keys(c.labels).length > 0) {
|
||||
@@ -995,7 +995,7 @@ const containersPage = {
|
||||
} else {
|
||||
labelsEl.innerHTML = '<span class="text-gray-500">Aucun label</span>';
|
||||
}
|
||||
|
||||
|
||||
// Update action buttons based on state
|
||||
const isRunning = c.state === 'running';
|
||||
document.getElementById('drawer-btn-start')?.classList.toggle('hidden', isRunning);
|
||||
@@ -1008,12 +1008,12 @@ const containersPage = {
|
||||
tab.classList.toggle('active', tab.dataset.tab === tabName);
|
||||
tab.classList.toggle('text-gray-400', tab.dataset.tab !== tabName);
|
||||
});
|
||||
|
||||
|
||||
// Update tab content
|
||||
document.querySelectorAll('.drawer-tab-content').forEach(content => {
|
||||
content.classList.toggle('hidden', !content.id.endsWith(tabName));
|
||||
});
|
||||
|
||||
|
||||
// Load content if needed
|
||||
if (tabName === 'logs' && this.currentContainer) {
|
||||
this.loadLogs();
|
||||
@@ -1024,18 +1024,18 @@ const containersPage = {
|
||||
|
||||
async loadLogs() {
|
||||
if (!this.currentContainer) return;
|
||||
|
||||
|
||||
const logsEl = document.getElementById('drawer-logs-content');
|
||||
logsEl.textContent = 'Chargement...';
|
||||
|
||||
|
||||
try {
|
||||
const tail = document.getElementById('drawer-logs-tail')?.value || 200;
|
||||
const timestamps = document.getElementById('drawer-logs-timestamps')?.checked || false;
|
||||
|
||||
|
||||
const response = await this.fetchAPI(
|
||||
`/api/docker/containers/${this.currentContainer.host_id}/${this.currentContainer.container_id}/logs?tail=${tail}×tamps=${timestamps}`
|
||||
);
|
||||
|
||||
|
||||
logsEl.textContent = response.logs || 'Aucun log disponible';
|
||||
} catch (error) {
|
||||
logsEl.textContent = `Erreur: ${error.message}`;
|
||||
@@ -1044,15 +1044,15 @@ const containersPage = {
|
||||
|
||||
async loadInspect() {
|
||||
if (!this.currentContainer) return;
|
||||
|
||||
|
||||
const inspectEl = document.getElementById('drawer-inspect-content');
|
||||
inspectEl.textContent = 'Chargement...';
|
||||
|
||||
|
||||
try {
|
||||
const response = await this.fetchAPI(
|
||||
`/api/docker/containers/${this.currentContainer.host_id}/${this.currentContainer.container_id}/inspect`
|
||||
);
|
||||
|
||||
|
||||
this.inspectData = response.inspect_data || {};
|
||||
inspectEl.textContent = JSON.stringify(this.inspectData, null, 2);
|
||||
} catch (error) {
|
||||
@@ -1065,7 +1065,7 @@ const containersPage = {
|
||||
this.showToast('Aucune donnée à copier', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(JSON.stringify(this.inspectData, null, 2));
|
||||
this.showToast('JSON copié !', 'success');
|
||||
@@ -1105,8 +1105,8 @@ const containersPage = {
|
||||
const now = new Date();
|
||||
const d = new Date(date);
|
||||
const diff = Math.floor((now - d) / 1000);
|
||||
|
||||
if (diff < 60) return 'À l\'instant';
|
||||
|
||||
if (diff < 60) return 'À l instant';
|
||||
if (diff < 3600) return `il y a ${Math.floor(diff / 60)} min`;
|
||||
if (diff < 86400) return `il y a ${Math.floor(diff / 3600)} h`;
|
||||
return `il y a ${Math.floor(diff / 86400)} j`;
|
||||
@@ -1129,7 +1129,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
// Listen for page navigation
|
||||
const originalNavigateTo = window.navigateTo;
|
||||
if (originalNavigateTo) {
|
||||
window.navigateTo = function(pageName) {
|
||||
window.navigateTo = function (pageName) {
|
||||
originalNavigateTo(pageName);
|
||||
if (pageName === 'docker-containers') {
|
||||
containersPage.init();
|
||||
|
||||
+9
-3
@@ -60,14 +60,14 @@ class Settings(BaseSettings):
|
||||
ssh_remote_user: str = Field(default_factory=lambda: os.environ.get("SSH_REMOTE_USER", "root"))
|
||||
|
||||
# === API ===
|
||||
api_key: str = Field(default_factory=lambda: os.environ.get("API_KEY", "dev-key-12345"))
|
||||
api_key: str = Field(default_factory=lambda: os.environ.get("API_KEY", ""))
|
||||
api_title: str = "Homelab Automation Dashboard API"
|
||||
api_version: str = "1.0.0"
|
||||
api_description: str = "API REST moderne pour la gestion automatique d'homelab"
|
||||
|
||||
# === JWT Authentication ===
|
||||
jwt_secret_key: str = Field(
|
||||
default_factory=lambda: os.environ.get("JWT_SECRET_KEY", "dev-secret-key-change-in-production")
|
||||
default_factory=lambda: os.environ.get("JWT_SECRET_KEY", "")
|
||||
)
|
||||
jwt_expire_minutes: int = Field(
|
||||
default_factory=lambda: int(os.environ.get("JWT_EXPIRE_MINUTES", "1440"))
|
||||
@@ -85,7 +85,13 @@ class Settings(BaseSettings):
|
||||
return f"sqlite+aiosqlite:///{self.db_path}"
|
||||
|
||||
# === CORS ===
|
||||
cors_origins: list = Field(default=["*"])
|
||||
cors_origins: list = Field(
|
||||
default_factory=lambda: [
|
||||
o.strip() for o in os.environ.get(
|
||||
"CORS_ORIGINS", "http://localhost:3000,http://localhost:8008"
|
||||
).split(",") if o.strip()
|
||||
]
|
||||
)
|
||||
cors_allow_credentials: bool = True
|
||||
cors_allow_methods: list = Field(default=["*"])
|
||||
cors_allow_headers: list = Field(default=["*"])
|
||||
|
||||
@@ -157,9 +157,8 @@ async def require_admin(
|
||||
if user.get("type") == "api_key":
|
||||
return user
|
||||
|
||||
# Vérifier le rôle dans le payload JWT
|
||||
payload = user.get("payload", {})
|
||||
role = payload.get("role", "viewer")
|
||||
# Vérifier le rôle directement dans le dict utilisateur (pas dans "payload")
|
||||
role = user.get("role", "viewer")
|
||||
|
||||
if role != "admin":
|
||||
raise HTTPException(
|
||||
|
||||
+10
-3
@@ -5,11 +5,13 @@ Ce module contient la fonction create_app() qui configure et retourne
|
||||
une instance FastAPI prête à l'emploi.
|
||||
"""
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import HTMLResponse, FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.responses import FileResponse
|
||||
from slowapi import Limiter, _rate_limit_exceeded_handler
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.database import init_db, async_session_maker
|
||||
@@ -40,6 +42,11 @@ def create_app() -> FastAPI:
|
||||
allow_headers=settings.cors_allow_headers,
|
||||
)
|
||||
|
||||
# Rate limiting
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
app.state.limiter = limiter
|
||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
|
||||
# Monter les fichiers statiques (main.js, etc.)
|
||||
app.mount("/static", StaticFiles(directory=settings.base_dir, html=False), name="static")
|
||||
|
||||
|
||||
+1248
-1249
File diff suppressed because it is too large
Load Diff
@@ -19,8 +19,11 @@ aiosqlite>=0.19.0
|
||||
pytest>=7.0.0
|
||||
pytest-asyncio>=0.21.0
|
||||
# Authentication
|
||||
python-jose[cryptography]>=3.3.0
|
||||
passlib[bcrypt]>=1.7.4
|
||||
PyJWT>=2.8.0
|
||||
bcrypt>=4.0.0
|
||||
reportlab>=4.0.0
|
||||
pillow>=10.0.0
|
||||
asyncssh>=2.14.0
|
||||
asyncssh>=2.14.0
|
||||
slowapi>=0.1.9
|
||||
cachetools>=5.3.0
|
||||
jinja2>=3.1.0
|
||||
+10
-1
@@ -5,8 +5,10 @@ Routes API pour l'authentification JWT.
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.dependencies import get_db, get_current_user, get_current_user_optional
|
||||
@@ -17,6 +19,7 @@ from app.schemas.auth import (
|
||||
from app.services import auth_service
|
||||
|
||||
router = APIRouter()
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
@@ -55,7 +58,9 @@ async def auth_status(
|
||||
|
||||
|
||||
@router.post("/setup")
|
||||
@limiter.limit("3/minute")
|
||||
async def setup_admin(
|
||||
request: Request,
|
||||
user_data: UserCreate,
|
||||
db_session: AsyncSession = Depends(get_db)
|
||||
):
|
||||
@@ -93,7 +98,9 @@ async def setup_admin(
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
@limiter.limit("5/minute")
|
||||
async def login_form(
|
||||
request: Request,
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
db_session: AsyncSession = Depends(get_db)
|
||||
):
|
||||
@@ -129,7 +136,9 @@ async def login_form(
|
||||
|
||||
|
||||
@router.post("/login/json", response_model=Token)
|
||||
@limiter.limit("5/minute")
|
||||
async def login_json(
|
||||
request: Request,
|
||||
credentials: LoginRequest,
|
||||
db_session: AsyncSession = Depends(get_db)
|
||||
):
|
||||
|
||||
+147
-1085
File diff suppressed because it is too large
Load Diff
+30
-14
@@ -7,10 +7,33 @@ from typing import Optional
|
||||
from pydantic import BaseModel, EmailStr, Field, field_validator
|
||||
|
||||
|
||||
def _validate_password_strength(password: str) -> str:
|
||||
"""Shared password strength validator.
|
||||
|
||||
Requirements:
|
||||
- Minimum 8 characters
|
||||
- At least 1 uppercase letter
|
||||
- At least 1 lowercase letter
|
||||
- At least 1 digit
|
||||
- At least 1 special character
|
||||
"""
|
||||
if len(password) < 8:
|
||||
raise ValueError("Le mot de passe doit contenir au moins 8 caractères")
|
||||
if not any(c.isupper() for c in password):
|
||||
raise ValueError("Le mot de passe doit contenir au moins une majuscule")
|
||||
if not any(c.islower() for c in password):
|
||||
raise ValueError("Le mot de passe doit contenir au moins une minuscule")
|
||||
if not any(c.isdigit() for c in password):
|
||||
raise ValueError("Le mot de passe doit contenir au moins un chiffre")
|
||||
if not any(c in "!@#$%^&*()_+-=[]{}|;:',.<>?/~`" for c in password):
|
||||
raise ValueError("Le mot de passe doit contenir au moins un caractère spécial")
|
||||
return password
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
"""Request schema for user login."""
|
||||
username: str = Field(..., min_length=3, max_length=50, description="Username")
|
||||
password: str = Field(..., min_length=6, description="Password")
|
||||
password: str = Field(..., min_length=1, description="Password")
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
@@ -39,15 +62,12 @@ class UserBase(BaseModel):
|
||||
|
||||
class UserCreate(UserBase):
|
||||
"""Schema for creating a new user."""
|
||||
password: str = Field(..., min_length=6, max_length=128, description="Password (min 6 chars)")
|
||||
password: str = Field(..., min_length=8, max_length=128, description="Password (min 8 chars, requires uppercase, lowercase, digit, special char)")
|
||||
|
||||
@field_validator('password')
|
||||
@classmethod
|
||||
def password_strength(cls, v: str) -> str:
|
||||
"""Validate password has minimum complexity."""
|
||||
if len(v) < 6:
|
||||
raise ValueError('Password must be at least 6 characters')
|
||||
return v
|
||||
return _validate_password_strength(v)
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
@@ -61,14 +81,12 @@ class UserUpdate(BaseModel):
|
||||
class PasswordChange(BaseModel):
|
||||
"""Schema for changing password."""
|
||||
current_password: str = Field(..., description="Current password")
|
||||
new_password: str = Field(..., min_length=6, max_length=128, description="New password")
|
||||
new_password: str = Field(..., min_length=8, max_length=128, description="New password")
|
||||
|
||||
@field_validator('new_password')
|
||||
@classmethod
|
||||
def password_strength(cls, v: str) -> str:
|
||||
if len(v) < 6:
|
||||
raise ValueError('Password must be at least 6 characters')
|
||||
return v
|
||||
return _validate_password_strength(v)
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
@@ -90,16 +108,14 @@ class UserOut(BaseModel):
|
||||
class UserSetup(BaseModel):
|
||||
"""Schema for initial admin setup (first user creation)."""
|
||||
username: str = Field(..., min_length=3, max_length=50)
|
||||
password: str = Field(..., min_length=6, max_length=128)
|
||||
password: str = Field(..., min_length=8, max_length=128)
|
||||
email: Optional[EmailStr] = None
|
||||
display_name: Optional[str] = None
|
||||
|
||||
@field_validator('password')
|
||||
@classmethod
|
||||
def password_strength(cls, v: str) -> str:
|
||||
if len(v) < 6:
|
||||
raise ValueError('Password must be at least 6 characters')
|
||||
return v
|
||||
return _validate_password_strength(v)
|
||||
|
||||
|
||||
class AuthStatus(BaseModel):
|
||||
|
||||
@@ -12,6 +12,7 @@ from time import perf_counter
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import yaml
|
||||
from cachetools import TTLCache
|
||||
|
||||
from app.core.config import settings
|
||||
from app.schemas.host_api import AnsibleInventoryHost
|
||||
@@ -28,27 +29,23 @@ class AnsibleService:
|
||||
self.ssh_key_path = ssh_key_path or settings.ssh_key_path
|
||||
self.ssh_user = ssh_user or settings.ssh_user
|
||||
|
||||
# Cache
|
||||
self._inventory_cache: Optional[Dict] = None
|
||||
self._inventory_cache_time: float = 0
|
||||
self._playbooks_cache: Optional[List[PlaybookInfo]] = None
|
||||
self._playbooks_cache_time: float = 0
|
||||
self._cache_ttl = settings.inventory_cache_ttl
|
||||
# Cache with automatic TTL eviction (replaces hand-rolled cache)
|
||||
cache_ttl = settings.inventory_cache_ttl
|
||||
self._inventory_cache: TTLCache = TTLCache(maxsize=16, ttl=cache_ttl)
|
||||
self._playbooks_cache: TTLCache = TTLCache(maxsize=16, ttl=cache_ttl)
|
||||
|
||||
def invalidate_cache(self):
|
||||
"""Invalide les caches."""
|
||||
self._inventory_cache = None
|
||||
self._playbooks_cache = None
|
||||
self._inventory_cache.clear()
|
||||
self._playbooks_cache.clear()
|
||||
|
||||
# ===== PLAYBOOKS =====
|
||||
|
||||
def get_playbooks(self) -> List[Dict[str, Any]]:
|
||||
"""Récupère la liste des playbooks disponibles."""
|
||||
import time
|
||||
current_time = time.time()
|
||||
|
||||
if self._playbooks_cache and (current_time - self._playbooks_cache_time) < self._cache_ttl:
|
||||
return self._playbooks_cache
|
||||
cache_key = "all"
|
||||
if cache_key in self._playbooks_cache:
|
||||
return self._playbooks_cache[cache_key]
|
||||
|
||||
playbooks = []
|
||||
|
||||
@@ -78,8 +75,7 @@ class AnsibleService:
|
||||
if pb:
|
||||
playbooks.append(pb)
|
||||
|
||||
self._playbooks_cache = playbooks
|
||||
self._playbooks_cache_time = current_time
|
||||
self._playbooks_cache[cache_key] = playbooks
|
||||
return playbooks
|
||||
|
||||
def _parse_playbook_file(self, file_path: Path, category: str, subcategory: str) -> Optional[Dict[str, Any]]:
|
||||
@@ -179,11 +175,9 @@ class AnsibleService:
|
||||
|
||||
def load_inventory(self) -> Dict:
|
||||
"""Charge l'inventaire Ansible depuis le fichier YAML."""
|
||||
import time
|
||||
current_time = time.time()
|
||||
|
||||
if self._inventory_cache and (current_time - self._inventory_cache_time) < self._cache_ttl:
|
||||
return self._inventory_cache
|
||||
cache_key = "inventory"
|
||||
if cache_key in self._inventory_cache:
|
||||
return self._inventory_cache[cache_key]
|
||||
|
||||
if not self.inventory_path.exists():
|
||||
return {}
|
||||
@@ -191,8 +185,7 @@ class AnsibleService:
|
||||
try:
|
||||
with open(self.inventory_path, 'r', encoding='utf-8') as f:
|
||||
inventory = yaml.safe_load(f) or {}
|
||||
self._inventory_cache = inventory
|
||||
self._inventory_cache_time = current_time
|
||||
self._inventory_cache[cache_key] = inventory
|
||||
return inventory
|
||||
except Exception:
|
||||
return {}
|
||||
@@ -204,8 +197,8 @@ class AnsibleService:
|
||||
with open(self.inventory_path, 'w', encoding='utf-8') as f:
|
||||
yaml.dump(inventory, f, default_flow_style=False, allow_unicode=True)
|
||||
|
||||
# Invalider le cache
|
||||
self._inventory_cache = None
|
||||
# Invalider le cache inventaire
|
||||
self._inventory_cache.clear()
|
||||
|
||||
def get_hosts_from_inventory(self, group_filter: str = None) -> List[AnsibleInventoryHost]:
|
||||
"""Récupère les hôtes depuis l'inventaire Ansible."""
|
||||
|
||||
@@ -11,15 +11,16 @@ from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
import bcrypt
|
||||
from jose import JWTError, jwt
|
||||
import jwt
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.user import User
|
||||
from app.schemas.auth import TokenData
|
||||
|
||||
# Configuration from environment variables
|
||||
SECRET_KEY = os.environ.get("JWT_SECRET_KEY", "homelab-secret-key-change-in-production")
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.environ.get("JWT_EXPIRE_MINUTES", "1440")) # 24 hours default
|
||||
# Configuration from centralized settings (single source of truth)
|
||||
SECRET_KEY = settings.jwt_secret_key
|
||||
ALGORITHM = settings.jwt_algorithm
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = settings.jwt_expire_minutes
|
||||
|
||||
|
||||
class AuthService:
|
||||
@@ -83,7 +84,7 @@ class AuthService:
|
||||
return None
|
||||
|
||||
return TokenData(username=username, user_id=user_id, role=role)
|
||||
except JWTError:
|
||||
except (jwt.PyJWTError, jwt.ExpiredSignatureError, jwt.DecodeError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ safe_title }}</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||
background: #0f0f1a;
|
||||
color: #e5e7eb;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.terminal-header {
|
||||
background: #1a1a2e;
|
||||
padding: 0.5rem 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid #374151;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.terminal-header .host-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.terminal-header .host-name { font-weight: 600; color: #fff; }
|
||||
.terminal-header .host-ip { color: #9ca3af; font-size: 0.875rem; }
|
||||
.terminal-header .status-badge {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.terminal-header .status-badge.online {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #4ade80;
|
||||
}
|
||||
.terminal-header .session-timer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #9ca3af;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.terminal-header .session-timer.warning { color: #fbbf24; }
|
||||
.terminal-header .session-timer.critical { color: #ef4444; }
|
||||
.terminal-header .actions { display: flex; gap: 0.5rem; }
|
||||
.terminal-header .btn {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.terminal-header .btn-secondary { background: #374151; color: #e5e7eb; }
|
||||
.terminal-header .btn-secondary:hover { background: #4b5563; }
|
||||
.terminal-header .btn-secondary.active { background: #4f46e5; color: #fff; }
|
||||
.terminal-header .btn-danger { background: #dc2626; color: #fff; }
|
||||
.terminal-header .btn-danger:hover { background: #b91c1c; }
|
||||
.terminal-container { flex: 1; position: relative; overflow: hidden; }
|
||||
.terminal-container iframe { width: 100%; height: 100%; border: none; background: #000; }
|
||||
.terminal-loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #0f0f1a;
|
||||
}
|
||||
.terminal-loading .spinner {
|
||||
width: 48px; height: 48px;
|
||||
border: 3px solid #374151;
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.terminal-loading .loading-text { margin-top: 1rem; color: #9ca3af; }
|
||||
.pwa-hint {
|
||||
background: #1e3a5f;
|
||||
color: #93c5fd;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.pwa-hint button {
|
||||
background: none; border: none; color: #60a5fa; cursor: pointer; font-size: 0.75rem;
|
||||
}
|
||||
body.embed #pwaHint { display: none !important; }
|
||||
body.embed .terminal-header { display: none !important; }
|
||||
.hidden { display: none !important; }
|
||||
/* History Panel */
|
||||
.terminal-history-panel {
|
||||
position: absolute; top: 0; left: 0; right: 0;
|
||||
max-height: 0; overflow: hidden;
|
||||
background: #1e1e2e; border-bottom: 1px solid #374151;
|
||||
display: flex; flex-direction: column; z-index: 50;
|
||||
box-shadow: 0 5px 25px rgba(0,0,0,0.5);
|
||||
opacity: 0;
|
||||
transition: max-height 0.3s ease, opacity 0.2s ease;
|
||||
}
|
||||
.terminal-history-panel.open { max-height: 350px; opacity: 1; }
|
||||
.terminal-history-header {
|
||||
padding: 0.75rem; background: #151520; border-bottom: 1px solid #374151; flex-shrink: 0;
|
||||
}
|
||||
.terminal-history-search {
|
||||
position: relative; display: flex; align-items: center;
|
||||
background: #0f0f1a; border: 1px solid #374151;
|
||||
border-radius: 0.375rem; padding: 0.5rem 0.75rem; transition: border-color 0.15s;
|
||||
}
|
||||
.terminal-history-search:focus-within { border-color: #7c3aed; }
|
||||
.terminal-history-search i { color: #6b7280; font-size: 0.875rem; margin-right: 0.5rem; }
|
||||
.terminal-history-search input {
|
||||
flex: 1; background: transparent; border: none; color: #e5e7eb;
|
||||
font-size: 0.875rem; outline: none;
|
||||
}
|
||||
.terminal-history-search input::placeholder { color: #6b7280; }
|
||||
.terminal-history-clear-search {
|
||||
background: none; border: none; color: #6b7280; cursor: pointer;
|
||||
padding: 0.25rem; font-size: 0.75rem; transition: color 0.15s;
|
||||
}
|
||||
.terminal-history-clear-search:hover { color: #e5e7eb; }
|
||||
.terminal-history-filters {
|
||||
display: flex; align-items: center; gap: 0.75rem; margin-top: 0.5rem;
|
||||
}
|
||||
.terminal-history-filter-select {
|
||||
background: #0f0f1a; border: 1px solid #374151; border-radius: 0.375rem;
|
||||
color: #e5e7eb; padding: 0.375rem 0.5rem; font-size: 0.75rem;
|
||||
cursor: pointer; outline: none;
|
||||
}
|
||||
.terminal-history-filter-select:hover,
|
||||
.terminal-history-filter-select:focus { border-color: #7c3aed; }
|
||||
.terminal-history-scope {
|
||||
display: flex; align-items: center; gap: 0.375rem;
|
||||
font-size: 0.75rem; color: #9ca3af; cursor: pointer;
|
||||
}
|
||||
.terminal-history-scope input {
|
||||
width: 0.875rem; height: 0.875rem; accent-color: #7c3aed; cursor: pointer;
|
||||
}
|
||||
.terminal-history-list {
|
||||
flex: 1; overflow-y: auto; padding: 0.5rem;
|
||||
scrollbar-width: thin; scrollbar-color: #374151 transparent;
|
||||
}
|
||||
.terminal-history-list::-webkit-scrollbar { width: 6px; }
|
||||
.terminal-history-list::-webkit-scrollbar-track { background: transparent; }
|
||||
.terminal-history-list::-webkit-scrollbar-thumb { background: #374151; border-radius: 3px; }
|
||||
.terminal-history-item {
|
||||
display: flex; align-items: center; padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.375rem; cursor: pointer; transition: background 0.15s;
|
||||
gap: 0.75rem; border: 1px solid transparent;
|
||||
}
|
||||
.terminal-history-item:hover { background: rgba(124, 58, 237, 0.1); }
|
||||
.terminal-history-item.selected {
|
||||
background: rgba(124, 58, 237, 0.2); border-color: rgba(124, 58, 237, 0.4);
|
||||
}
|
||||
.terminal-history-cmd { flex: 1; min-width: 0; }
|
||||
.terminal-history-cmd code {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||
font-size: 0.8125rem; color: #a5b4fc;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block;
|
||||
}
|
||||
.terminal-history-cmd code mark {
|
||||
background: rgba(251, 191, 36, 0.3); color: #fbbf24;
|
||||
padding: 0 0.125rem; border-radius: 2px;
|
||||
}
|
||||
.terminal-history-meta { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
|
||||
.terminal-history-time { font-size: 0.6875rem; color: #6b7280; }
|
||||
.terminal-history-count {
|
||||
font-size: 0.625rem; color: #7c3aed;
|
||||
background: rgba(124, 58, 237, 0.2); padding: 0.125rem 0.375rem;
|
||||
border-radius: 9999px; font-weight: 500;
|
||||
}
|
||||
.terminal-history-actions-inline {
|
||||
display: flex; gap: 0.25rem; opacity: 0; transition: opacity 0.15s;
|
||||
}
|
||||
.terminal-history-item:hover .terminal-history-actions-inline,
|
||||
.terminal-history-item.selected .terminal-history-actions-inline { opacity: 1; }
|
||||
.terminal-history-action {
|
||||
background: rgba(55, 65, 81, 0.5); border: none; color: #9ca3af;
|
||||
padding: 0.375rem; border-radius: 0.25rem; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 0.75rem; transition: background 0.15s, color 0.15s, transform 0.1s;
|
||||
}
|
||||
.terminal-history-action:hover {
|
||||
background: rgba(124, 58, 237, 0.3); color: #fff; transform: scale(1.1);
|
||||
}
|
||||
.terminal-history-action-execute:hover {
|
||||
background: rgba(34, 197, 94, 0.3); color: #4ade80;
|
||||
}
|
||||
.terminal-history-empty {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
justify-content: center; padding: 2rem; color: #6b7280; gap: 0.5rem; text-align: center;
|
||||
}
|
||||
.terminal-history-empty i { font-size: 2rem; opacity: 0.5; }
|
||||
.terminal-history-loading {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 2rem; color: #9ca3af; gap: 0.5rem;
|
||||
}
|
||||
.terminal-history-footer {
|
||||
padding: 0.5rem 1rem; background: #151520;
|
||||
border-top: 1px solid #374151; flex-shrink: 0;
|
||||
}
|
||||
.terminal-history-hint {
|
||||
font-size: 0.6875rem; color: #6b7280;
|
||||
display: flex; align-items: center; gap: 0.25rem; flex-wrap: wrap;
|
||||
}
|
||||
.terminal-history-hint kbd {
|
||||
background: #374151; color: #e5e7eb; padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem; font-family: inherit; font-size: 0.625rem; border: 1px solid #4b5563;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="{{ 'embed' if embed_mode else '' }}">
|
||||
<div class="pwa-hint" id="pwaHint">
|
||||
<span>💡 Pour une expérience sans barre d'outils : installez en PWA ou utilisez <code>chrome --app=URL</code></span>
|
||||
<button onclick="dismissPwaHint()">✕ Fermer</button>
|
||||
</div>
|
||||
|
||||
<div class="terminal-header">
|
||||
<div class="host-info">
|
||||
<span class="host-name">{{ safe_host_name }}</span>
|
||||
<span class="host-ip">{{ safe_host_ip }}</span>
|
||||
<span class="status-badge online">Connecté</span>
|
||||
</div>
|
||||
<div class="session-timer" id="sessionTimer">
|
||||
<i class="fas fa-clock"></i>
|
||||
<span id="timerDisplay">{{ timer_display }}</span>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-secondary" onclick="toggleHistory()" id="btnHistory" title="Historique (Ctrl+R)">
|
||||
<i class="fas fa-history"></i> Historique
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="copySSHCommand()" title="Copier la commande SSH">
|
||||
<i class="fas fa-copy"></i> SSH
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="reconnect()" title="Reconnecter">
|
||||
<i class="fas fa-redo"></i> Reconnecter
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="goToDashboard()" title="Retour au Dashboard">
|
||||
<i class="fas fa-arrow-left"></i> Dashboard
|
||||
</button>
|
||||
<button class="btn btn-danger" onclick="closeSession()" title="Fermer la session">
|
||||
<i class="fas fa-times"></i> Fermer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="terminal-container">
|
||||
<div class="terminal-loading" id="terminalLoading">
|
||||
<div style="text-align: center;">
|
||||
<div class="spinner"></div>
|
||||
<div class="loading-text">Connexion au terminal...</div>
|
||||
<div style="margin-top:0.75rem; font-size:0.85rem; color:#9ca3af;">
|
||||
session={{ session_id_short }}… · ttyd_port={{ ttyd_port }} · Debug: Ctrl+Shift+D
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<noscript>
|
||||
<div style="position:absolute; top:14px; right:14px; z-index:9999; background:rgba(239,68,68,0.18); border:1px solid rgba(239,68,68,0.6); color:#fff; padding:10px 12px; border-radius:10px; font-size:12px; max-width:520px;">
|
||||
JavaScript est désactivé: le terminal web ne peut pas se charger.
|
||||
</div>
|
||||
</noscript>
|
||||
{% if debug_panel_html %}
|
||||
{{ debug_panel_html | safe }}
|
||||
{% endif %}
|
||||
<iframe
|
||||
id="terminalFrame"
|
||||
src="about:blank"
|
||||
allow="clipboard-read; clipboard-write; clipboard-write-text"
|
||||
></iframe>
|
||||
<div class="terminal-history-panel" id="terminalHistoryPanel" style="display: none;">
|
||||
<div class="terminal-history-header">
|
||||
<div class="terminal-history-search">
|
||||
<i class="fas fa-search"></i>
|
||||
<input type="text"
|
||||
id="terminalHistorySearch"
|
||||
placeholder="Rechercher... (Ctrl+R)"
|
||||
oninput="searchHistory(this.value)"
|
||||
onkeydown="handleHistoryKeydown(event)"
|
||||
autocomplete="off">
|
||||
<button class="terminal-history-clear-search" onclick="clearHistorySearch()" title="Effacer">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="terminal-history-filters">
|
||||
<select id="terminalHistoryTimeFilter" onchange="setHistoryTimeFilter(this.value)" class="terminal-history-filter-select">
|
||||
<option value="all">Tout</option>
|
||||
<option value="today">Aujourd'hui</option>
|
||||
<option value="week">7 jours</option>
|
||||
<option value="month">30 jours</option>
|
||||
</select>
|
||||
<label class="terminal-history-scope">
|
||||
<input type="checkbox" id="terminalHistoryAllHosts" onchange="toggleHistoryScope()">
|
||||
<span>Tous hôtes</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="terminal-history-list" id="terminalHistoryList">
|
||||
<div class="terminal-history-loading">
|
||||
<i class="fas fa-spinner fa-spin"></i> Chargement...
|
||||
</div>
|
||||
</div>
|
||||
<div class="terminal-history-footer">
|
||||
<span class="terminal-history-hint">
|
||||
<kbd>↑</kbd><kbd>↓</kbd> naviguer · <kbd>Enter</kbd> insérer · <kbd>Esc</kbd> fermer
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ script_block | safe }}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,48 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ title }}</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
background: #1a1a2e;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
}
|
||||
.error {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
max-width: 520px;
|
||||
}
|
||||
.error h1 { color: #ef4444; }
|
||||
.error p { color: #e5e7eb; margin-bottom: 0.75rem; }
|
||||
.error a { color: #60a5fa; }
|
||||
.btn {
|
||||
display: inline-block;
|
||||
margin-top: 1rem;
|
||||
padding: 0.5rem 0.9rem;
|
||||
background: #7c3aed;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="error">
|
||||
<h1>{{ heading }}</h1>
|
||||
{% for msg in messages %}
|
||||
<p>{{ msg }}</p>
|
||||
{% endfor %}
|
||||
{% if show_back_link %}
|
||||
<a class="btn" href="/">Retour au Dashboard</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user