Add favorites feature with toggle filters, group management, color/icon pickers, dashboard widget, and star buttons across containers/docker sections with persistent storage and real-time UI updates
Some checks failed
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:
Bruno Charest 2025-12-23 14:56:31 -05:00
parent 46823eb42d
commit 661d005fc7
24 changed files with 2455 additions and 38 deletions

View File

@ -0,0 +1,107 @@
"""Add favorites tables (favorite_groups, favorite_containers)
Revision ID: 0017_add_favorites_tables
Revises: 0016
Create Date: 2025-12-22
"""
from __future__ import annotations
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision: str = '0017_add_favorites_tables'
down_revision: Union[str, None] = '0016'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
insp = inspect(bind)
def _table_exists(name: str) -> bool:
try:
return insp.has_table(name)
except Exception:
return name in insp.get_table_names()
def _index_exists(table: str, index: str) -> bool:
try:
return any(i.get('name') == index for i in insp.get_indexes(table))
except Exception:
return False
if not _table_exists('favorite_groups'):
op.create_table(
'favorite_groups',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('sort_order', sa.Integer(), nullable=False, server_default=sa.text('0')),
sa.Column('color', sa.String(length=20), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
)
if _table_exists('favorite_groups'):
if not _index_exists('favorite_groups', 'ix_favorite_groups_user_id'):
op.create_index('ix_favorite_groups_user_id', 'favorite_groups', ['user_id'])
if not _index_exists('favorite_groups', 'ix_favorite_groups_sort_order'):
op.create_index('ix_favorite_groups_sort_order', 'favorite_groups', ['user_id', 'sort_order'])
if not _index_exists('favorite_groups', 'uq_favorite_groups_user_name'):
op.create_index(
'uq_favorite_groups_user_name',
'favorite_groups',
['user_id', 'name'],
unique=True,
)
if not _table_exists('favorite_containers'):
op.create_table(
'favorite_containers',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('docker_container_id', sa.Integer(), nullable=False),
sa.Column('group_id', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['docker_container_id'], ['docker_containers.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['group_id'], ['favorite_groups.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id'),
)
if _table_exists('favorite_containers'):
if not _index_exists('favorite_containers', 'ix_favorite_containers_user_id'):
op.create_index('ix_favorite_containers_user_id', 'favorite_containers', ['user_id'])
if not _index_exists('favorite_containers', 'ix_favorite_containers_docker_container_id'):
op.create_index('ix_favorite_containers_docker_container_id', 'favorite_containers', ['docker_container_id'])
if not _index_exists('favorite_containers', 'ix_favorite_containers_group_id'):
op.create_index('ix_favorite_containers_group_id', 'favorite_containers', ['group_id'])
if not _index_exists('favorite_containers', 'uq_favorite_containers_user_docker_container'):
op.create_index(
'uq_favorite_containers_user_docker_container',
'favorite_containers',
['user_id', 'docker_container_id'],
unique=True,
)
def downgrade() -> None:
op.drop_index('uq_favorite_containers_user_docker_container', table_name='favorite_containers')
op.drop_index('ix_favorite_containers_group_id', table_name='favorite_containers')
op.drop_index('ix_favorite_containers_docker_container_id', table_name='favorite_containers')
op.drop_index('ix_favorite_containers_user_id', table_name='favorite_containers')
op.drop_table('favorite_containers')
op.drop_index('uq_favorite_groups_user_name', table_name='favorite_groups')
op.drop_index('ix_favorite_groups_sort_order', table_name='favorite_groups')
op.drop_index('ix_favorite_groups_user_id', table_name='favorite_groups')
op.drop_table('favorite_groups')

View File

@ -0,0 +1,45 @@
"""add icon_key to favorite_groups
Revision ID: 0018
Revises: 0017_add_favorites_tables
Create Date: 2025-12-22
"""
from __future__ import annotations
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
revision: str = '0018'
down_revision: Union[str, None] = '0017_add_favorites_tables'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
insp = inspect(bind)
if not insp.has_table('favorite_groups'):
return
columns = [col['name'] for col in insp.get_columns('favorite_groups')]
if 'icon_key' not in columns:
op.add_column('favorite_groups', sa.Column('icon_key', sa.String(length=100), nullable=True))
def downgrade() -> None:
bind = op.get_bind()
insp = inspect(bind)
if not insp.has_table('favorite_groups'):
return
columns = [col['name'] for col in insp.get_columns('favorite_groups')]
if 'icon_key' in columns:
op.drop_column('favorite_groups', 'icon_key')

View File

@ -11,6 +11,8 @@ const containersPage = {
selectedIds: new Set(),
currentContainer: null,
inspectData: null,
_initialized: false,
_initPromise: null,
// View settings
viewMode: 'comfortable', // 'comfortable', 'compact', 'grouped'
@ -25,13 +27,28 @@ const containersPage = {
health: ''
},
sortBy: 'name-asc',
favoritesOnly: false,
// ========== INITIALIZATION ==========
async init() {
this.setupEventListeners();
this.setupKeyboardShortcuts();
await this.loadData();
if (this._initPromise) return await this._initPromise;
this._initPromise = (async () => {
this.setupEventListeners();
this.setupKeyboardShortcuts();
if (window.favoritesManager) {
await window.favoritesManager.ensureInit();
}
await this.loadData();
this._initialized = true;
})();
return await this._initPromise;
},
async ensureInit() {
return await this.init();
},
setupEventListeners() {
@ -82,6 +99,26 @@ const containersPage = {
}
});
// Favorites-only toggle
const favOnlyBtn = document.getElementById('containers-filter-favorites');
if (favOnlyBtn) {
favOnlyBtn.addEventListener('click', async () => {
if (window.favoritesManager) {
await window.favoritesManager.ensureInit();
}
this.favoritesOnly = !this.favoritesOnly;
favOnlyBtn.classList.toggle('bg-purple-600', this.favoritesOnly);
favOnlyBtn.classList.toggle('bg-gray-700', !this.favoritesOnly);
favOnlyBtn.setAttribute('aria-pressed', this.favoritesOnly ? 'true' : 'false');
const icon = favOnlyBtn.querySelector('i');
if (icon) {
icon.classList.toggle('fas', this.favoritesOnly);
icon.classList.toggle('far', !this.favoritesOnly);
}
this.applyFilters();
});
}
// Per page select
const perPageEl = document.getElementById('containers-per-page');
if (perPageEl) {
@ -239,6 +276,11 @@ const containersPage = {
}
}
// Favorites-only
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);
@ -406,8 +448,11 @@ const containersPage = {
restarting: 'orange',
dead: 'red'
};
const stateColor = stateColors[c.state] || 'gray';
const isCompact = this.viewMode === 'compact';
const favKey = `${c.host_id}::${c.container_id}`;
const favIconClass = window.favoritesManager?.isFavorite(c.host_id, c.container_id) ? 'fas' : 'far';
const favTitle = window.favoritesManager?.isFavorite(c.host_id, c.container_id) ? 'Retirer des favoris' : 'Ajouter aux favoris';
const favColorClass = window.favoritesManager?.isFavorite(c.host_id, c.container_id) ? 'text-purple-400' : 'text-gray-400';
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">
@ -428,11 +473,14 @@ const containersPage = {
role="listitem"
tabindex="0"
data-container-id="${c.container_id}">
<span class="w-2 h-2 rounded-full bg-${stateColor}-500 flex-shrink-0"></span>
<span class="w-2 h-2 rounded-full bg-${stateColors[c.state]}-500 flex-shrink-0"></span>
<span class="font-medium truncate flex-1 min-w-0">${this.escapeHtml(c.name)}</span>
<span class="text-xs text-gray-500 truncate max-w-[120px]">${this.escapeHtml(c.host_name)}</span>
<span class="text-xs text-gray-500 truncate max-w-[150px]">${this.escapeHtml(c.image || '—')}</span>
<div class="flex items-center gap-1 flex-shrink-0">
<button data-fav-key="${favKey}" onclick="event.stopPropagation(); containersPage.toggleFavorite('${c.host_id}','${c.container_id}')" class="p-1.5 hover:bg-gray-700 rounded transition-colors ${favColorClass}" title="${favTitle}" aria-pressed="false">
<i class="${favIconClass} fa-star text-sm"></i>
</button>
${this.renderQuickActions(c)}
</div>
</div>
@ -448,7 +496,7 @@ const containersPage = {
<div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap mb-2">
<span class="w-2.5 h-2.5 rounded-full bg-${stateColor}-500"></span>
<span class="w-2.5 h-2.5 rounded-full bg-${stateColors[c.state]}-500"></span>
<span class="font-semibold">${this.escapeHtml(c.name)}</span>
${projectBadge}
${healthBadge}
@ -465,6 +513,9 @@ const containersPage = {
<div class="text-xs text-gray-500 mt-1">${c.status || c.state}</div>
</div>
<div class="flex items-center gap-1 flex-shrink-0">
<button data-fav-key="${favKey}" onclick="event.stopPropagation(); containersPage.toggleFavorite('${c.host_id}','${c.container_id}')" class="p-1.5 hover:bg-gray-700 rounded transition-colors ${favColorClass}" title="${favTitle}" aria-pressed="false">
<i class="${favIconClass} fa-star text-sm"></i>
</button>
${this.renderQuickActions(c)}
</div>
</div>
@ -472,6 +523,19 @@ const containersPage = {
`;
},
async toggleFavorite(hostId, containerId) {
if (!window.favoritesManager) return;
try {
await window.favoritesManager.ensureInit();
await window.favoritesManager.toggleFavorite(hostId, containerId);
this.showToast('Favoris mis à jour', 'success');
// Refresh current list rendering quickly
this.render();
} catch (e) {
this.showToast(`Erreur favoris: ${e.message}`, 'error');
}
},
renderGroupedView(containers) {
// Group by host
const groups = {};
@ -800,8 +864,15 @@ const containersPage = {
// ========== DRAWER ==========
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 => c.host_id === hostId && c.container_id === containerId
c => String(c.host_id) === wantedHostId && String(c.container_id) === wantedContainerId
);
if (!this.currentContainer) {

128
app/crud/favorites.py Normal file
View File

@ -0,0 +1,128 @@
from __future__ import annotations
from typing import Optional
from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.favorite_container import FavoriteContainer
from app.models.favorite_group import FavoriteGroup
class FavoriteGroupRepository:
def __init__(self, session: AsyncSession):
self.session = session
async def list_by_user(self, user_id: Optional[int]) -> list[FavoriteGroup]:
stmt = select(FavoriteGroup)
if user_id is None:
stmt = stmt.where(FavoriteGroup.user_id.is_(None))
else:
stmt = stmt.where(FavoriteGroup.user_id == user_id)
stmt = stmt.order_by(FavoriteGroup.sort_order.asc(), FavoriteGroup.name.asc())
result = await self.session.execute(stmt)
return list(result.scalars().all())
async def get_for_user(self, group_id: int, user_id: Optional[int]) -> Optional[FavoriteGroup]:
stmt = select(FavoriteGroup).where(FavoriteGroup.id == group_id)
if user_id is None:
stmt = stmt.where(FavoriteGroup.user_id.is_(None))
else:
stmt = stmt.where(FavoriteGroup.user_id == user_id)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def exists_name(self, name: str, user_id: Optional[int]) -> bool:
stmt = select(FavoriteGroup.id).where(FavoriteGroup.name == name)
if user_id is None:
stmt = stmt.where(FavoriteGroup.user_id.is_(None))
else:
stmt = stmt.where(FavoriteGroup.user_id == user_id)
result = await self.session.execute(stmt.limit(1))
return result.scalar_one_or_none() is not None
async def create(
self,
*,
user_id: Optional[int],
name: str,
sort_order: int = 0,
color: Optional[str] = None,
icon_key: Optional[str] = None,
) -> FavoriteGroup:
group = FavoriteGroup(user_id=user_id, name=name, sort_order=sort_order, color=color, icon_key=icon_key)
self.session.add(group)
await self.session.flush()
return group
async def update(
self,
group: FavoriteGroup,
*,
name: Optional[str] = None,
sort_order: Optional[int] = None,
color: Optional[str] = None,
icon_key: Optional[str] = None,
) -> FavoriteGroup:
if name is not None:
group.name = name
if sort_order is not None:
group.sort_order = sort_order
if color is not None:
group.color = color
if icon_key is not None:
group.icon_key = icon_key
await self.session.flush()
return group
async def delete(self, group: FavoriteGroup) -> None:
await self.session.delete(group)
await self.session.flush()
class FavoriteContainerRepository:
def __init__(self, session: AsyncSession):
self.session = session
async def list_by_user(self, user_id: Optional[int]) -> list[FavoriteContainer]:
stmt = select(FavoriteContainer)
if user_id is None:
stmt = stmt.where(FavoriteContainer.user_id.is_(None))
else:
stmt = stmt.where(FavoriteContainer.user_id == user_id)
stmt = stmt.order_by(FavoriteContainer.created_at.desc())
result = await self.session.execute(stmt)
return list(result.scalars().all())
async def get_for_user(self, favorite_id: int, user_id: Optional[int]) -> Optional[FavoriteContainer]:
stmt = select(FavoriteContainer).where(FavoriteContainer.id == favorite_id)
if user_id is None:
stmt = stmt.where(FavoriteContainer.user_id.is_(None))
else:
stmt = stmt.where(FavoriteContainer.user_id == user_id)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def get_by_docker_container_id(self, docker_container_db_id: int, user_id: Optional[int]) -> Optional[FavoriteContainer]:
stmt = select(FavoriteContainer).where(FavoriteContainer.docker_container_id == docker_container_db_id)
if user_id is None:
stmt = stmt.where(FavoriteContainer.user_id.is_(None))
else:
stmt = stmt.where(FavoriteContainer.user_id == user_id)
result = await self.session.execute(stmt.limit(1))
return result.scalar_one_or_none()
async def create(self, *, user_id: Optional[int], docker_container_db_id: int, group_id: Optional[int]) -> FavoriteContainer:
fav = FavoriteContainer(user_id=user_id, docker_container_id=docker_container_db_id, group_id=group_id)
self.session.add(fav)
await self.session.flush()
return fav
async def update_group(self, fav: FavoriteContainer, *, group_id: Optional[int]) -> FavoriteContainer:
fav.group_id = group_id
await self.session.flush()
return fav
async def delete(self, fav: FavoriteContainer) -> None:
await self.session.delete(fav)
await self.session.flush()

View File

@ -21,6 +21,9 @@ const dockerSection = {
await this.loadStats();
this.setupEventListeners();
this.setupWebSocket();
if (window.favoritesManager) {
await window.favoritesManager.ensureInit();
}
},
async ensureInit() {
@ -234,6 +237,21 @@ const dockerSection = {
`;
},
async toggleFavorite(hostId, containerId) {
if (!window.favoritesManager) return;
try {
await window.favoritesManager.ensureInit();
await window.favoritesManager.toggleFavorite(hostId, containerId);
// Refresh the current host list only
if (this.currentHostId) {
await this.loadContainers(this.currentHostId);
}
} catch (e) {
console.error('toggleFavorite error:', e);
this.showToast(`Erreur favoris: ${e.message}`, 'error');
}
},
filterHosts(query) {
const cards = document.querySelectorAll('#docker-hosts-grid > div[data-host-id]');
const lowerQuery = query.toLowerCase();
@ -357,6 +375,12 @@ const dockerSection = {
// Parse ports and create clickable links
const portLinks = this.parsePortLinks(c.ports, hostId);
const favKey = `${hostId}::${c.container_id}`;
const isFav = window.favoritesManager?.isFavorite(hostId, c.container_id);
const favIconClass = isFav ? 'fas' : 'far';
const favTitle = isFav ? 'Retirer des favoris' : 'Ajouter aux favoris';
const favColorClass = isFav ? 'text-purple-400' : 'text-gray-400';
return `
<div class="bg-gray-800/50 rounded-lg p-3 flex items-center justify-between gap-4">
<div class="flex-1 min-w-0">
@ -372,6 +396,10 @@ const dockerSection = {
</div>
<div class="flex items-center gap-1">
<button data-fav-key="${favKey}" onclick="event.stopPropagation(); dockerSection.toggleFavorite('${hostId}', '${c.container_id}')"
class="p-2 hover:bg-gray-700 rounded transition-colors ${favColorClass}" title="${favTitle}" aria-pressed="false">
<i class="${favIconClass} fa-star"></i>
</button>
${c.state !== 'running' ? `
<button onclick="dockerSection.startContainer('${hostId}', '${c.container_id}')"
class="p-2 hover:bg-gray-700 rounded transition-colors text-green-400" title="Démarrer">

225
app/favorites_manager.js Normal file
View File

@ -0,0 +1,225 @@
const favoritesManager = {
apiBase: window.location.origin,
_initialized: false,
_loadPromise: null,
groups: [],
favoritesByKey: new Map(),
favoritesById: new Map(),
listeners: new Set(),
getAuthHeaders() {
const token = localStorage.getItem('accessToken');
const apiKey = localStorage.getItem('apiKey');
const headers = {
'Content-Type': 'application/json'
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
} else if (apiKey) {
headers['X-API-Key'] = apiKey;
}
return headers;
},
async fetchAPI(endpoint, options = {}) {
const response = await fetch(`${this.apiBase}${endpoint}`, {
...options,
headers: {
...this.getAuthHeaders(),
...(options.headers || {})
}
});
if (!response.ok) {
if (response.status === 401) {
if (window.dashboard?.logout) {
window.dashboard.logout();
}
return null;
}
throw new Error(`API Error: ${response.status}`);
}
if (response.status === 204) return null;
return response.json();
},
setData(groups, favorites) {
this.groups = Array.isArray(groups) ? groups : [];
this.favoritesByKey.clear();
this.favoritesById.clear();
(favorites || []).forEach(f => {
const dc = f?.docker_container;
if (!dc?.host_id || !dc?.container_id) return;
const key = `${dc.host_id}::${dc.container_id}`;
this.favoritesByKey.set(key, f);
this.favoritesById.set(f.id, f);
});
this.refreshStarsInDom();
this._emitChange();
},
async load() {
const [groupsRes, favsRes] = await Promise.all([
this.fetchAPI('/api/favorites/groups').catch(() => ({ groups: [] })),
this.fetchAPI('/api/favorites/containers').catch(() => ({ containers: [] })),
]);
this.setData(groupsRes?.groups || [], favsRes?.containers || []);
},
async ensureInit() {
if (this._initialized) return;
if (this._loadPromise) {
await this._loadPromise;
return;
}
this._loadPromise = (async () => {
try {
await this.load();
} finally {
this._initialized = true;
}
})();
await this._loadPromise;
},
onChange(callback) {
this.listeners.add(callback);
return () => this.listeners.delete(callback);
},
_emitChange() {
for (const cb of this.listeners) {
try {
cb();
} catch (e) {
console.error('favoritesManager listener error:', e);
}
}
},
_key(hostId, containerId) {
return `${hostId}::${containerId}`;
},
isFavorite(hostId, containerId) {
return this.favoritesByKey.has(this._key(hostId, containerId));
},
getFavoriteId(hostId, containerId) {
const fav = this.favoritesByKey.get(this._key(hostId, containerId));
return fav ? fav.id : null;
},
async addOrUpdateFavorite(hostId, containerId, groupId = null) {
const created = await this.fetchAPI('/api/favorites/containers', {
method: 'POST',
body: JSON.stringify({
host_id: hostId,
container_id: containerId,
group_id: groupId
})
});
if (!created) return null;
const key = this._key(hostId, containerId);
this.favoritesByKey.set(key, created);
this.favoritesById.set(created.id, created);
this.refreshStarsInDom();
this._emitChange();
return created;
},
async moveFavoriteToGroup(hostId, containerId, groupId = null) {
return await this.addOrUpdateFavorite(hostId, containerId, groupId);
},
async listGroups() {
const res = await this.fetchAPI('/api/favorites/groups');
this.groups = res?.groups || [];
this._emitChange();
return this.groups;
},
async createGroup({ name, sort_order = 0, color = null, icon_key = null }) {
const created = await this.fetchAPI('/api/favorites/groups', {
method: 'POST',
body: JSON.stringify({ name, sort_order, color, icon_key })
});
await this.listGroups();
return created;
},
async updateGroup(groupId, { name = null, sort_order = null, color = null, icon_key = null }) {
const updated = await this.fetchAPI(`/api/favorites/groups/${groupId}`, {
method: 'PATCH',
body: JSON.stringify({ name, sort_order, color, icon_key })
});
await this.listGroups();
return updated;
},
async deleteGroup(groupId) {
await this.fetchAPI(`/api/favorites/groups/${groupId}`, { method: 'DELETE' });
await this.listGroups();
await this.load();
},
async removeFavoriteById(favoriteId) {
await this.fetchAPI(`/api/favorites/containers/${favoriteId}`, { method: 'DELETE' });
const existing = this.favoritesById.get(favoriteId);
if (existing?.docker_container?.host_id && existing?.docker_container?.container_id) {
const key = this._key(existing.docker_container.host_id, existing.docker_container.container_id);
this.favoritesByKey.delete(key);
}
this.favoritesById.delete(favoriteId);
this.refreshStarsInDom();
this._emitChange();
},
async toggleFavorite(hostId, containerId) {
const key = this._key(hostId, containerId);
const existing = this.favoritesByKey.get(key);
if (existing?.id) {
await this.removeFavoriteById(existing.id);
return { favorited: false };
}
await this.addOrUpdateFavorite(hostId, containerId, null);
return { favorited: true };
},
refreshStarsInDom() {
const buttons = document.querySelectorAll('[data-fav-key]');
buttons.forEach(btn => {
const key = btn.getAttribute('data-fav-key');
const isFav = this.favoritesByKey.has(key);
const icon = btn.querySelector('i');
if (icon) {
icon.classList.toggle('fas', isFav);
icon.classList.toggle('far', !isFav);
}
btn.classList.toggle('text-purple-400', isFav);
btn.classList.toggle('text-gray-400', !isFav);
btn.setAttribute('title', isFav ? 'Retirer des favoris' : 'Ajouter aux favoris');
btn.setAttribute('aria-pressed', isFav ? 'true' : 'false');
});
},
};
window.favoritesManager = favoritesManager;

483
app/icon_picker.js Normal file
View File

@ -0,0 +1,483 @@
const iconPicker = {
currentPack: 'all',
searchQuery: '',
selectedIcon: null,
onSelect: null,
loadedPacks: {},
currentLetter: '',
currentPage: 1,
itemsPerPage: 200,
packs: {
lucide: { name: 'Lucide', color: '#F56565', prefix: 'lucide' },
tabler: { name: 'Tabler', color: '#4299E1', prefix: 'tabler' },
'material-symbols': { name: 'Material', color: '#48BB78', prefix: 'material-symbols' },
mdi: { name: 'MDI', color: '#A855F7', prefix: 'mdi' },
'simple-icons': { name: 'Simple Icons', color: '#10B981', prefix: 'simple-icons' },
business: { name: 'Business', color: '#F59E0B', prefix: null }
},
icons: {
lucide: [],
tabler: [],
'material-symbols': [],
mdi: [],
'simple-icons': [],
// Business icons use explicit icon keys across multiple collections.
// This avoids silent failures when an app is not available in one collection.
business: [
'simple-icons:mysql',
'simple-icons:mariadb',
'simple-icons:postgresql',
'simple-icons:mongodb',
'simple-icons:redis',
'simple-icons:sqlite',
'simple-icons:docker',
'simple-icons:kubernetes',
'simple-icons:portainer',
'simple-icons:traefikproxy',
'simple-icons:nginx',
'simple-icons:apache',
'simple-icons:plex',
'simple-icons:jellyfin',
'simple-icons:emby',
'cbi:sonarr',
'cbi:radarr',
'cbi:prowlarr',
'simple-icons:nextcloud',
'simple-icons:owncloud',
'simple-icons:seafile',
'simple-icons:syncthing',
'simple-icons:grafana',
'simple-icons:prometheus',
'simple-icons:influxdb',
'simple-icons:elasticsearch',
'simple-icons:kibana',
'simple-icons:homeassistant',
'simple-icons:nodered',
'simple-icons:eclipsemosquitto',
'simple-icons:zigbee2mqtt',
'simple-icons:pihole',
'simple-icons:adguard',
'simple-icons:wireguard',
'simple-icons:openvpn',
'simple-icons:gitlab',
'simple-icons:gitea',
'simple-icons:github',
'simple-icons:bitbucket',
'simple-icons:jenkins',
'simple-icons:ansible',
'simple-icons:terraform',
'simple-icons:vault',
'simple-icons:wordpress',
'simple-icons:ghost',
'simple-icons:drupal',
'simple-icons:joomla',
'simple-icons:phpmyadmin',
'simple-icons:adminer',
'simple-icons:bitwarden',
'simple-icons:vaultwarden',
'simple-icons:keycloak',
'simple-icons:authelia',
'simple-icons:transmission',
'simple-icons:qbittorrent',
'simple-icons:deluge',
'simple-icons:frigate',
'simple-icons:immich',
'simple-icons:piwigo',
'simple-icons:calibreweb',
'simple-icons:truenas',
'simple-icons:openmediavault',
'simple-icons:cockpit',
'simple-icons:webmin',
'cbi:proxmox',
'simple-icons:vmware',
'simple-icons:unraid',
'simple-icons:opnsense',
'simple-icons:pfsense'
]
},
show(onSelectCallback, currentIconKey = null) {
this.onSelect = onSelectCallback;
this.selectedIcon = currentIconKey;
this.searchQuery = '';
this.currentPack = 'all';
const modal = document.getElementById('icon-picker-modal');
if (!modal) {
this.createModal();
}
this.render();
document.getElementById('icon-picker-modal').classList.remove('hidden');
document.getElementById('icon-picker-search').focus();
},
close() {
document.getElementById('icon-picker-modal').classList.add('hidden');
},
async ensurePackLoaded(pack) {
if (pack === 'business' || pack === 'all') return;
if (this.loadedPacks[pack]) return;
const packConfig = this.packs[pack];
if (!packConfig || !packConfig.prefix) return;
this.loadedPacks[pack] = 'loading';
try {
const url = `https://api.iconify.design/collection?prefix=${packConfig.prefix}`;
const res = await fetch(url, { headers: { 'Accept': 'application/json' } });
if (!res.ok) throw new Error(`Iconify API error: ${res.status}`);
const data = await res.json();
const set = new Set();
if (Array.isArray(data.uncategorized)) {
data.uncategorized.forEach((name) => {
if (typeof name === 'string' && name) set.add(name);
});
}
if (data && typeof data.categories === 'object' && data.categories) {
Object.values(data.categories).forEach((arr) => {
if (!Array.isArray(arr)) return;
arr.forEach((name) => {
if (typeof name === 'string' && name) set.add(name);
});
});
}
this.icons[pack] = Array.from(set).sort();
this.loadedPacks[pack] = true;
} catch (e) {
console.warn(`Failed to load ${pack} icons from Iconify API:`, e);
this.loadedPacks[pack] = false;
}
},
createModal() {
const modalHtml = `
<div id="icon-picker-modal" class="fixed inset-0 bg-black/80 backdrop-blur-sm z-[100] hidden flex items-center justify-center p-4">
<div class="bg-gray-900 border border-gray-700 rounded-xl shadow-2xl w-full max-w-6xl max-h-[90vh] flex flex-col">
<div class="flex items-center justify-between p-4 border-b border-gray-700">
<h3 class="text-lg font-semibold">Choisir une icône</h3>
<button onclick="iconPicker.close()" class="p-2 hover:bg-gray-800 rounded-lg transition-colors">
<i class="fas fa-times text-gray-400"></i>
</button>
</div>
<div class="p-4 border-b border-gray-700 space-y-3">
<input
id="icon-picker-search"
type="text"
placeholder="Rechercher une icône..."
oninput="iconPicker.handleSearch(event)"
class="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-purple-500"
/>
<div class="flex flex-wrap gap-2" id="icon-picker-filters">
<button onclick="iconPicker.setFilter('all')" data-filter="all" class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors bg-purple-600 text-white">
Tous
</button>
<button onclick="iconPicker.setFilter('lucide')" data-filter="lucide" class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors bg-gray-700 text-gray-300 hover:bg-gray-600">
Lucide
</button>
<button onclick="iconPicker.setFilter('tabler')" data-filter="tabler" class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors bg-gray-700 text-gray-300 hover:bg-gray-600">
Tabler
</button>
<button onclick="iconPicker.setFilter('material-symbols')" data-filter="material-symbols" class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors bg-gray-700 text-gray-300 hover:bg-gray-600">
Material
</button>
<button onclick="iconPicker.setFilter('mdi')" data-filter="mdi" class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors bg-gray-700 text-gray-300 hover:bg-gray-600">
MDI
</button>
<button onclick="iconPicker.setFilter('simple-icons')" data-filter="simple-icons" class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors bg-gray-700 text-gray-300 hover:bg-gray-600">
Simple Icons
</button>
<button onclick="iconPicker.setFilter('business')" data-filter="business" class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors bg-gray-700 text-gray-300 hover:bg-gray-600">
Business
</button>
</div>
<div class="flex flex-wrap gap-1 mt-2" id="icon-picker-alphabet">
<button onclick="iconPicker.setLetter('')" data-letter="" class="px-2 py-1 rounded text-xs font-medium transition-colors bg-purple-600 text-white">
#
</button>
</div>
</div>
<div id="icon-picker-grid" class="flex-1 overflow-y-auto p-4">
<div class="grid grid-cols-10 sm:grid-cols-12 md:grid-cols-14 lg:grid-cols-16 xl:grid-cols-18 gap-2"></div>
</div>
<div class="border-t border-gray-700 p-3">
<div id="icon-picker-pagination" class="flex items-center justify-center gap-2"></div>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
this.renderAlphabetFilter();
document.getElementById('icon-picker-modal').addEventListener('click', (e) => {
if (e.target.id === 'icon-picker-modal') {
this.close();
}
});
},
renderAlphabetFilter() {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
const container = document.getElementById('icon-picker-alphabet');
if (!container) return;
const buttons = alphabet.map(letter => `
<button onclick="iconPicker.setLetter('${letter}')" data-letter="${letter}" class="px-2 py-1 rounded text-xs font-medium transition-colors bg-gray-700 text-gray-300 hover:bg-gray-600">
${letter}
</button>
`).join('');
container.innerHTML = `
<button onclick="iconPicker.setLetter('')" data-letter="" class="px-2 py-1 rounded text-xs font-medium transition-colors bg-purple-600 text-white">
#
</button>
${buttons}
`;
},
handleSearch(event) {
this.searchQuery = event.target.value.toLowerCase();
this.render();
},
setFilter(pack) {
this.currentPack = pack;
this.currentPage = 1;
document.querySelectorAll('#icon-picker-filters button').forEach(btn => {
if (btn.dataset.filter === pack) {
btn.className = 'px-3 py-1.5 rounded-lg text-xs font-medium transition-colors bg-purple-600 text-white';
} else {
btn.className = 'px-3 py-1.5 rounded-lg text-xs font-medium transition-colors bg-gray-700 text-gray-300 hover:bg-gray-600';
}
});
this.render();
},
setLetter(letter) {
this.currentLetter = letter.toLowerCase();
this.currentPage = 1;
document.querySelectorAll('#icon-picker-alphabet button').forEach(btn => {
if (btn.dataset.letter === letter) {
btn.className = 'px-2 py-1 rounded text-xs font-medium transition-colors bg-purple-600 text-white';
} else {
btn.className = 'px-2 py-1 rounded text-xs font-medium transition-colors bg-gray-700 text-gray-300 hover:bg-gray-600';
}
});
this.render();
},
setPage(page) {
this.currentPage = page;
this.render();
},
getFilteredIcons() {
const result = [];
const packs = this.currentPack === 'all' ? Object.keys(this.icons) : [this.currentPack];
packs.forEach(pack => {
const icons = this.icons[pack] || [];
icons.forEach(icon => {
const isFullKey = typeof icon === 'string' && icon.includes(':');
const iconKey = isFullKey ? icon : `${pack}:${icon}`;
const iconName = isFullKey ? icon.split(':').slice(1).join(':') : icon;
const q = this.searchQuery;
const letter = this.currentLetter;
const matchesSearch = !q || iconName.includes(q) || iconKey.includes(q);
const matchesLetter = !letter || iconName.toLowerCase().startsWith(letter);
if (matchesSearch && matchesLetter) {
result.push({ pack, icon: iconName, key: iconKey });
}
});
});
return result;
},
getPaginatedIcons(allIcons) {
const start = (this.currentPage - 1) * this.itemsPerPage;
const end = start + this.itemsPerPage;
return {
icons: allIcons.slice(start, end),
total: allIcons.length,
totalPages: Math.ceil(allIcons.length / this.itemsPerPage)
};
},
renderPagination(totalPages, total) {
const container = document.getElementById('icon-picker-pagination');
if (!container || totalPages <= 1) {
if (container) container.innerHTML = '';
return;
}
const maxButtons = 7;
let startPage = Math.max(1, this.currentPage - Math.floor(maxButtons / 2));
let endPage = Math.min(totalPages, startPage + maxButtons - 1);
if (endPage - startPage < maxButtons - 1) {
startPage = Math.max(1, endPage - maxButtons + 1);
}
let html = `
<button onclick="iconPicker.setPage(${this.currentPage - 1})"
class="px-3 py-1 rounded text-sm transition-colors ${this.currentPage === 1 ? 'bg-gray-800 text-gray-600 cursor-not-allowed' : 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
${this.currentPage === 1 ? 'disabled' : ''}>
</button>
`;
if (startPage > 1) {
html += `
<button onclick="iconPicker.setPage(1)" class="px-3 py-1 rounded text-sm transition-colors bg-gray-700 text-gray-300 hover:bg-gray-600">
1
</button>
`;
if (startPage > 2) {
html += '<span class="text-gray-500 px-2">...</span>';
}
}
for (let i = startPage; i <= endPage; i++) {
html += `
<button onclick="iconPicker.setPage(${i})"
class="px-3 py-1 rounded text-sm transition-colors ${i === this.currentPage ? 'bg-purple-600 text-white' : 'bg-gray-700 text-gray-300 hover:bg-gray-600'}">
${i}
</button>
`;
}
if (endPage < totalPages) {
if (endPage < totalPages - 1) {
html += '<span class="text-gray-500 px-2">...</span>';
}
html += `
<button onclick="iconPicker.setPage(${totalPages})" class="px-3 py-1 rounded text-sm transition-colors bg-gray-700 text-gray-300 hover:bg-gray-600">
${totalPages}
</button>
`;
}
html += `
<button onclick="iconPicker.setPage(${this.currentPage + 1})"
class="px-3 py-1 rounded text-sm transition-colors ${this.currentPage === totalPages ? 'bg-gray-800 text-gray-600 cursor-not-allowed' : 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
${this.currentPage === totalPages ? 'disabled' : ''}>
</button>
<span class="text-gray-400 text-sm ml-3">
${total} icônes
</span>
`;
container.innerHTML = html;
},
async render() {
const packsToLoad = this.currentPack === 'all'
? Object.keys(this.packs).filter(p => p !== 'business')
: [this.currentPack];
await Promise.all(packsToLoad.map(pack => this.ensurePackLoaded(pack)));
const grid = document.querySelector('#icon-picker-grid .grid');
const allIcons = this.getFilteredIcons();
const { icons, total, totalPages } = this.getPaginatedIcons(allIcons);
if (icons.length === 0) {
grid.innerHTML = `
<div class="col-span-8 text-center py-8 text-gray-500">
<i class="fas fa-search text-2xl mb-2"></i>
<p class="text-sm">Aucune icône trouvée</p>
</div>
`;
this.renderPagination(0, 0);
return;
}
grid.innerHTML = icons.map(({ pack, icon, key }) => {
const isSelected = this.selectedIcon === key;
const displayName = icon;
const forcedColor = (this.currentPack === 'business' || pack === 'business') ? '#e5e7eb' : 'inherit';
return `
<button
onclick="iconPicker.selectIcon('${key}')"
class="aspect-square p-2 rounded-lg border transition-all hover:scale-110 hover:shadow-lg flex flex-col items-center justify-center gap-1 ${
isSelected
? 'bg-purple-600/20 border-purple-500'
: 'bg-gray-800 border-gray-700 hover:border-gray-500'
}"
title="${key}"
>
<span class="iconify text-2xl" data-icon="${key}" style="color:${forcedColor}"></span>
<span class="text-[10px] leading-tight text-gray-400 max-w-full truncate w-full text-center">${displayName}</span>
</button>
`;
}).join('');
// Handle missing icons (e.g., wrong key): Iconify will keep them blank.
// We proactively load icons and replace missing ones with a fallback.
if (window.Iconify && typeof window.Iconify.loadIcons === 'function') {
const keys = icons.map(i => i.key);
window.Iconify.loadIcons(keys, (loaded, missing, pending) => {
if (!Array.isArray(missing) || missing.length === 0) return;
missing.forEach((k) => {
const escaped = (window.CSS && typeof window.CSS.escape === 'function')
? window.CSS.escape(k)
: String(k).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const el = document.querySelector(`#icon-picker-grid .iconify[data-icon="${escaped}"]`);
if (el) {
el.setAttribute('data-icon', 'lucide:help-circle');
el.classList.add('text-gray-500');
}
});
});
}
this.renderPagination(totalPages, total);
},
selectIcon(iconKey) {
this.selectedIcon = iconKey;
if (this.onSelect) {
this.onSelect(iconKey);
}
this.close();
}
};
window.iconPicker = iconPicker;

View File

@ -4099,9 +4099,45 @@
</div>
</div>
</div>
<div class="lg:col-span-2 order-2 lg:order-1">
<div class="glass-card p-4 sm:p-6 fade-in">
<div class="flex items-center justify-between mb-4">
<h3 class="text-base sm:text-lg font-semibold flex items-center">
<i class="fas fa-star text-purple-400 mr-2"></i>
Containers favoris
</h3>
<div class="flex items-center gap-2">
<span id="dashboard-favorites-count" class="text-xs text-gray-500 hidden sm:inline"></span>
<button type="button" onclick="dashboard.expandAllFavorites()" class="px-2 py-1.5 bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors text-xs" title="Tout développer">
<i class="fas fa-chevron-down"></i>
</button>
<button type="button" onclick="dashboard.collapseAllFavorites()" class="px-2 py-1.5 bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors text-xs" title="Tout réduire">
<i class="fas fa-chevron-up"></i>
</button>
<button type="button" onclick="dashboard.showFavoriteGroupsModal()" class="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors text-xs">
<i class="fas fa-layer-group mr-1"></i>Groupes
</button>
</div>
</div>
<div class="mb-3">
<div class="relative">
<input type="text" id="dashboard-favorites-search" placeholder="Rechercher un container..." class="w-full px-3 py-2 pl-9 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-purple-500 transition-colors">
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 text-xs"></i>
<button id="dashboard-favorites-search-clear" class="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-gray-700 rounded transition-colors hidden" onclick="dashboard.clearFavoritesSearch()">
<i class="fas fa-times text-gray-500 text-xs"></i>
</button>
</div>
</div>
<div id="dashboard-favorites" class="space-y-3">
<p class="text-xs text-gray-500 text-center py-3">
<i class="fas fa-spinner fa-spin mr-1"></i>Chargement...
</p>
</div>
</div>
</div>
<!-- Schedules Widget -->
<div class="lg:col-span-2 order-2 lg:order-1">
<div class="lg:col-span-2 order-3 lg:order-2">
<div class="glass-card p-4 sm:p-6 h-full flex flex-col fade-in">
<div class="flex items-center justify-between mb-4">
<h3 class="text-base sm:text-lg font-semibold">
@ -4137,8 +4173,7 @@
</div>
</div>
<!-- Hosts Management (Moved down) -->
<div class="lg:col-span-2 order-3">
<div class="lg:col-span-2 order-4">
<div class="glass-card p-4 sm:p-6 fade-in">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4 sm:mb-6">
<h3 class="text-lg sm:text-xl font-semibold">Gestion des Hosts</h3>
@ -4894,6 +4929,9 @@
<!-- View Toggle -->
<div class="flex items-center gap-2">
<button id="containers-filter-favorites" class="p-2 bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors" title="Favoris uniquement" aria-pressed="false">
<i class="far fa-star"></i>
</button>
<button id="containers-view-comfortable" class="p-2 bg-purple-600 rounded-lg transition-colors" title="Vue confortable" aria-pressed="true">
<i class="fas fa-th-large"></i>
</button>
@ -5961,6 +5999,11 @@
}
</script>
<script src="https://code.iconify.design/3/3.1.1/iconify.min.js"></script>
<script src="/static/icon_picker.js"></script>
<script src="/static/favorites_manager.js"></script>
<!-- Docker Section JavaScript -->
<script src="/static/docker_section.js"></script>

View File

@ -67,6 +67,11 @@ class DashboardManager {
this.adhocHistory = [];
this.adhocCategories = [];
// Exécutions ad-hoc (logs de tâches markdown)
this.adhocWidgetLogs = [];
this.adhocWidgetTotalCount = 0;
this.adhocWidgetHasMore = false;
// Métriques des hôtes (collectées par les builtin playbooks)
this.hostMetrics = {}; // Map host_id -> HostMetricsSummary
this.builtinPlaybooks = [];
@ -118,6 +123,108 @@ class DashboardManager {
}
renderFavoriteGroupColorPicker(initialColor) {
const defaultColor = '#7c3aed';
const color = (initialColor && initialColor.startsWith('#') && initialColor.length >= 4) ? initialColor : '';
const pickerValue = color || defaultColor;
const palette = [
'#7c3aed', '#3b82f6', '#06b6d4', '#10b981', '#84cc16',
'#f59e0b', '#f97316', '#ef4444', '#ec4899', '#a855f7'
];
return `
<div class="space-y-2">
<div class="flex items-center gap-2">
<input id="fav-group-color-picker" type="color" value="${this.escapeHtml(pickerValue)}" oninput="dashboard.syncFavGroupColorFromPicker()" class="h-10 w-12 p-1 bg-gray-800 border border-gray-700 rounded-lg" />
<input id="fav-group-color-text" type="text" placeholder="${defaultColor}" maxlength="20" value="${this.escapeHtml(color)}" oninput="dashboard.syncFavGroupColorFromText()" class="flex-1 px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-purple-500" />
<button type="button" onclick="dashboard.clearFavGroupColor()" class="px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors text-xs">Aucune</button>
</div>
<div class="flex flex-wrap gap-2">
${palette.map(c => `
<button type="button" onclick="dashboard.setFavGroupColor('${c}')" class="w-7 h-7 rounded border border-gray-600 hover:border-gray-400 transition-colors" style="background:${c}" title="${c}"></button>
`).join('')}
</div>
</div>
`;
}
setFavGroupColor(color) {
const text = document.getElementById('fav-group-color-text');
const picker = document.getElementById('fav-group-color-picker');
if (text) text.value = color;
if (picker) picker.value = color;
}
clearFavGroupColor() {
const text = document.getElementById('fav-group-color-text');
const picker = document.getElementById('fav-group-color-picker');
if (text) text.value = '';
if (picker) picker.value = '#7c3aed';
}
syncFavGroupColorFromPicker() {
const text = document.getElementById('fav-group-color-text');
const picker = document.getElementById('fav-group-color-picker');
if (!text || !picker) return;
text.value = picker.value;
}
syncFavGroupColorFromText() {
const text = document.getElementById('fav-group-color-text');
const picker = document.getElementById('fav-group-color-picker');
if (!text || !picker) return;
const v = String(text.value || '').trim();
const isHex = /^#[0-9a-fA-F]{6}$/.test(v) || /^#[0-9a-fA-F]{3}$/.test(v);
if (isHex) picker.value = v;
}
renderFavoriteGroupIconPicker(initialIconKey) {
const iconKey = initialIconKey || '';
const color = document.getElementById('fav-group-color-text')?.value || '#7c3aed';
return `
<input type="hidden" id="fav-group-icon-key" value="${this.escapeHtml(iconKey)}" />
<div class="flex items-center gap-3">
<div id="fav-group-icon-preview" class="w-12 h-12 rounded-lg border border-gray-700 bg-gray-800 flex items-center justify-center">
${iconKey ? `<span class="iconify text-2xl" data-icon="${this.escapeHtml(iconKey)}" style="color:${this.escapeHtml(color)}"></span>` : '<span class="iconify text-2xl text-gray-600" data-icon="lucide:star"></span>'}
</div>
<button type="button" onclick="dashboard.openIconPicker()" class="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors text-sm">
<i class="fas fa-icons mr-2"></i>Choisir une icône
</button>
${iconKey ? `<button type="button" onclick="dashboard.clearFavGroupIcon()" class="px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors text-xs" title="Retirer l'icône"><i class="fas fa-times"></i></button>` : ''}
</div>
`;
}
openIconPicker() {
const currentIconKey = document.getElementById('fav-group-icon-key')?.value || '';
if (window.iconPicker) {
window.iconPicker.show((selectedIcon) => {
this.setFavGroupIcon(selectedIcon);
}, currentIconKey);
}
}
setFavGroupIcon(iconKey) {
const input = document.getElementById('fav-group-icon-key');
const preview = document.getElementById('fav-group-icon-preview');
const color = document.getElementById('fav-group-color-text')?.value || '#7c3aed';
if (input) input.value = iconKey;
if (preview) {
preview.innerHTML = `<span class="iconify text-2xl" data-icon="${this.escapeHtml(iconKey)}" style="color:${this.escapeHtml(color)}"></span>`;
}
}
clearFavGroupIcon() {
const input = document.getElementById('fav-group-icon-key');
const preview = document.getElementById('fav-group-icon-preview');
if (input) input.value = '';
if (preview) {
preview.innerHTML = '<span class="iconify text-2xl text-gray-600" data-icon="lucide:star"></span>';
}
}
async init() {
this.setupEventListeners();
this.setupScrollAnimations();
@ -137,18 +244,36 @@ class DashboardManager {
// Hide login screen if visible
this.hideLoginScreen();
if (window.favoritesManager) {
try {
await window.favoritesManager.ensureInit();
window.favoritesManager.onChange(() => {
this.renderFavoriteContainersWidget();
});
} catch (e) {
}
}
await this.loadAppConfig();
this.setDebugBadgeVisible(this.isDebugEnabled());
// Charger les données depuis l'API
await this.loadAllData();
this.renderFavoriteContainersWidget();
// Connecter WebSocket pour les mises à jour temps réel
this.connectWebSocket();
// Rafraîchir périodiquement les métriques
setInterval(() => this.loadMetrics(), 30000);
if (window.favoritesManager) {
setInterval(() => {
window.favoritesManager.load().catch(() => null);
}, 30000);
}
// Démarrer le polling des tâches en cours
this.startRunningTasksPolling();
}
@ -498,7 +623,7 @@ class DashboardManager {
async loadAllData() {
try {
// Charger en parallèle
const [hostsData, tasksData, logsData, metricsData, inventoryData, playbooksData, taskLogsData, taskStatsData, taskDatesData, adhocHistoryData, adhocCategoriesData, schedulesData, schedulesStatsData, hostMetricsData, builtinPlaybooksData, serverLogsData, alertsUnreadData] = await Promise.all([
const [hostsData, tasksData, logsData, metricsData, inventoryData, playbooksData, taskLogsData, taskStatsData, taskDatesData, adhocHistoryData, adhocCategoriesData, adhocTaskLogsData, schedulesData, schedulesStatsData, hostMetricsData, builtinPlaybooksData, serverLogsData, alertsUnreadData] = await Promise.all([
this.apiCall('/api/hosts').catch(() => []),
this.apiCall('/api/tasks').catch(() => []),
this.apiCall('/api/logs').catch(() => []),
@ -510,6 +635,7 @@ class DashboardManager {
this.apiCall('/api/tasks/logs/dates').catch(() => ({ years: {} })),
this.apiCall('/api/adhoc/history').catch(() => ({ commands: [], count: 0 })),
this.apiCall('/api/adhoc/categories').catch(() => ({ categories: [] })),
this.apiCall('/api/tasks/logs?source_type=adhoc&limit=500&offset=0').catch(() => ({ logs: [], total_count: 0, has_more: false })),
this.apiCall('/api/schedules').catch(() => ({ schedules: [], count: 0 })),
this.apiCall('/api/schedules/stats').catch(() => ({ stats: {}, upcoming: [] })),
this.apiCall('/api/metrics/all-hosts').catch(() => ({})),
@ -539,6 +665,11 @@ class DashboardManager {
this.adhocHistory = adhocHistoryData.commands || [];
this.adhocCategories = adhocCategoriesData.categories || [];
// Exécutions ad-hoc (logs)
this.adhocWidgetLogs = adhocTaskLogsData.logs || [];
this.adhocWidgetTotalCount = Number(adhocTaskLogsData.total_count || this.adhocWidgetLogs.length || 0);
this.adhocWidgetHasMore = Boolean(adhocTaskLogsData.has_more);
// Schedules (Planificateur)
this.schedules = schedulesData.schedules || [];
this.schedulesStats = schedulesStatsData.stats || { total: 0, active: 0, paused: 0, failures_24h: 0 };
@ -1102,6 +1233,13 @@ class DashboardManager {
});
}
const favoritesSearchInput = document.getElementById('dashboard-favorites-search');
if (favoritesSearchInput) {
favoritesSearchInput.addEventListener('input', () => {
this.renderFavoriteContainersWidget();
});
}
// Metrics collection configuration (Configuration page)
const configPage = document.getElementById('page-configuration');
if (configPage) {
@ -8885,20 +9023,21 @@ class DashboardManager {
if (!historyContainer) return;
// Calculer les stats
const total = this.adhocHistory.length;
const success = this.adhocHistory.filter(cmd => cmd.return_code === 0).length;
const failed = total - success;
const total = Array.isArray(this.adhocWidgetLogs) ? this.adhocWidgetLogs.length : 0;
const success = (this.adhocWidgetLogs || []).filter(l => (l.status || '').toLowerCase() === 'completed').length;
const failed = (this.adhocWidgetLogs || []).filter(l => (l.status || '').toLowerCase() === 'failed').length;
// Mettre à jour les stats
if (successEl) successEl.textContent = success;
if (failedEl) failedEl.textContent = failed;
if (totalEl) totalEl.textContent = total;
if (countEl) countEl.textContent = total > 0 ? `${total} commande${total > 1 ? 's' : ''}` : '';
if (countEl) countEl.textContent = this.adhocWidgetTotalCount > 0 ? `${this.adhocWidgetTotalCount} exécution${this.adhocWidgetTotalCount > 1 ? 's' : ''}` : '';
// Afficher les dernières exécutions
const displayedCommands = this.adhocHistory.slice(0, this.adhocWidgetLimit + this.adhocWidgetOffset);
const displayedLimit = this.adhocWidgetLimit + this.adhocWidgetOffset;
const displayedLogs = (this.adhocWidgetLogs || []).slice(0, displayedLimit);
if (displayedCommands.length === 0) {
if (displayedLogs.length === 0) {
historyContainer.innerHTML = `
<div class="text-center py-4">
<i class="fas fa-terminal text-gray-600 text-2xl mb-2"></i>
@ -8910,27 +9049,28 @@ class DashboardManager {
return;
}
historyContainer.innerHTML = displayedCommands.map(cmd => {
const isSuccess = cmd.return_code === 0;
historyContainer.innerHTML = displayedLogs.map(log => {
const status = (log.status || '').toLowerCase();
const isSuccess = status === 'completed';
const statusColor = isSuccess ? 'text-green-400' : 'text-red-400';
const statusBg = isSuccess ? 'bg-green-900/30 border-green-700/50' : 'bg-red-900/30 border-red-700/50';
const statusIcon = isSuccess ? 'fa-check-circle' : 'fa-times-circle';
const statusText = isSuccess ? 'Succès' : 'Échec';
// Formater la date
const date = cmd.executed_at ? new Date(cmd.executed_at) : new Date();
const date = log.created_at ? new Date(log.created_at) : new Date();
const timeAgo = this.formatTimeAgo(date);
// Extraire le nom de la commande (première partie avant |)
const cmdName = cmd.command ? cmd.command.split('|')[0].trim().split(' ')[0] : 'Commande';
const taskName = (log.task_name || '').trim();
const cmdName = taskName ? taskName.replace(/^ad-?hoc\s*:\s*/i, '').split(' ')[0] : 'Ad-hoc';
// Trouver la catégorie
const category = this.adhocCategories.find(c => c.name === cmd.category);
const catColor = category?.color || '#7c3aed';
const catColor = '#60a5fa';
return `
<div class="p-2.5 rounded-lg border ${statusBg} hover:bg-gray-800/50 cursor-pointer transition-all group"
onclick="dashboard.showAdhocExecutionDetail('${cmd.id}')">
onclick="dashboard.viewTaskLogContent('${log.id}')">
<div class="flex items-start justify-between gap-2">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
@ -8938,22 +9078,22 @@ class DashboardManager {
<span class="text-xs font-medium text-white truncate">${this.escapeHtml(cmdName)}</span>
<span class="text-[10px] px-1.5 py-0.5 rounded"
style="background-color: ${catColor}20; color: ${catColor}">
${this.escapeHtml(cmd.category || 'default')}
Ad-hoc
</span>
</div>
<div class="flex items-center gap-2 text-[10px] text-gray-500">
<span><i class="fas fa-crosshairs mr-1"></i>${this.escapeHtml(cmd.target || 'all')}</span>
<span><i class="fas fa-crosshairs mr-1"></i>${this.escapeHtml(log.target || 'all')}</span>
<span><i class="fas fa-clock mr-1"></i>${timeAgo}</span>
${cmd.duration ? `<span><i class="fas fa-stopwatch mr-1"></i>${cmd.duration.toFixed(1)}s</span>` : ''}
${log.duration_seconds ? `<span><i class="fas fa-stopwatch mr-1"></i>${Number(log.duration_seconds).toFixed(1)}s</span>` : ''}
</div>
</div>
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button onclick="event.stopPropagation(); dashboard.replayAdhocCommand('${cmd.id}')"
<button onclick="event.stopPropagation(); dashboard.showAdHocConsole()"
class="p-1 text-purple-400 hover:text-purple-300 hover:bg-purple-900/30 rounded"
title="Rejouer">
<i class="fas fa-redo text-[10px]"></i>
</button>
<button onclick="event.stopPropagation(); dashboard.showAdhocExecutionDetail('${cmd.id}')"
<button onclick="event.stopPropagation(); dashboard.viewTaskLogContent('${log.id}')"
class="p-1 text-blue-400 hover:text-blue-300 hover:bg-blue-900/30 rounded"
title="Détails">
<i class="fas fa-eye text-[10px]"></i>
@ -8966,21 +9106,616 @@ class DashboardManager {
// Afficher/masquer le bouton "Charger plus"
if (loadMoreBtn) {
if (displayedCommands.length < total) {
const moreToDisplayLocally = (this.adhocWidgetLogs || []).length > displayedLogs.length;
const moreOnServer = Boolean(this.adhocWidgetHasMore);
if (moreToDisplayLocally || moreOnServer) {
loadMoreBtn.classList.remove('hidden');
loadMoreBtn.innerHTML = `<i class="fas fa-chevron-down mr-1"></i>Charger plus (${total - displayedCommands.length} restantes)`;
const remaining = Math.max(0, this.adhocWidgetTotalCount - displayedLogs.length);
loadMoreBtn.innerHTML = `<i class="fas fa-chevron-down mr-1"></i>Charger plus (${remaining} restantes)`;
} else {
loadMoreBtn.classList.add('hidden');
}
}
}
renderFavoriteContainersWidget() {
const container = document.getElementById('dashboard-favorites');
const countEl = document.getElementById('dashboard-favorites-count');
const searchInput = document.getElementById('dashboard-favorites-search');
const clearBtn = document.getElementById('dashboard-favorites-search-clear');
if (!container) return;
const fm = window.favoritesManager;
if (!fm) {
container.innerHTML = `
<div class="text-center py-4">
<i class="fas fa-star text-gray-600 text-2xl mb-2"></i>
<p class="text-xs text-gray-500">Favoris indisponibles</p>
</div>
`;
return;
}
let favorites = Array.from(fm.favoritesById.values());
const searchTerm = searchInput ? searchInput.value.toLowerCase().trim() : '';
if (searchTerm) {
favorites = favorites.filter(f => {
const dc = f.docker_container;
if (!dc) return false;
return (dc.name || '').toLowerCase().includes(searchTerm) ||
(dc.host_name || '').toLowerCase().includes(searchTerm) ||
(dc.image || '').toLowerCase().includes(searchTerm);
});
}
if (clearBtn) {
clearBtn.classList.toggle('hidden', !searchTerm);
}
if (countEl) {
countEl.textContent = favorites.length > 0 ? `${favorites.length} favori${favorites.length > 1 ? 's' : ''}` : '';
}
if (favorites.length === 0) {
container.innerHTML = `
<div class="text-center py-4">
<i class="far fa-star text-gray-600 text-2xl mb-2"></i>
<p class="text-xs text-gray-500">Aucun container favori</p>
<p class="text-[10px] text-gray-600 mt-1">Ajoute des favoris depuis la page Containers</p>
</div>
`;
return;
}
const groups = Array.isArray(fm.groups) ? fm.groups.slice() : [];
groups.sort((a, b) => {
const ao = Number(a.sort_order || 0);
const bo = Number(b.sort_order || 0);
if (ao !== bo) return ao - bo;
return String(a.name || '').localeCompare(String(b.name || ''), 'fr');
});
const groupsById = new Map(groups.map(g => [g.id, g]));
const grouped = new Map();
const uncategorizedKey = 'uncategorized';
favorites.forEach(f => {
const gid = f.group_id;
const key = gid ? String(gid) : uncategorizedKey;
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(f);
});
const orderedGroupKeys = [];
groups.forEach(g => {
const key = String(g.id);
if (grouped.has(key)) orderedGroupKeys.push(key);
});
if (grouped.has(uncategorizedKey)) orderedGroupKeys.push(uncategorizedKey);
const stateColor = (state) => {
const s = String(state || '').toLowerCase();
if (s === 'running') return 'green';
if (s === 'paused') return 'yellow';
if (s === 'restarting') return 'orange';
if (s === 'created') return 'blue';
if (s === 'exited' || s === 'dead') return 'red';
return 'gray';
};
const portLinks = (ports, hostIp) => {
if (!ports || !hostIp) return '';
const portStr = ports.raw || (typeof ports === 'string' ? ports : '');
if (!portStr) return '';
const portRegex = /(?:([\d.]+):)?(\d+)->\d+\/tcp/g;
const links = [];
const seen = 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 (seen.has(hostPort)) continue;
seen.add(hostPort);
const protocol = ['443', '8443', '9443'].includes(hostPort) ? 'https' : 'http';
const url = `${protocol}://${hostIp}:${hostPort}`;
links.push(`
<a href="${url}" target="_blank" rel="noopener noreferrer"
class="px-2 py-0.5 rounded text-xs bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 transition-colors"
title="Ouvrir ${url}">
<i class="fas fa-external-link-alt mr-1"></i>${hostPort}
</a>
`);
}
return links.slice(0, 4).join('');
};
const groupTitle = (key) => {
if (key === uncategorizedKey) return { name: 'Non classé', color: null, icon_key: null };
const g = groupsById.get(Number(key));
return { name: g?.name || 'Groupe', color: g?.color || null, icon_key: g?.icon_key || null };
};
const groupHtml = orderedGroupKeys.map(key => {
const title = groupTitle(key);
const items = grouped.get(key) || [];
const iconColor = title.color || '#7c3aed';
const iconKey = title.icon_key || 'lucide:star';
const headerPill = `<span class="iconify" data-icon="${this.escapeHtml(iconKey)}" style="color:${this.escapeHtml(iconColor)}"></span>`;
return `
<details class="bg-gray-800/30 rounded-lg border border-gray-700/60 overflow-hidden" open>
<summary class="cursor-pointer select-none px-3 py-2 flex items-center justify-between hover:bg-gray-800/50 transition-colors">
<div class="flex items-center gap-2 min-w-0">
${headerPill}
<span class="font-medium text-sm truncate">${this.escapeHtml(title.name)}</span>
<span class="text-xs text-gray-500">${items.length}</span>
</div>
<i class="fas fa-chevron-down text-xs text-gray-500"></i>
</summary>
<div class="divide-y divide-gray-700/50">
${items.map(f => {
const dc = f.docker_container;
if (!dc) return '';
const hostStatus = (dc.host_docker_status || '').toLowerCase();
const hostOffline = hostStatus && hostStatus !== 'online';
const dotColor = stateColor(dc.state);
const isRunning = String(dc.state || '').toLowerCase() === 'running';
const favId = f.id;
const disabledClass = hostOffline ? 'opacity-50 cursor-not-allowed' : '';
const btnDisabled = hostOffline ? 'disabled' : '';
const healthBadge = dc.health && dc.health !== 'none'
? `<span class="px-2 py-0.5 rounded text-xs bg-${dc.health === 'healthy' ? 'green' : 'red'}-500/20 text-${dc.health === 'healthy' ? 'green' : 'red'}-400">${this.escapeHtml(dc.health)}</span>`
: '';
return `
<div class="px-3 py-2 flex items-center justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 flex-wrap">
<span class="w-2 h-2 rounded-full bg-${dotColor}-500"></span>
<span class="font-medium text-sm truncate cursor-pointer hover:text-purple-400 transition-colors" onclick="dashboard.openContainerDrawerFromFavorites('${this.escapeHtml(dc.host_id)}','${this.escapeHtml(dc.container_id)}')" title="Voir les détails">${this.escapeHtml(dc.name)}</span>
${healthBadge}
${portLinks(dc.ports, dc.host_ip)}
</div>
<div class="text-xs text-gray-500 truncate">
<i class="fas fa-server mr-1 text-purple-400"></i>${this.escapeHtml(dc.host_name)}
<span class="ml-2">${this.escapeHtml(dc.status || dc.state || '')}</span>
</div>
</div>
<div class="flex items-center gap-1 flex-shrink-0">
${!isRunning ? `
<button ${btnDisabled} class="p-1.5 hover:bg-gray-700 rounded transition-colors text-green-400 ${disabledClass}" title="Démarrer"
onclick="dashboard.favoriteContainerAction('${this.escapeHtml(dc.host_id)}','${this.escapeHtml(dc.container_id)}','start')">
<i class="fas fa-play text-sm"></i>
</button>
` : `
<button ${btnDisabled} class="p-1.5 hover:bg-gray-700 rounded transition-colors text-red-400 ${disabledClass}" title="Arrêter"
onclick="dashboard.favoriteContainerAction('${this.escapeHtml(dc.host_id)}','${this.escapeHtml(dc.container_id)}','stop')">
<i class="fas fa-stop text-sm"></i>
</button>
`}
<button ${btnDisabled} class="p-1.5 hover:bg-gray-700 rounded transition-colors text-yellow-400 ${disabledClass}" title="Redémarrer"
onclick="dashboard.favoriteContainerAction('${this.escapeHtml(dc.host_id)}','${this.escapeHtml(dc.container_id)}','restart')">
<i class="fas fa-redo text-sm"></i>
</button>
<button class="p-1.5 hover:bg-gray-700 rounded transition-colors text-gray-400" title="Déplacer"
onclick="dashboard.showMoveFavoriteModal('${this.escapeHtml(dc.host_id)}','${this.escapeHtml(dc.container_id)}')">
<i class="fas fa-folder-open text-sm"></i>
</button>
<button class="p-1.5 hover:bg-gray-700 rounded transition-colors text-purple-400" title="Retirer des favoris"
onclick="dashboard.removeFavoriteContainer(${favId})">
<i class="fas fa-star text-sm"></i>
</button>
</div>
</div>
`;
}).join('')}
</div>
</details>
`;
}).join('');
container.innerHTML = groupHtml;
}
async favoriteContainerAction(hostId, containerId, action) {
try {
this.showNotification(`${action}...`, 'info');
const result = await this.apiCall(`/api/docker/containers/${encodeURIComponent(hostId)}/${encodeURIComponent(containerId)}/${encodeURIComponent(action)}`, {
method: 'POST'
});
if (result && result.success) {
this.showNotification(`Action ${action} OK`, 'success');
} else {
this.showNotification(`Erreur: ${result?.error || result?.message || 'Échec'}`, 'error');
}
} catch (e) {
this.showNotification(`Erreur: ${e.message}`, 'error');
} finally {
if (window.favoritesManager) {
window.favoritesManager.load().catch(() => null);
}
}
}
async removeFavoriteContainer(favoriteId) {
if (!window.favoritesManager) return;
try {
await window.favoritesManager.ensureInit();
await window.favoritesManager.removeFavoriteById(Number(favoriteId));
this.showNotification('Favori retiré', 'success');
} catch (e) {
this.showNotification(`Erreur favoris: ${e.message}`, 'error');
}
}
async showMoveFavoriteModal(hostId, containerId) {
const fm = window.favoritesManager;
if (!fm) return;
try {
await fm.ensureInit();
await fm.listGroups();
const groups = fm.groups || [];
const options = [
`<option value="">Non classé</option>`,
...groups.map(g => `<option value="${g.id}">${this.escapeHtml(g.name)}</option>`)
].join('');
this.showModal('Déplacer le favori', `
<form onsubmit="dashboard.moveFavoriteToGroup(event, '${this.escapeHtml(hostId)}', '${this.escapeHtml(containerId)}')" class="space-y-4">
<div>
<label class="block text-sm text-gray-400 mb-2">Groupe</label>
<select id="favorite-move-group" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-purple-500">
${options}
</select>
</div>
<div class="flex justify-end gap-3 pt-2 border-t border-gray-700">
<button type="button" onclick="dashboard.closeModal()" class="px-4 py-2 bg-gray-600 rounded-lg hover:bg-gray-500 transition-colors text-sm">
Annuler
</button>
<button type="submit" class="px-4 py-2 bg-purple-600 rounded-lg hover:bg-purple-500 transition-colors text-sm">
Déplacer
</button>
</div>
</form>
`);
} catch (e) {
this.showNotification(`Erreur: ${e.message}`, 'error');
}
}
async moveFavoriteToGroup(event, hostId, containerId) {
event.preventDefault();
const fm = window.favoritesManager;
if (!fm) return;
const select = document.getElementById('favorite-move-group');
const raw = select ? select.value : '';
const groupId = raw ? Number(raw) : null;
try {
await fm.ensureInit();
await fm.moveFavoriteToGroup(hostId, containerId, groupId);
this.closeModal();
this.showNotification('Favori déplacé', 'success');
} catch (e) {
this.showNotification(`Erreur: ${e.message}`, 'error');
}
}
async showFavoriteGroupsModal() {
const fm = window.favoritesManager;
if (!fm) return;
try {
await fm.ensureInit();
await fm.listGroups();
const groups = (fm.groups || []).slice().sort((a, b) => {
const ao = Number(a.sort_order || 0);
const bo = Number(b.sort_order || 0);
if (ao !== bo) return ao - bo;
return String(a.name || '').localeCompare(String(b.name || ''), 'fr');
});
const rows = groups.length === 0
? `<p class="text-sm text-gray-500 text-center py-4">Aucun groupe</p>`
: groups.map(g => `
<div class="flex items-center justify-between gap-3 p-3 bg-gray-800/40 rounded-lg border border-gray-700/60">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="iconify" data-icon="${this.escapeHtml(g.icon_key || 'lucide:star')}" style="color:${this.escapeHtml(g.color || '#7c3aed')}"></span>
<span class="font-medium text-sm truncate">${this.escapeHtml(g.name)}</span>
</div>
<div class="text-xs text-gray-500">Ordre: ${Number(g.sort_order || 0)}</div>
</div>
<div class="flex items-center gap-1 flex-shrink-0">
<button class="p-2 hover:bg-gray-700 rounded transition-colors text-blue-400" title="Modifier"
onclick="dashboard.showEditFavoriteGroupModal(${g.id})">
<i class="fas fa-pen text-xs"></i>
</button>
<button class="p-2 hover:bg-gray-700 rounded transition-colors text-red-400" title="Supprimer"
onclick="dashboard.confirmDeleteFavoriteGroup(${g.id})">
<i class="fas fa-trash text-xs"></i>
</button>
</div>
</div>
`).join('');
this.showModal('Groupes de favoris', `
<div class="space-y-4">
<div class="flex items-center justify-between">
<p class="text-sm text-gray-400">${groups.length} groupe(s)</p>
<button onclick="dashboard.showAddFavoriteGroupModal()" class="px-3 py-2 bg-purple-600 rounded-lg hover:bg-purple-500 transition-colors text-sm">
<i class="fas fa-plus mr-2"></i>Ajouter
</button>
</div>
<div class="space-y-2">
${rows}
</div>
<div class="flex justify-end pt-4 border-t border-gray-700">
<button onclick="dashboard.closeModal()" class="px-6 py-2 border border-gray-600 rounded-lg text-gray-300 hover:border-gray-500 transition-colors">
Fermer
</button>
</div>
</div>
`);
} catch (e) {
this.showNotification(`Erreur: ${e.message}`, 'error');
}
}
showAddFavoriteGroupModal() {
const fm = window.favoritesManager;
if (!fm) return;
this.showModal('Ajouter un groupe de favoris', `
<form onsubmit="dashboard.createFavoriteGroup(event)" class="space-y-4">
<div>
<label class="block text-sm text-gray-400 mb-2">Nom</label>
<input id="fav-group-name" type="text" required maxlength="100" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-purple-500" />
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="block text-sm text-gray-400 mb-2">Ordre</label>
<input id="fav-group-order" type="number" min="0" value="0" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-purple-500" />
</div>
<div>
<label class="block text-sm text-gray-400 mb-2">Couleur</label>
${this.renderFavoriteGroupColorPicker('')}
</div>
</div>
<div>
<label class="block text-sm text-gray-400 mb-2">Icône</label>
${this.renderFavoriteGroupIconPicker('')}
</div>
<div class="flex justify-end gap-3 pt-2 border-t border-gray-700">
<button type="button" onclick="dashboard.closeModal()" class="px-4 py-2 bg-gray-600 rounded-lg hover:bg-gray-500 transition-colors text-sm">
Annuler
</button>
<button type="submit" class="px-4 py-2 bg-purple-600 rounded-lg hover:bg-purple-500 transition-colors text-sm">
Créer
</button>
</div>
</form>
`);
}
async createFavoriteGroup(event) {
event.preventDefault();
const fm = window.favoritesManager;
if (!fm) return;
const name = document.getElementById('fav-group-name')?.value?.trim() || '';
const sortOrder = Number(document.getElementById('fav-group-order')?.value || 0);
const color = document.getElementById('fav-group-color-text')?.value?.trim() || null;
const iconKey = document.getElementById('fav-group-icon-key')?.value?.trim() || null;
try {
await fm.createGroup({ name, sort_order: sortOrder, color, icon_key: iconKey });
this.closeModal();
this.showNotification('Groupe créé', 'success');
this.renderFavoriteContainersWidget();
this.showFavoriteGroupsModal();
} catch (e) {
this.showNotification(`Erreur: ${e.message}`, 'error');
}
}
async showEditFavoriteGroupModal(groupId) {
const fm = window.favoritesManager;
if (!fm) return;
await fm.ensureInit();
await fm.listGroups();
const g = (fm.groups || []).find(x => Number(x.id) === Number(groupId));
if (!g) {
this.showNotification('Groupe introuvable', 'error');
return;
}
this.showModal('Modifier le groupe', `
<form onsubmit="dashboard.updateFavoriteGroup(event, ${Number(groupId)})" class="space-y-4">
<div>
<label class="block text-sm text-gray-400 mb-2">Nom</label>
<input id="fav-group-name" type="text" required maxlength="100" value="${this.escapeHtml(g.name)}" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-purple-500" />
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="block text-sm text-gray-400 mb-2">Ordre</label>
<input id="fav-group-order" type="number" min="0" value="${Number(g.sort_order || 0)}" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-purple-500" />
</div>
<div>
<label class="block text-sm text-gray-400 mb-2">Couleur</label>
${this.renderFavoriteGroupColorPicker(this.escapeHtml(g.color || ''))}
</div>
</div>
<div>
<label class="block text-sm text-gray-400 mb-2">Icône</label>
${this.renderFavoriteGroupIconPicker(this.escapeHtml(g.icon_key || ''))}
</div>
<div class="flex justify-end gap-3 pt-2 border-t border-gray-700">
<button type="button" onclick="dashboard.closeModal()" class="px-4 py-2 bg-gray-600 rounded-lg hover:bg-gray-500 transition-colors text-sm">
Annuler
</button>
<button type="submit" class="px-4 py-2 bg-blue-600 rounded-lg hover:bg-blue-500 transition-colors text-sm">
Enregistrer
</button>
</div>
</form>
`);
}
async updateFavoriteGroup(event, groupId) {
event.preventDefault();
const fm = window.favoritesManager;
if (!fm) return;
const name = document.getElementById('fav-group-name')?.value?.trim() || '';
const sortOrder = Number(document.getElementById('fav-group-order')?.value || 0);
const color = document.getElementById('fav-group-color-text')?.value?.trim() || null;
const iconKey = document.getElementById('fav-group-icon-key')?.value?.trim() || null;
try {
await fm.updateGroup(Number(groupId), { name, sort_order: sortOrder, color, icon_key: iconKey });
this.closeModal();
this.showNotification('Groupe mis à jour', 'success');
this.renderFavoriteContainersWidget();
this.showFavoriteGroupsModal();
} catch (e) {
this.showNotification(`Erreur: ${e.message}`, 'error');
}
}
async confirmDeleteFavoriteGroup(groupId) {
const fm = window.favoritesManager;
if (!fm) return;
await fm.ensureInit();
await fm.listGroups();
const g = (fm.groups || []).find(x => Number(x.id) === Number(groupId));
if (!g) {
this.showNotification('Groupe introuvable', 'error');
return;
}
this.showModal('Confirmer la suppression', `
<div class="space-y-4">
<div class="p-4 bg-red-900/30 border border-red-600 rounded-lg">
<div class="flex items-start space-x-3">
<i class="fas fa-exclamation-triangle text-red-400 text-xl mt-0.5"></i>
<div>
<h4 class="font-semibold text-red-400">Supprimer le groupe</h4>
<p class="text-sm text-gray-300 mt-1">${this.escapeHtml(g.name)}</p>
<p class="text-xs text-gray-400 mt-2">Les favoris seront déplacés en "Non classé".</p>
</div>
</div>
</div>
<div class="flex space-x-3">
<button onclick="dashboard.deleteFavoriteGroup(${Number(groupId)})" class="flex-1 px-4 py-3 bg-red-600 rounded-lg hover:bg-red-500 transition-colors font-medium">
<i class="fas fa-trash mr-2"></i>Supprimer
</button>
<button onclick="dashboard.closeModal()" class="px-6 py-3 border border-gray-600 rounded-lg text-gray-300 hover:border-gray-500 transition-colors">
Annuler
</button>
</div>
</div>
`);
}
async deleteFavoriteGroup(groupId) {
const fm = window.favoritesManager;
if (!fm) return;
try {
await fm.deleteGroup(Number(groupId));
this.closeModal();
this.showNotification('Groupe supprimé', 'success');
this.renderFavoriteContainersWidget();
this.showFavoriteGroupsModal();
} catch (e) {
this.showNotification(`Erreur: ${e.message}`, 'error');
}
}
/**
* Expand all favorite groups
*/
expandAllFavorites() {
const details = document.querySelectorAll('#dashboard-favorites details');
details.forEach(detail => detail.open = true);
}
/**
* Collapse all favorite groups
*/
collapseAllFavorites() {
const details = document.querySelectorAll('#dashboard-favorites details');
details.forEach(detail => detail.open = false);
}
/**
* Clear favorites search
*/
clearFavoritesSearch() {
const searchInput = document.getElementById('dashboard-favorites-search');
if (searchInput) {
searchInput.value = '';
this.renderFavoriteContainersWidget();
}
}
/**
* Open container drawer from favorites widget
*/
async openContainerDrawerFromFavorites(hostId, containerId) {
const wantedHostId = String(hostId);
const wantedContainerId = String(containerId);
const tryOpen = async () => {
if (!window.containersPage || typeof window.containersPage.openDrawer !== 'function') return false;
if (typeof window.containersPage.ensureInit === 'function') {
await window.containersPage.ensureInit();
}
await window.containersPage.openDrawer(wantedHostId, wantedContainerId);
return true;
};
try {
if (await tryOpen()) return;
this.showNotification('Chargement du panneau...', 'info');
if (typeof navigateTo === 'function') {
navigateTo('docker-containers');
}
const maxAttempts = 20;
for (let i = 0; i < maxAttempts; i++) {
await new Promise(resolve => setTimeout(resolve, 150));
if (await tryOpen()) return;
}
} catch (e) {
this.showNotification(`Erreur drawer: ${e.message}`, 'error');
}
}
/**
* Charger plus d'historique dans le widget
*/
loadMoreAdhocHistory() {
this.adhocWidgetOffset += 5;
this.renderAdhocWidget();
async loadMoreAdhocHistory() {
try {
// D'abord augmenter le nombre d'éléments affichés
this.adhocWidgetOffset += 5;
// Si on a déjà assez de logs chargés pour l'affichage, pas besoin de re-fetch.
const desiredCount = this.adhocWidgetLimit + this.adhocWidgetOffset;
const loadedCount = (this.adhocWidgetLogs || []).length;
if (loadedCount < desiredCount && this.adhocWidgetHasMore) {
const nextOffset = loadedCount;
const nextData = await this.apiCall(`/api/tasks/logs?source_type=adhoc&limit=200&offset=${nextOffset}`);
const nextLogs = nextData.logs || [];
this.adhocWidgetLogs = [...(this.adhocWidgetLogs || []), ...nextLogs];
this.adhocWidgetTotalCount = Number(nextData.total_count || this.adhocWidgetTotalCount || this.adhocWidgetLogs.length || 0);
this.adhocWidgetHasMore = Boolean(nextData.has_more);
}
this.renderAdhocWidget();
} catch (error) {
console.error('Erreur chargement exécutions ad-hoc:', error);
this.showNotification('Erreur chargement historique Ad-Hoc', 'error');
}
}
/**

View File

@ -13,6 +13,8 @@ from .docker_container import DockerContainer
from .docker_image import DockerImage
from .docker_volume import DockerVolume
from .docker_alert import DockerAlert
from .favorite_group import FavoriteGroup
from .favorite_container import FavoriteContainer
from .terminal_session import TerminalSession
from .terminal_command_log import TerminalCommandLog
@ -37,6 +39,8 @@ __all__ = [
"DockerImage",
"DockerVolume",
"DockerAlert",
"FavoriteGroup",
"FavoriteContainer",
"TerminalSession",
"TerminalCommandLog",
]

View File

@ -125,6 +125,8 @@ async def init_db() -> None:
docker_volume,
docker_alert,
playbook_lint,
favorite_group,
favorite_container,
) # noqa: F401
async with engine.begin() as conn:

View File

@ -0,0 +1,31 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from sqlalchemy import DateTime, ForeignKey, Integer
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
from .database import Base
class FavoriteContainer(Base):
__tablename__ = "favorite_containers"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True)
docker_container_id: Mapped[int] = mapped_column(
Integer, ForeignKey("docker_containers.id", ondelete="CASCADE"), nullable=False
)
group_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("favorite_groups.id", ondelete="SET NULL"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
__table_args__ = (
{"sqlite_autoincrement": True},
)

View File

@ -0,0 +1,27 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from sqlalchemy import DateTime, ForeignKey, Integer, String, text
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
from .database import Base
class FavoriteGroup(Base):
__tablename__ = "favorite_groups"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True)
name: Mapped[str] = mapped_column(String(100), nullable=False)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
color: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
icon_key: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
__table_args__ = (
{"sqlite_autoincrement": True},
)

View File

@ -28,6 +28,7 @@ from app.routes.docker import router as docker_router
from app.routes.lint import router as lint_router
from app.routes.terminal import router as terminal_router
from app.routes.config import router as config_router
from app.routes.favorites import router as favorites_router
# Router principal qui agrège tous les sous-routers
api_router = APIRouter()
@ -53,6 +54,7 @@ api_router.include_router(alerts_router, prefix="/alerts", tags=["Alerts"])
api_router.include_router(docker_router, prefix="/docker", tags=["Docker"])
api_router.include_router(lint_router, prefix="/playbooks", tags=["Lint"])
api_router.include_router(terminal_router, prefix="/terminal", tags=["Terminal"])
api_router.include_router(favorites_router, prefix="/favorites", tags=["Favorites"])
api_router.include_router(config_router, tags=["Config"])
__all__ = [

225
app/routes/favorites.py Normal file
View File

@ -0,0 +1,225 @@
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.dependencies import get_current_user, get_db
from app.crud.docker_container import DockerContainerRepository
from app.crud.favorites import FavoriteContainerRepository, FavoriteGroupRepository
from app.crud.host import HostRepository
from app.schemas.favorites import (
FavoriteContainerCreate,
FavoriteContainerOut,
FavoriteContainersListResponse,
FavoriteDockerContainerOut,
FavoriteGroupCreate,
FavoriteGroupOut,
FavoriteGroupsListResponse,
FavoriteGroupUpdate,
)
router = APIRouter()
def _resolve_user_id(user: dict) -> Optional[int]:
# For API-key auth, we keep favorites in a shared (user_id NULL) namespace.
if user.get("type") == "api_key":
return None
uid = user.get("user_id")
if uid is None:
return None
try:
return int(uid)
except Exception:
return None
async def _favorite_to_out(db: AsyncSession, favorite, docker_container, host) -> FavoriteContainerOut:
dc = FavoriteDockerContainerOut(
host_id=docker_container.host_id,
host_name=host.name if host else "Unknown",
host_ip=host.ip_address if host else "Unknown",
host_docker_status=host.docker_status if host else None,
container_id=docker_container.container_id,
name=docker_container.name,
image=docker_container.image,
state=docker_container.state,
status=docker_container.status,
health=docker_container.health,
ports=docker_container.ports,
compose_project=docker_container.compose_project,
)
return FavoriteContainerOut(
id=favorite.id,
group_id=favorite.group_id,
created_at=favorite.created_at,
docker_container=dc,
)
@router.get("/groups", response_model=FavoriteGroupsListResponse)
async def list_favorite_groups(
user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db),
):
user_id = _resolve_user_id(user)
repo = FavoriteGroupRepository(db_session)
groups = await repo.list_by_user(user_id)
return FavoriteGroupsListResponse(groups=[FavoriteGroupOut.model_validate(g) for g in groups])
@router.post("/groups", response_model=FavoriteGroupOut, status_code=status.HTTP_201_CREATED)
async def create_favorite_group(
payload: FavoriteGroupCreate,
user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db),
):
user_id = _resolve_user_id(user)
repo = FavoriteGroupRepository(db_session)
if await repo.exists_name(payload.name, user_id):
raise HTTPException(status_code=400, detail="Un groupe avec ce nom existe déjà")
group = await repo.create(
user_id=user_id,
name=payload.name,
sort_order=payload.sort_order,
color=payload.color,
icon_key=payload.icon_key,
)
await db_session.commit()
await db_session.refresh(group)
return FavoriteGroupOut.model_validate(group)
@router.patch("/groups/{group_id}", response_model=FavoriteGroupOut)
async def update_favorite_group(
group_id: int,
payload: FavoriteGroupUpdate,
user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db),
):
user_id = _resolve_user_id(user)
repo = FavoriteGroupRepository(db_session)
group = await repo.get_for_user(group_id, user_id)
if not group:
raise HTTPException(status_code=404, detail="Groupe introuvable")
if payload.name and payload.name != group.name:
if await repo.exists_name(payload.name, user_id):
raise HTTPException(status_code=400, detail="Un groupe avec ce nom existe déjà")
group = await repo.update(
group,
name=payload.name,
sort_order=payload.sort_order,
color=payload.color,
icon_key=payload.icon_key,
)
await db_session.commit()
await db_session.refresh(group)
return FavoriteGroupOut.model_validate(group)
@router.delete("/groups/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_favorite_group(
group_id: int,
user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db),
):
user_id = _resolve_user_id(user)
repo = FavoriteGroupRepository(db_session)
group = await repo.get_for_user(group_id, user_id)
if not group:
raise HTTPException(status_code=404, detail="Groupe introuvable")
await repo.delete(group)
await db_session.commit()
@router.get("/containers", response_model=FavoriteContainersListResponse)
async def list_favorite_containers(
user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db),
):
user_id = _resolve_user_id(user)
fav_repo = FavoriteContainerRepository(db_session)
container_repo = DockerContainerRepository(db_session)
host_repo = HostRepository(db_session)
favorites = await fav_repo.list_by_user(user_id)
out: list[FavoriteContainerOut] = []
for fav in favorites:
docker_container = await container_repo.get(fav.docker_container_id)
if not docker_container:
# Should be cleaned by FK cascade, but keep the API robust.
continue
host = await host_repo.get(docker_container.host_id)
out.append(await _favorite_to_out(db_session, fav, docker_container, host))
return FavoriteContainersListResponse(containers=out)
@router.post("/containers", response_model=FavoriteContainerOut, status_code=status.HTTP_201_CREATED)
async def add_favorite_container(
payload: FavoriteContainerCreate,
user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db),
):
user_id = _resolve_user_id(user)
container_repo = DockerContainerRepository(db_session)
fav_repo = FavoriteContainerRepository(db_session)
group_repo = FavoriteGroupRepository(db_session)
docker_container = await container_repo.get_by_container_id(payload.host_id, payload.container_id)
if not docker_container:
raise HTTPException(status_code=404, detail="Container introuvable")
if payload.group_id is not None:
group = await group_repo.get_for_user(payload.group_id, user_id)
if not group:
raise HTTPException(status_code=404, detail="Groupe introuvable")
existing = await fav_repo.get_by_docker_container_id(docker_container.id, user_id)
if existing:
# If already exists, treat as idempotent and optionally move group.
if existing.group_id != payload.group_id:
await fav_repo.update_group(existing, group_id=payload.group_id)
await db_session.commit()
await db_session.refresh(existing)
host_repo = HostRepository(db_session)
host = await host_repo.get(docker_container.host_id)
return await _favorite_to_out(db_session, existing, docker_container, host)
fav = await fav_repo.create(user_id=user_id, docker_container_db_id=docker_container.id, group_id=payload.group_id)
await db_session.commit()
await db_session.refresh(fav)
host_repo = HostRepository(db_session)
host = await host_repo.get(docker_container.host_id)
return await _favorite_to_out(db_session, fav, docker_container, host)
@router.delete("/containers/{favorite_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_favorite_container(
favorite_id: int,
user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db),
):
user_id = _resolve_user_id(user)
fav_repo = FavoriteContainerRepository(db_session)
fav = await fav_repo.get_for_user(favorite_id, user_id)
if not fav:
raise HTTPException(status_code=404, detail="Favori introuvable")
await fav_repo.delete(fav)
await db_session.commit()

69
app/schemas/favorites.py Normal file
View File

@ -0,0 +1,69 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Optional
from pydantic import BaseModel, ConfigDict, Field
class FavoriteGroupCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
sort_order: int = Field(0, ge=0)
color: Optional[str] = Field(None, max_length=20)
icon_key: Optional[str] = Field(None, max_length=100)
class FavoriteGroupUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
sort_order: Optional[int] = Field(None, ge=0)
color: Optional[str] = Field(None, max_length=20)
icon_key: Optional[str] = Field(None, max_length=100)
class FavoriteGroupOut(BaseModel):
id: int
name: str
sort_order: int
color: Optional[str] = None
icon_key: Optional[str] = None
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class FavoriteDockerContainerOut(BaseModel):
host_id: str
host_name: str
host_ip: str
host_docker_status: Optional[str] = None
container_id: str
name: str
image: Optional[str] = None
state: str
status: Optional[str] = None
health: Optional[str] = None
ports: Optional[dict[str, Any]] = None
compose_project: Optional[str] = None
class FavoriteContainerCreate(BaseModel):
host_id: str
container_id: str
group_id: Optional[int] = None
class FavoriteContainerOut(BaseModel):
id: int
group_id: Optional[int] = None
created_at: datetime
docker_container: FavoriteDockerContainerOut
class FavoriteContainersListResponse(BaseModel):
containers: list[FavoriteContainerOut]
class FavoriteGroupsListResponse(BaseModel):
groups: list[FavoriteGroupOut]

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,53 @@
# ✅ Ad-hoc: docker version
## Informations
| Propriété | Valeur |
|-----------|--------|
| **ID** | `adhoc_2ccf82c42b38` |
| **Nom** | Ad-hoc: docker version |
| **Cible** | `jump.point.home` |
| **Statut** | completed |
| **Type** | Ad-hoc |
| **Progression** | 100% |
| **Début** | 2025-12-23T18:45:58.498930+00:00 |
| **Fin** | 2025-12-23T18:46:03.291427+00:00 |
| **Durée** | 4.79s |
## Sortie
```
jump.point.home | CHANGED | rc=0 >>
Client: Docker Engine - Community
Version: 29.1.3
API version: 1.52
Go version: go1.25.5
Git commit: f52814d
Built: Fri Dec 12 14:49:42 2025
OS/Arch: linux/amd64
Context: default
Server: Docker Engine - Community
Engine:
Version: 29.1.3
API version: 1.52 (minimum version 1.44)
Go version: go1.25.5
Git commit: fbf3ed2
Built: Fri Dec 12 14:49:42 2025
OS/Arch: linux/amd64
Experimental: false
containerd:
Version: v2.2.1
GitCommit: dea7da592f5d1d2b7755e3a161be07f43fad8f75
runc:
Version: 1.3.4
GitCommit: v1.3.4-0-gd6d73eb8
docker-init:
Version: 0.19.0
GitCommit: de40ad0
```
---
*Généré automatiquement par Homelab Automation Dashboard*
*Date: 2025-12-23T18:46:03.308411+00:00*

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,90 @@
import pytest
@pytest.mark.asyncio
async def test_favorite_groups_crud(client, host_factory, db_session):
# Create group
resp = await client.post(
"/api/favorites/groups",
json={"name": "Production", "sort_order": 1, "color": "purple"},
)
assert resp.status_code == 201
group = resp.json()
assert group["name"] == "Production"
# List groups
resp = await client.get("/api/favorites/groups")
assert resp.status_code == 200
data = resp.json()
assert any(g["name"] == "Production" for g in data["groups"])
# Update group
resp = await client.patch(
f"/api/favorites/groups/{group['id']}",
json={"name": "Prod", "sort_order": 2},
)
assert resp.status_code == 200
updated = resp.json()
assert updated["name"] == "Prod"
assert updated["sort_order"] == 2
# Delete group
resp = await client.delete(f"/api/favorites/groups/{group['id']}")
assert resp.status_code == 204
@pytest.mark.asyncio
async def test_favorite_containers_add_list_delete(client, host_factory, db_session):
# Create a host + docker container in DB
host = await host_factory.create(db_session, id="host-0001", name="docker1", ip_address="10.0.0.10")
from app.crud.docker_container import DockerContainerRepository
repo = DockerContainerRepository(db_session)
c = await repo.upsert(
host_id=host.id,
container_id="abcdef123456",
name="portainer",
image="portainer/portainer-ce:latest",
state="running",
status="Up 1 hour",
health=None,
ports={"raw": "0.0.0.0:9000->9000/tcp"},
labels=None,
compose_project=None,
)
await db_session.commit()
# Create favorite
resp = await client.post(
"/api/favorites/containers",
json={"host_id": host.id, "container_id": c.container_id, "group_id": None},
)
assert resp.status_code == 201
fav = resp.json()
assert fav["docker_container"]["name"] == "portainer"
fav_id = fav["id"]
# List favorites
resp = await client.get("/api/favorites/containers")
assert resp.status_code == 200
data = resp.json()
assert any(x["id"] == fav_id for x in data["containers"])
# Idempotent add should return 201? We return 201 always, but route returns 201; if exists we return 200? current impl returns 201 for POST but returns existing directly.
resp2 = await client.post(
"/api/favorites/containers",
json={"host_id": host.id, "container_id": c.container_id, "group_id": None},
)
assert resp2.status_code in (200, 201)
fav2 = resp2.json()
assert fav2["id"] == fav_id
# Delete favorite
resp = await client.delete(f"/api/favorites/containers/{fav_id}")
assert resp.status_code == 204
# List favorites -> empty
resp = await client.get("/api/favorites/containers")
assert resp.status_code == 200
assert resp.json()["containers"] == []

View File

@ -638,3 +638,52 @@ describe('DashboardCore - Constructor', () => {
expect(core.apiBase).toBeDefined();
});
});
// ============================================================================
// TESTS: Dashboard UI helpers (main.js) - Ad-Hoc widget regression
// ============================================================================
describe('main.js - Ad-Hoc widget regression', () => {
it('computes counters from task log status and shows load more when more items exist locally', async () => {
// Minimal DOM for widget
document.body.innerHTML = `
<div id="adhoc-widget-history"></div>
<button id="adhoc-widget-load-more" class="hidden"></button>
<div id="adhoc-widget-success"></div>
<div id="adhoc-widget-failed"></div>
<div id="adhoc-widget-total"></div>
<div id="adhoc-widget-count"></div>
`;
// Import side-effects are heavy; instead, instantiate the real DashboardManager by requiring main.js.
// main.js defines DashboardManager on the global scope.
await import('../../app/main.js');
// Create instance and stub required methods used by widget
const dash = new window.DashboardManager();
dash.escapeHtml = (s) => String(s ?? '');
dash.formatTimeAgo = () => 'À l\'instant';
dash.viewTaskLogContent = vi.fn();
dash.showAdHocConsole = vi.fn();
// Emulate loaded adhoc task logs
dash.adhocWidgetLimit = 2;
dash.adhocWidgetOffset = 0;
dash.adhocWidgetLogs = [
{ id: 'l1', status: 'completed', task_name: 'Ad-hoc: docker version', target: 'h1', created_at: new Date().toISOString(), duration_seconds: 1.2 },
{ id: 'l2', status: 'failed', task_name: 'Ad-hoc: docker ps', target: 'h2', created_at: new Date().toISOString(), duration_seconds: 2.3 },
{ id: 'l3', status: 'completed', task_name: 'Ad-hoc: cat /etc/os-release', target: 'h3', created_at: new Date().toISOString(), duration_seconds: 0.8 },
];
dash.adhocWidgetTotalCount = 3;
dash.adhocWidgetHasMore = false;
dash.renderAdhocWidget();
expect(document.getElementById('adhoc-widget-success').textContent).toBe('2');
expect(document.getElementById('adhoc-widget-failed').textContent).toBe('1');
expect(document.getElementById('adhoc-widget-total').textContent).toBe('3');
expect(document.getElementById('adhoc-widget-count').textContent).toContain('3');
// Since only 2 are displayed but 3 are loaded, load-more must be visible.
expect(document.getElementById('adhoc-widget-load-more').classList.contains('hidden')).toBe(false);
});
});