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
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:
parent
46823eb42d
commit
661d005fc7
107
alembic/versions/0017_add_favorites_tables.py
Normal file
107
alembic/versions/0017_add_favorites_tables.py
Normal 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')
|
||||
45
alembic/versions/0018_add_icon_key_to_favorite_groups.py
Normal file
45
alembic/versions/0018_add_icon_key_to_favorite_groups.py
Normal 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')
|
||||
@ -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() {
|
||||
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
128
app/crud/favorites.py
Normal 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()
|
||||
@ -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
225
app/favorites_manager.js
Normal 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
483
app/icon_picker.js
Normal 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;
|
||||
@ -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>
|
||||
|
||||
|
||||
779
app/main.js
779
app/main.js
@ -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() {
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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:
|
||||
|
||||
31
app/models/favorite_container.py
Normal file
31
app/models/favorite_container.py
Normal 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},
|
||||
)
|
||||
27
app/models/favorite_group.py
Normal file
27
app/models/favorite_group.py
Normal 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},
|
||||
)
|
||||
@ -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
225
app/routes/favorites.py
Normal 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
69
app/schemas/favorites.py
Normal 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]
|
||||
BIN
data/homelab.db
BIN
data/homelab.db
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
@ -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
90
tests/backend/test_favorites.py
Normal file
90
tests/backend/test_favorites.py
Normal 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"] == []
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user