mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
feat(stats): implement infinite scrolling and paginated model usage lists (fixes #812)
- Add get_model_usage_list API endpoint for paginated stats - Replace static rendering with client-side infinite scroll logic - Add scrollbars and max-height to model usage lists
This commit is contained in:
@@ -209,6 +209,80 @@ class StatsRoutes:
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
async def get_model_usage_list(self, request: web.Request) -> web.Response:
|
||||
"""Get paginated model usage list for infinite scrolling"""
|
||||
try:
|
||||
await self.init_services()
|
||||
|
||||
model_type = request.query.get('type', 'lora')
|
||||
sort_order = request.query.get('sort', 'desc')
|
||||
|
||||
try:
|
||||
limit = int(request.query.get('limit', '50'))
|
||||
offset = int(request.query.get('offset', '0'))
|
||||
except ValueError:
|
||||
limit = 50
|
||||
offset = 0
|
||||
|
||||
# Get usage statistics
|
||||
usage_data = await self.usage_stats.get_stats()
|
||||
|
||||
# Select proper cache and usage dict based on type
|
||||
if model_type == 'lora':
|
||||
cache = await self.lora_scanner.get_cached_data()
|
||||
type_usage_data = usage_data.get('loras', {})
|
||||
elif model_type == 'checkpoint':
|
||||
cache = await self.checkpoint_scanner.get_cached_data()
|
||||
type_usage_data = usage_data.get('checkpoints', {})
|
||||
elif model_type == 'embedding':
|
||||
cache = await self.embedding_scanner.get_cached_data()
|
||||
type_usage_data = usage_data.get('embeddings', {})
|
||||
else:
|
||||
return web.json_response({'success': False, 'error': f"Invalid model type: {model_type}"}, status=400)
|
||||
|
||||
# Create list of all models
|
||||
all_models = []
|
||||
for item in cache.raw_data:
|
||||
sha256 = item.get('sha256')
|
||||
usage_info = type_usage_data.get(sha256, {}) if sha256 else {}
|
||||
usage_count = usage_info.get('total', 0) if isinstance(usage_info, dict) else 0
|
||||
|
||||
all_models.append({
|
||||
'name': item.get('model_name', 'Unknown'),
|
||||
'usage_count': usage_count,
|
||||
'base_model': item.get('base_model', 'Unknown'),
|
||||
'preview_url': config.get_preview_static_url(item.get('preview_url', '')),
|
||||
'folder': item.get('folder', '')
|
||||
})
|
||||
|
||||
# Sort the models
|
||||
reverse = (sort_order == 'desc')
|
||||
all_models.sort(key=lambda x: (x['usage_count'], x['name'].lower()), reverse=reverse)
|
||||
if not reverse:
|
||||
# If asc, sort by usage_count ascending, but keep name ascending
|
||||
all_models.sort(key=lambda x: (x['usage_count'], x['name'].lower()))
|
||||
else:
|
||||
all_models.sort(key=lambda x: (-x['usage_count'], x['name'].lower()))
|
||||
|
||||
# Slice for pagination
|
||||
paginated_models = all_models[offset:offset + limit]
|
||||
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
'data': {
|
||||
'items': paginated_models,
|
||||
'total': len(all_models),
|
||||
'type': model_type
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting model usage list: {e}", exc_info=True)
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
async def get_base_model_distribution(self, request: web.Request) -> web.Response:
|
||||
"""Get base model distribution statistics"""
|
||||
try:
|
||||
@@ -530,6 +604,7 @@ class StatsRoutes:
|
||||
# Register API routes
|
||||
app.router.add_get('/api/lm/stats/collection-overview', self.get_collection_overview)
|
||||
app.router.add_get('/api/lm/stats/usage-analytics', self.get_usage_analytics)
|
||||
app.router.add_get('/api/lm/stats/model-usage-list', self.get_model_usage_list)
|
||||
app.router.add_get('/api/lm/stats/base-model-distribution', self.get_base_model_distribution)
|
||||
app.router.add_get('/api/lm/stats/tag-analytics', self.get_tag_analytics)
|
||||
app.router.add_get('/api/lm/stats/storage-analytics', self.get_storage_analytics)
|
||||
|
||||
@@ -196,6 +196,9 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.model-item {
|
||||
|
||||
@@ -10,6 +10,11 @@ export class StatisticsManager {
|
||||
this.charts = {};
|
||||
this.data = {};
|
||||
this.initialized = false;
|
||||
this.listStates = {
|
||||
lora: { offset: 0, limit: 50, sort: 'desc', isLoading: false, hasMore: true },
|
||||
checkpoint: { offset: 0, limit: 50, sort: 'desc', isLoading: false, hasMore: true },
|
||||
embedding: { offset: 0, limit: 50, sort: 'desc', isLoading: false, hasMore: true }
|
||||
};
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
@@ -105,7 +110,8 @@ export class StatisticsManager {
|
||||
this.initializeCharts();
|
||||
|
||||
// Initialize lists and other components
|
||||
this.renderTopModelsLists();
|
||||
this.initializeLists();
|
||||
this.renderLargestModelsList();
|
||||
this.renderTagCloud();
|
||||
this.renderInsights();
|
||||
}
|
||||
@@ -548,86 +554,86 @@ export class StatisticsManager {
|
||||
});
|
||||
}
|
||||
|
||||
renderTopModelsLists() {
|
||||
this.renderTopLorasList();
|
||||
this.renderTopCheckpointsList();
|
||||
this.renderTopEmbeddingsList();
|
||||
this.renderLargestModelsList();
|
||||
initializeLists() {
|
||||
const listTypes = [
|
||||
{ type: 'lora', containerId: 'topLorasList' },
|
||||
{ type: 'checkpoint', containerId: 'topCheckpointsList' },
|
||||
{ type: 'embedding', containerId: 'topEmbeddingsList' }
|
||||
];
|
||||
|
||||
listTypes.forEach(({ type, containerId }) => {
|
||||
const container = document.getElementById(containerId);
|
||||
|
||||
if (container) {
|
||||
// Handle infinite scrolling
|
||||
container.addEventListener('scroll', () => {
|
||||
if (container.scrollTop + container.clientHeight >= container.scrollHeight - 50) {
|
||||
this.fetchAndRenderList(type, container);
|
||||
}
|
||||
});
|
||||
|
||||
// Initial fetch
|
||||
this.fetchAndRenderList(type, container);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
renderTopLorasList() {
|
||||
const container = document.getElementById('topLorasList');
|
||||
if (!container || !this.data.usage?.top_loras) return;
|
||||
async fetchAndRenderList(type, container) {
|
||||
const state = this.listStates[type];
|
||||
if (state.isLoading || !state.hasMore) return;
|
||||
|
||||
const topLoras = this.data.usage.top_loras;
|
||||
state.isLoading = true;
|
||||
|
||||
if (topLoras.length === 0) {
|
||||
container.innerHTML = '<div class="loading-placeholder">No usage data available</div>';
|
||||
return;
|
||||
// Show loading indicator on initial load
|
||||
if (state.offset === 0) {
|
||||
container.innerHTML = '<div class="loading-placeholder"><i class="fas fa-spinner fa-spin"></i> Loading...</div>';
|
||||
}
|
||||
|
||||
container.innerHTML = topLoras.map(lora => `
|
||||
<div class="model-item">
|
||||
<img src="${lora.preview_url || '/loras_static/images/no-preview.png'}"
|
||||
alt="${lora.name}" class="model-preview"
|
||||
onerror="this.src='/loras_static/images/no-preview.png'">
|
||||
<div class="model-info">
|
||||
<div class="model-name" title="${lora.name}">${lora.name}</div>
|
||||
<div class="model-meta">${lora.base_model} • ${lora.folder}</div>
|
||||
</div>
|
||||
<div class="model-usage">${lora.usage_count}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
try {
|
||||
const url = `/api/lm/stats/model-usage-list?type=${type}&sort=${state.sort}&offset=${state.offset}&limit=${state.limit}`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error('Network response was not ok');
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
const items = result.data.items;
|
||||
|
||||
// Remove loading indicator if it's the first page
|
||||
if (state.offset === 0) {
|
||||
container.innerHTML = '';
|
||||
}
|
||||
|
||||
renderTopCheckpointsList() {
|
||||
const container = document.getElementById('topCheckpointsList');
|
||||
if (!container || !this.data.usage?.top_checkpoints) return;
|
||||
if (items.length === 0 && state.offset === 0) {
|
||||
container.innerHTML = '<div class="loading-placeholder">No models found</div>';
|
||||
state.hasMore = false;
|
||||
} else if (items.length < state.limit) {
|
||||
state.hasMore = false;
|
||||
}
|
||||
|
||||
const topCheckpoints = this.data.usage.top_checkpoints;
|
||||
|
||||
if (topCheckpoints.length === 0) {
|
||||
container.innerHTML = '<div class="loading-placeholder">No usage data available</div>';
|
||||
return;
|
||||
const html = items.map(model => `
|
||||
<div class="model-item">
|
||||
<img src="${model.preview_url || '/loras_static/images/no-preview.png'}"
|
||||
alt="${model.name}" class="model-preview"
|
||||
onerror="this.src='/loras_static/images/no-preview.png'">
|
||||
<div class="model-info">
|
||||
<div class="model-name" title="${model.name}">${model.name}</div>
|
||||
<div class="model-meta">${model.base_model} • ${model.folder || 'Root'}</div>
|
||||
</div>
|
||||
<div class="model-usage">${model.usage_count}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
container.insertAdjacentHTML('beforeend', html);
|
||||
state.offset += state.limit;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error loading ${type} list:`, error);
|
||||
if (state.offset === 0) {
|
||||
container.innerHTML = '<div class="loading-placeholder">Error loading data</div>';
|
||||
}
|
||||
} finally {
|
||||
state.isLoading = false;
|
||||
}
|
||||
|
||||
container.innerHTML = topCheckpoints.map(checkpoint => `
|
||||
<div class="model-item">
|
||||
<img src="${checkpoint.preview_url || '/loras_static/images/no-preview.png'}"
|
||||
alt="${checkpoint.name}" class="model-preview"
|
||||
onerror="this.src='/loras_static/images/no-preview.png'">
|
||||
<div class="model-info">
|
||||
<div class="model-name" title="${checkpoint.name}">${checkpoint.name}</div>
|
||||
<div class="model-meta">${checkpoint.base_model} • ${checkpoint.folder}</div>
|
||||
</div>
|
||||
<div class="model-usage">${checkpoint.usage_count}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
renderTopEmbeddingsList() {
|
||||
const container = document.getElementById('topEmbeddingsList');
|
||||
if (!container || !this.data.usage?.top_embeddings) return;
|
||||
|
||||
const topEmbeddings = this.data.usage.top_embeddings;
|
||||
|
||||
if (topEmbeddings.length === 0) {
|
||||
container.innerHTML = '<div class="loading-placeholder">No usage data available</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = topEmbeddings.map(embedding => `
|
||||
<div class="model-item">
|
||||
<img src="${embedding.preview_url || '/loras_static/images/no-preview.png'}"
|
||||
alt="${embedding.name}" class="model-preview"
|
||||
onerror="this.src='/loras_static/images/no-preview.png'">
|
||||
<div class="model-info">
|
||||
<div class="model-name" title="${embedding.name}">${embedding.name}</div>
|
||||
<div class="model-meta">${embedding.base_model} • ${embedding.folder}</div>
|
||||
</div>
|
||||
<div class="model-usage">${embedding.usage_count}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
renderLargestModelsList() {
|
||||
|
||||
Reference in New Issue
Block a user