mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-25 07:05:43 -03:00
Merge pull request #252 from willmiao/stats-page
Add statistics page with metrics, charts, and insights functionality
This commit is contained in:
@@ -10,6 +10,7 @@ from .routes.lora_routes import LoraRoutes
|
|||||||
from .routes.api_routes import ApiRoutes
|
from .routes.api_routes import ApiRoutes
|
||||||
from .routes.recipe_routes import RecipeRoutes
|
from .routes.recipe_routes import RecipeRoutes
|
||||||
from .routes.checkpoints_routes import CheckpointsRoutes
|
from .routes.checkpoints_routes import CheckpointsRoutes
|
||||||
|
from .routes.stats_routes import StatsRoutes
|
||||||
from .routes.update_routes import UpdateRoutes
|
from .routes.update_routes import UpdateRoutes
|
||||||
from .routes.misc_routes import MiscRoutes
|
from .routes.misc_routes import MiscRoutes
|
||||||
from .routes.example_images_routes import ExampleImagesRoutes
|
from .routes.example_images_routes import ExampleImagesRoutes
|
||||||
@@ -112,10 +113,12 @@ class LoraManager:
|
|||||||
# Setup feature routes
|
# Setup feature routes
|
||||||
lora_routes = LoraRoutes()
|
lora_routes = LoraRoutes()
|
||||||
checkpoints_routes = CheckpointsRoutes()
|
checkpoints_routes = CheckpointsRoutes()
|
||||||
|
stats_routes = StatsRoutes()
|
||||||
|
|
||||||
# Initialize routes
|
# Initialize routes
|
||||||
lora_routes.setup_routes(app)
|
lora_routes.setup_routes(app)
|
||||||
checkpoints_routes.setup_routes(app)
|
checkpoints_routes.setup_routes(app)
|
||||||
|
stats_routes.setup_routes(app) # Add statistics routes
|
||||||
ApiRoutes.setup_routes(app)
|
ApiRoutes.setup_routes(app)
|
||||||
RecipeRoutes.setup_routes(app)
|
RecipeRoutes.setup_routes(app)
|
||||||
UpdateRoutes.setup_routes(app)
|
UpdateRoutes.setup_routes(app)
|
||||||
|
|||||||
438
py/routes/stats_routes.py
Normal file
438
py/routes/stats_routes.py
Normal file
@@ -0,0 +1,438 @@
|
|||||||
|
import os
|
||||||
|
import json
|
||||||
|
import jinja2
|
||||||
|
from aiohttp import web
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from collections import defaultdict, Counter
|
||||||
|
from typing import Dict, List, Any
|
||||||
|
|
||||||
|
from ..config import config
|
||||||
|
from ..services.settings_manager import settings
|
||||||
|
from ..services.service_registry import ServiceRegistry
|
||||||
|
from ..utils.usage_stats import UsageStats
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class StatsRoutes:
|
||||||
|
"""Route handlers for Statistics page and API endpoints"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.lora_scanner = None
|
||||||
|
self.checkpoint_scanner = None
|
||||||
|
self.usage_stats = None
|
||||||
|
self.template_env = jinja2.Environment(
|
||||||
|
loader=jinja2.FileSystemLoader(config.templates_path),
|
||||||
|
autoescape=True
|
||||||
|
)
|
||||||
|
|
||||||
|
async def init_services(self):
|
||||||
|
"""Initialize services from ServiceRegistry"""
|
||||||
|
self.lora_scanner = await ServiceRegistry.get_lora_scanner()
|
||||||
|
self.checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||||
|
self.usage_stats = UsageStats()
|
||||||
|
|
||||||
|
async def handle_stats_page(self, request: web.Request) -> web.Response:
|
||||||
|
"""Handle GET /statistics request"""
|
||||||
|
try:
|
||||||
|
# Ensure services are initialized
|
||||||
|
await self.init_services()
|
||||||
|
|
||||||
|
# Check if scanners are initializing
|
||||||
|
lora_initializing = (
|
||||||
|
self.lora_scanner._cache is None or
|
||||||
|
(hasattr(self.lora_scanner, 'is_initializing') and self.lora_scanner.is_initializing())
|
||||||
|
)
|
||||||
|
|
||||||
|
checkpoint_initializing = (
|
||||||
|
self.checkpoint_scanner._cache is None or
|
||||||
|
(hasattr(self.checkpoint_scanner, '_is_initializing') and self.checkpoint_scanner._is_initializing)
|
||||||
|
)
|
||||||
|
|
||||||
|
is_initializing = lora_initializing or checkpoint_initializing
|
||||||
|
|
||||||
|
template = self.template_env.get_template('statistics.html')
|
||||||
|
rendered = template.render(
|
||||||
|
is_initializing=is_initializing,
|
||||||
|
settings=settings,
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
|
||||||
|
return web.Response(
|
||||||
|
text=rendered,
|
||||||
|
content_type='text/html'
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error handling statistics request: {e}", exc_info=True)
|
||||||
|
return web.Response(
|
||||||
|
text="Error loading statistics page",
|
||||||
|
status=500
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_collection_overview(self, request: web.Request) -> web.Response:
|
||||||
|
"""Get collection overview statistics"""
|
||||||
|
try:
|
||||||
|
await self.init_services()
|
||||||
|
|
||||||
|
# Get LoRA statistics
|
||||||
|
lora_cache = await self.lora_scanner.get_cached_data()
|
||||||
|
lora_count = len(lora_cache.raw_data)
|
||||||
|
lora_size = sum(lora.get('size', 0) for lora in lora_cache.raw_data)
|
||||||
|
|
||||||
|
# Get Checkpoint statistics
|
||||||
|
checkpoint_cache = await self.checkpoint_scanner.get_cached_data()
|
||||||
|
checkpoint_count = len(checkpoint_cache.raw_data)
|
||||||
|
checkpoint_size = sum(cp.get('size', 0) for cp in checkpoint_cache.raw_data)
|
||||||
|
|
||||||
|
# Get usage statistics
|
||||||
|
usage_data = await self.usage_stats.get_stats()
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
'success': True,
|
||||||
|
'data': {
|
||||||
|
'total_models': lora_count + checkpoint_count,
|
||||||
|
'lora_count': lora_count,
|
||||||
|
'checkpoint_count': checkpoint_count,
|
||||||
|
'total_size': lora_size + checkpoint_size,
|
||||||
|
'lora_size': lora_size,
|
||||||
|
'checkpoint_size': checkpoint_size,
|
||||||
|
'total_generations': usage_data.get('total_executions', 0),
|
||||||
|
'unused_loras': self._count_unused_models(lora_cache.raw_data, usage_data.get('loras', {})),
|
||||||
|
'unused_checkpoints': self._count_unused_models(checkpoint_cache.raw_data, usage_data.get('checkpoints', {}))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting collection overview: {e}", exc_info=True)
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}, status=500)
|
||||||
|
|
||||||
|
async def get_usage_analytics(self, request: web.Request) -> web.Response:
|
||||||
|
"""Get usage analytics data"""
|
||||||
|
try:
|
||||||
|
await self.init_services()
|
||||||
|
|
||||||
|
# Get usage statistics
|
||||||
|
usage_data = await self.usage_stats.get_stats()
|
||||||
|
|
||||||
|
# Get model data for enrichment
|
||||||
|
lora_cache = await self.lora_scanner.get_cached_data()
|
||||||
|
checkpoint_cache = await self.checkpoint_scanner.get_cached_data()
|
||||||
|
|
||||||
|
# Create hash to model mapping
|
||||||
|
lora_map = {lora['sha256']: lora for lora in lora_cache.raw_data}
|
||||||
|
checkpoint_map = {cp['sha256']: cp for cp in checkpoint_cache.raw_data}
|
||||||
|
|
||||||
|
# Prepare top used models
|
||||||
|
top_loras = self._get_top_used_models(usage_data.get('loras', {}), lora_map, 10)
|
||||||
|
top_checkpoints = self._get_top_used_models(usage_data.get('checkpoints', {}), checkpoint_map, 10)
|
||||||
|
|
||||||
|
# Prepare usage timeline (last 30 days)
|
||||||
|
timeline = self._get_usage_timeline(usage_data, 30)
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
'success': True,
|
||||||
|
'data': {
|
||||||
|
'top_loras': top_loras,
|
||||||
|
'top_checkpoints': top_checkpoints,
|
||||||
|
'usage_timeline': timeline,
|
||||||
|
'total_executions': usage_data.get('total_executions', 0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting usage analytics: {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:
|
||||||
|
await self.init_services()
|
||||||
|
|
||||||
|
# Get model data
|
||||||
|
lora_cache = await self.lora_scanner.get_cached_data()
|
||||||
|
checkpoint_cache = await self.checkpoint_scanner.get_cached_data()
|
||||||
|
|
||||||
|
# Count by base model
|
||||||
|
lora_base_models = Counter(lora.get('base_model', 'Unknown') for lora in lora_cache.raw_data)
|
||||||
|
checkpoint_base_models = Counter(cp.get('base_model', 'Unknown') for cp in checkpoint_cache.raw_data)
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
'success': True,
|
||||||
|
'data': {
|
||||||
|
'loras': dict(lora_base_models),
|
||||||
|
'checkpoints': dict(checkpoint_base_models)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting base model distribution: {e}", exc_info=True)
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}, status=500)
|
||||||
|
|
||||||
|
async def get_tag_analytics(self, request: web.Request) -> web.Response:
|
||||||
|
"""Get tag usage analytics"""
|
||||||
|
try:
|
||||||
|
await self.init_services()
|
||||||
|
|
||||||
|
# Get model data
|
||||||
|
lora_cache = await self.lora_scanner.get_cached_data()
|
||||||
|
checkpoint_cache = await self.checkpoint_scanner.get_cached_data()
|
||||||
|
|
||||||
|
# Count tag frequencies
|
||||||
|
all_tags = []
|
||||||
|
for lora in lora_cache.raw_data:
|
||||||
|
all_tags.extend(lora.get('tags', []))
|
||||||
|
for cp in checkpoint_cache.raw_data:
|
||||||
|
all_tags.extend(cp.get('tags', []))
|
||||||
|
|
||||||
|
tag_counts = Counter(all_tags)
|
||||||
|
|
||||||
|
# Get top 50 tags
|
||||||
|
top_tags = [{'tag': tag, 'count': count} for tag, count in tag_counts.most_common(50)]
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
'success': True,
|
||||||
|
'data': {
|
||||||
|
'top_tags': top_tags,
|
||||||
|
'total_unique_tags': len(tag_counts)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting tag analytics: {e}", exc_info=True)
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}, status=500)
|
||||||
|
|
||||||
|
async def get_storage_analytics(self, request: web.Request) -> web.Response:
|
||||||
|
"""Get storage usage analytics"""
|
||||||
|
try:
|
||||||
|
await self.init_services()
|
||||||
|
|
||||||
|
# Get usage statistics
|
||||||
|
usage_data = await self.usage_stats.get_stats()
|
||||||
|
|
||||||
|
# Get model data
|
||||||
|
lora_cache = await self.lora_scanner.get_cached_data()
|
||||||
|
checkpoint_cache = await self.checkpoint_scanner.get_cached_data()
|
||||||
|
|
||||||
|
# Create models with usage data
|
||||||
|
lora_storage = []
|
||||||
|
for lora in lora_cache.raw_data:
|
||||||
|
usage_count = 0
|
||||||
|
if lora['sha256'] in usage_data.get('loras', {}):
|
||||||
|
usage_count = usage_data['loras'][lora['sha256']].get('total', 0)
|
||||||
|
|
||||||
|
lora_storage.append({
|
||||||
|
'name': lora['model_name'],
|
||||||
|
'size': lora.get('size', 0),
|
||||||
|
'usage_count': usage_count,
|
||||||
|
'folder': lora.get('folder', ''),
|
||||||
|
'base_model': lora.get('base_model', 'Unknown')
|
||||||
|
})
|
||||||
|
|
||||||
|
checkpoint_storage = []
|
||||||
|
for cp in checkpoint_cache.raw_data:
|
||||||
|
usage_count = 0
|
||||||
|
if cp['sha256'] in usage_data.get('checkpoints', {}):
|
||||||
|
usage_count = usage_data['checkpoints'][cp['sha256']].get('total', 0)
|
||||||
|
|
||||||
|
checkpoint_storage.append({
|
||||||
|
'name': cp['model_name'],
|
||||||
|
'size': cp.get('size', 0),
|
||||||
|
'usage_count': usage_count,
|
||||||
|
'folder': cp.get('folder', ''),
|
||||||
|
'base_model': cp.get('base_model', 'Unknown')
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort by size
|
||||||
|
lora_storage.sort(key=lambda x: x['size'], reverse=True)
|
||||||
|
checkpoint_storage.sort(key=lambda x: x['size'], reverse=True)
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
'success': True,
|
||||||
|
'data': {
|
||||||
|
'loras': lora_storage[:20], # Top 20 by size
|
||||||
|
'checkpoints': checkpoint_storage[:20]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting storage analytics: {e}", exc_info=True)
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}, status=500)
|
||||||
|
|
||||||
|
async def get_insights(self, request: web.Request) -> web.Response:
|
||||||
|
"""Get smart insights about the collection"""
|
||||||
|
try:
|
||||||
|
await self.init_services()
|
||||||
|
|
||||||
|
# Get usage statistics
|
||||||
|
usage_data = await self.usage_stats.get_stats()
|
||||||
|
|
||||||
|
# Get model data
|
||||||
|
lora_cache = await self.lora_scanner.get_cached_data()
|
||||||
|
checkpoint_cache = await self.checkpoint_scanner.get_cached_data()
|
||||||
|
|
||||||
|
insights = []
|
||||||
|
|
||||||
|
# Calculate unused models
|
||||||
|
unused_loras = self._count_unused_models(lora_cache.raw_data, usage_data.get('loras', {}))
|
||||||
|
unused_checkpoints = self._count_unused_models(checkpoint_cache.raw_data, usage_data.get('checkpoints', {}))
|
||||||
|
|
||||||
|
total_loras = len(lora_cache.raw_data)
|
||||||
|
total_checkpoints = len(checkpoint_cache.raw_data)
|
||||||
|
|
||||||
|
if total_loras > 0:
|
||||||
|
unused_lora_percent = (unused_loras / total_loras) * 100
|
||||||
|
if unused_lora_percent > 50:
|
||||||
|
insights.append({
|
||||||
|
'type': 'warning',
|
||||||
|
'title': 'High Number of Unused LoRAs',
|
||||||
|
'description': f'{unused_lora_percent:.1f}% of your LoRAs ({unused_loras}/{total_loras}) have never been used.',
|
||||||
|
'suggestion': 'Consider organizing or archiving unused models to free up storage space.'
|
||||||
|
})
|
||||||
|
|
||||||
|
if total_checkpoints > 0:
|
||||||
|
unused_checkpoint_percent = (unused_checkpoints / total_checkpoints) * 100
|
||||||
|
if unused_checkpoint_percent > 30:
|
||||||
|
insights.append({
|
||||||
|
'type': 'warning',
|
||||||
|
'title': 'Unused Checkpoints Detected',
|
||||||
|
'description': f'{unused_checkpoint_percent:.1f}% of your checkpoints ({unused_checkpoints}/{total_checkpoints}) have never been used.',
|
||||||
|
'suggestion': 'Review and consider removing checkpoints you no longer need.'
|
||||||
|
})
|
||||||
|
|
||||||
|
# Storage insights
|
||||||
|
total_size = sum(lora.get('size', 0) for lora in lora_cache.raw_data) + \
|
||||||
|
sum(cp.get('size', 0) for cp in checkpoint_cache.raw_data)
|
||||||
|
|
||||||
|
if total_size > 100 * 1024 * 1024 * 1024: # 100GB
|
||||||
|
insights.append({
|
||||||
|
'type': 'info',
|
||||||
|
'title': 'Large Collection Detected',
|
||||||
|
'description': f'Your model collection is using {self._format_size(total_size)} of storage.',
|
||||||
|
'suggestion': 'Consider using external storage or cloud solutions for better organization.'
|
||||||
|
})
|
||||||
|
|
||||||
|
# Recent activity insight
|
||||||
|
if usage_data.get('total_executions', 0) > 100:
|
||||||
|
insights.append({
|
||||||
|
'type': 'success',
|
||||||
|
'title': 'Active User',
|
||||||
|
'description': f'You\'ve completed {usage_data["total_executions"]} generations so far!',
|
||||||
|
'suggestion': 'Keep exploring and creating amazing content with your models.'
|
||||||
|
})
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
'success': True,
|
||||||
|
'data': {
|
||||||
|
'insights': insights
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting insights: {e}", exc_info=True)
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}, status=500)
|
||||||
|
|
||||||
|
def _count_unused_models(self, models: List[Dict], usage_data: Dict) -> int:
|
||||||
|
"""Count models that have never been used"""
|
||||||
|
used_hashes = set(usage_data.keys())
|
||||||
|
unused_count = 0
|
||||||
|
|
||||||
|
for model in models:
|
||||||
|
if model.get('sha256') not in used_hashes:
|
||||||
|
unused_count += 1
|
||||||
|
|
||||||
|
return unused_count
|
||||||
|
|
||||||
|
def _get_top_used_models(self, usage_data: Dict, model_map: Dict, limit: int) -> List[Dict]:
|
||||||
|
"""Get top used models with their metadata"""
|
||||||
|
sorted_usage = sorted(usage_data.items(), key=lambda x: x[1].get('total', 0), reverse=True)
|
||||||
|
|
||||||
|
top_models = []
|
||||||
|
for sha256, usage_info in sorted_usage[:limit]:
|
||||||
|
if sha256 in model_map:
|
||||||
|
model = model_map[sha256]
|
||||||
|
top_models.append({
|
||||||
|
'name': model['model_name'],
|
||||||
|
'usage_count': usage_info.get('total', 0),
|
||||||
|
'base_model': model.get('base_model', 'Unknown'),
|
||||||
|
'preview_url': config.get_preview_static_url(model.get('preview_url', '')),
|
||||||
|
'folder': model.get('folder', '')
|
||||||
|
})
|
||||||
|
|
||||||
|
return top_models
|
||||||
|
|
||||||
|
def _get_usage_timeline(self, usage_data: Dict, days: int) -> List[Dict]:
|
||||||
|
"""Get usage timeline for the past N days"""
|
||||||
|
timeline = []
|
||||||
|
today = datetime.now()
|
||||||
|
|
||||||
|
for i in range(days):
|
||||||
|
date = today - timedelta(days=i)
|
||||||
|
date_str = date.strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
lora_usage = 0
|
||||||
|
checkpoint_usage = 0
|
||||||
|
|
||||||
|
# Count usage for this date
|
||||||
|
for model_usage in usage_data.get('loras', {}).values():
|
||||||
|
if isinstance(model_usage, dict) and 'history' in model_usage:
|
||||||
|
lora_usage += model_usage['history'].get(date_str, 0)
|
||||||
|
|
||||||
|
for model_usage in usage_data.get('checkpoints', {}).values():
|
||||||
|
if isinstance(model_usage, dict) and 'history' in model_usage:
|
||||||
|
checkpoint_usage += model_usage['history'].get(date_str, 0)
|
||||||
|
|
||||||
|
timeline.append({
|
||||||
|
'date': date_str,
|
||||||
|
'lora_usage': lora_usage,
|
||||||
|
'checkpoint_usage': checkpoint_usage,
|
||||||
|
'total_usage': lora_usage + checkpoint_usage
|
||||||
|
})
|
||||||
|
|
||||||
|
return list(reversed(timeline)) # Oldest to newest
|
||||||
|
|
||||||
|
def _format_size(self, size_bytes: int) -> str:
|
||||||
|
"""Format file size in human readable format"""
|
||||||
|
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
|
||||||
|
if size_bytes < 1024.0:
|
||||||
|
return f"{size_bytes:.1f} {unit}"
|
||||||
|
size_bytes /= 1024.0
|
||||||
|
return f"{size_bytes:.1f} PB"
|
||||||
|
|
||||||
|
def setup_routes(self, app: web.Application):
|
||||||
|
"""Register routes with the application"""
|
||||||
|
# Add an app startup handler to initialize services
|
||||||
|
app.on_startup.append(self._on_startup)
|
||||||
|
|
||||||
|
# Register page route
|
||||||
|
app.router.add_get('/statistics', self.handle_stats_page)
|
||||||
|
|
||||||
|
# Register API routes
|
||||||
|
app.router.add_get('/api/stats/collection-overview', self.get_collection_overview)
|
||||||
|
app.router.add_get('/api/stats/usage-analytics', self.get_usage_analytics)
|
||||||
|
app.router.add_get('/api/stats/base-model-distribution', self.get_base_model_distribution)
|
||||||
|
app.router.add_get('/api/stats/tag-analytics', self.get_tag_analytics)
|
||||||
|
app.router.add_get('/api/stats/storage-analytics', self.get_storage_analytics)
|
||||||
|
app.router.add_get('/api/stats/insights', self.get_insights)
|
||||||
|
|
||||||
|
async def _on_startup(self, app):
|
||||||
|
"""Initialize services when the app starts"""
|
||||||
|
await self.init_services()
|
||||||
@@ -301,13 +301,16 @@ class StandaloneLoraManager(LoraManager):
|
|||||||
from py.routes.update_routes import UpdateRoutes
|
from py.routes.update_routes import UpdateRoutes
|
||||||
from py.routes.misc_routes import MiscRoutes
|
from py.routes.misc_routes import MiscRoutes
|
||||||
from py.routes.example_images_routes import ExampleImagesRoutes
|
from py.routes.example_images_routes import ExampleImagesRoutes
|
||||||
|
from py.routes.stats_routes import StatsRoutes
|
||||||
|
|
||||||
lora_routes = LoraRoutes()
|
lora_routes = LoraRoutes()
|
||||||
checkpoints_routes = CheckpointsRoutes()
|
checkpoints_routes = CheckpointsRoutes()
|
||||||
|
stats_routes = StatsRoutes()
|
||||||
|
|
||||||
# Initialize routes
|
# Initialize routes
|
||||||
lora_routes.setup_routes(app)
|
lora_routes.setup_routes(app)
|
||||||
checkpoints_routes.setup_routes(app)
|
checkpoints_routes.setup_routes(app)
|
||||||
|
stats_routes.setup_routes(app)
|
||||||
ApiRoutes.setup_routes(app)
|
ApiRoutes.setup_routes(app)
|
||||||
RecipeRoutes.setup_routes(app)
|
RecipeRoutes.setup_routes(app)
|
||||||
UpdateRoutes.setup_routes(app)
|
UpdateRoutes.setup_routes(app)
|
||||||
|
|||||||
520
static/css/components/statistics.css
Normal file
520
static/css/components/statistics.css
Normal file
@@ -0,0 +1,520 @@
|
|||||||
|
/* Statistics Page Styles */
|
||||||
|
.metrics-panel {
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-base);
|
||||||
|
padding: var(--space-2);
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card .metric-icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
color: var(--lora-accent);
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card .metric-value {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--text-color);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card .metric-label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: oklch(var(--text-color) / 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card .metric-change {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-change.positive {
|
||||||
|
color: var(--lora-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-change.negative {
|
||||||
|
color: var(--lora-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dashboard Content */
|
||||||
|
.dashboard-content {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-base);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-tabs {
|
||||||
|
display: flex;
|
||||||
|
background: var(--bg-color);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
color: var(--text-color);
|
||||||
|
border-bottom: 3px solid transparent;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button:hover {
|
||||||
|
background: oklch(var(--lora-accent) / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button.active {
|
||||||
|
color: var(--lora-accent);
|
||||||
|
border-bottom-color: var(--lora-accent);
|
||||||
|
background: oklch(var(--lora-accent) / 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
padding: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-panel {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-panel.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Panel Grid Layout */
|
||||||
|
.panel-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-grid .full-width {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chart Containers */
|
||||||
|
.chart-container {
|
||||||
|
background: var(--bg-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
padding: var(--space-2);
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container h3 {
|
||||||
|
margin: 0 0 var(--space-2) 0;
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container h3 i {
|
||||||
|
color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-wrapper {
|
||||||
|
position: relative;
|
||||||
|
height: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-wrapper canvas {
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* List Containers */
|
||||||
|
.list-container {
|
||||||
|
background: var(--bg-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
padding: var(--space-2);
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-container h3 {
|
||||||
|
margin: 0 0 var(--space-2) 0;
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-container h3 i {
|
||||||
|
color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-item:hover {
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-item .model-preview {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
margin-right: 12px;
|
||||||
|
object-fit: cover;
|
||||||
|
background: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-item .model-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-item .model-name {
|
||||||
|
font-weight: 600;
|
||||||
|
text-shadow: none;
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-item .model-meta {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: oklch(var(--text-color) / 0.7);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-item .model-usage {
|
||||||
|
text-align: right;
|
||||||
|
color: var(--lora-accent);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tag Cloud */
|
||||||
|
.tag-cloud {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
padding: var(--space-2) 0;
|
||||||
|
max-height: 250px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-cloud-item {
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: oklch(var(--lora-accent) / 0.1);
|
||||||
|
color: var(--lora-accent);
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
border: 1px solid oklch(var(--lora-accent) / 0.2);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-cloud-item:hover {
|
||||||
|
background: oklch(var(--lora-accent) / 0.2);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-cloud-item.size-1 { font-size: 0.7rem; }
|
||||||
|
.tag-cloud-item.size-2 { font-size: 0.8rem; }
|
||||||
|
.tag-cloud-item.size-3 { font-size: 0.9rem; }
|
||||||
|
.tag-cloud-item.size-4 { font-size: 1.0rem; }
|
||||||
|
.tag-cloud-item.size-5 { font-size: 1.1rem; font-weight: 600; }
|
||||||
|
|
||||||
|
/* Analysis Cards */
|
||||||
|
.analysis-cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
padding: var(--space-2);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-card .card-icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--lora-accent);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-card .card-value {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--text-color);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-card .card-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: oklch(var(--text-color) / 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Insights */
|
||||||
|
.insights-container {
|
||||||
|
background: var(--bg-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
padding: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.insights-container h3 {
|
||||||
|
margin: 0 0 var(--space-2) 0;
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insights-container h3 i {
|
||||||
|
color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.insights-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.insight-card {
|
||||||
|
padding: var(--space-2);
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insight-card:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.insight-card.type-success {
|
||||||
|
border-left: 4px solid var(--lora-success);
|
||||||
|
background: oklch(var(--lora-success) / 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.insight-card.type-warning {
|
||||||
|
border-left: 4px solid var(--lora-warning);
|
||||||
|
background: oklch(var(--lora-warning) / 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.insight-card.type-info {
|
||||||
|
border-left: 4px solid var(--lora-accent);
|
||||||
|
background: oklch(var(--lora-accent) / 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.insight-card.type-error {
|
||||||
|
border-left: 4px solid var(--lora-error);
|
||||||
|
background: oklch(var(--lora-error) / 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.insight-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insight-description {
|
||||||
|
color: oklch(var(--text-color) / 0.8);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insight-suggestion {
|
||||||
|
color: oklch(var(--text-color) / 0.7);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Recommendations Section */
|
||||||
|
.recommendations-section {
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
padding-top: var(--space-3);
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendations-section h4 {
|
||||||
|
margin: 0 0 var(--space-2) 0;
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendations-section h4 i {
|
||||||
|
color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendations-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendation-item {
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendation-item:hover {
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendation-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendation-description {
|
||||||
|
color: oklch(var(--text-color) / 0.8);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading States */
|
||||||
|
.loading-placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 200px;
|
||||||
|
color: oklch(var(--text-color) / 0.6);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-placeholder i {
|
||||||
|
margin-right: 8px;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.panel-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.dashboard-tabs {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 12px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button i {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
padding: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card {
|
||||||
|
padding: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card .metric-icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card .metric-value {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-wrapper {
|
||||||
|
height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-item .model-preview {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode adjustments */
|
||||||
|
[data-theme="dark"] .chart-container,
|
||||||
|
[data-theme="dark"] .list-container,
|
||||||
|
[data-theme="dark"] .insights-container {
|
||||||
|
border-color: oklch(var(--border-color) / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .metric-card {
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .metric-card:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@
|
|||||||
@import 'components/alphabet-bar.css'; /* Add alphabet bar component */
|
@import 'components/alphabet-bar.css'; /* Add alphabet bar component */
|
||||||
@import 'components/duplicates.css'; /* Add duplicates component */
|
@import 'components/duplicates.css'; /* Add duplicates component */
|
||||||
@import 'components/keyboard-nav.css'; /* Add keyboard navigation component */
|
@import 'components/keyboard-nav.css'; /* Add keyboard navigation component */
|
||||||
|
@import 'components/statistics.css'; /* Add statistics component */
|
||||||
|
|
||||||
.initialization-notice {
|
.initialization-notice {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export class HeaderManager {
|
|||||||
const path = window.location.pathname;
|
const path = window.location.pathname;
|
||||||
if (path.includes('/loras/recipes')) return 'recipes';
|
if (path.includes('/loras/recipes')) return 'recipes';
|
||||||
if (path.includes('/checkpoints')) return 'checkpoints';
|
if (path.includes('/checkpoints')) return 'checkpoints';
|
||||||
|
if (path.includes('/statistics')) return 'statistics';
|
||||||
if (path.includes('/loras')) return 'loras';
|
if (path.includes('/loras')) return 'loras';
|
||||||
return 'unknown';
|
return 'unknown';
|
||||||
}
|
}
|
||||||
@@ -121,14 +122,17 @@ export class HeaderManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle help toggle
|
// Hide search functionality on Statistics page
|
||||||
// const helpToggle = document.querySelector('.help-toggle');
|
this.updateHeaderForPage();
|
||||||
// if (helpToggle) {
|
}
|
||||||
// helpToggle.addEventListener('click', () => {
|
|
||||||
// if (window.modalManager) {
|
updateHeaderForPage() {
|
||||||
// window.modalManager.toggleModal('helpModal');
|
const headerSearch = document.getElementById('headerSearch');
|
||||||
// }
|
|
||||||
// });
|
if (this.currentPage === 'statistics' && headerSearch) {
|
||||||
// }
|
headerSearch.style.display = 'none';
|
||||||
|
} else if (headerSearch) {
|
||||||
|
headerSearch.style.display = 'flex';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,6 +148,8 @@ export class FilterManager {
|
|||||||
apiEndpoint = '/api/recipes/base-models';
|
apiEndpoint = '/api/recipes/base-models';
|
||||||
} else if (this.currentPage === 'checkpoints') {
|
} else if (this.currentPage === 'checkpoints') {
|
||||||
apiEndpoint = '/api/checkpoints/base-models';
|
apiEndpoint = '/api/checkpoints/base-models';
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch base models
|
// Fetch base models
|
||||||
|
|||||||
730
static/js/statistics.js
Normal file
730
static/js/statistics.js
Normal file
@@ -0,0 +1,730 @@
|
|||||||
|
// Statistics page functionality
|
||||||
|
import { appCore } from './core.js';
|
||||||
|
import { showToast } from './utils/uiHelpers.js';
|
||||||
|
|
||||||
|
// Chart.js import (assuming it's available globally or via CDN)
|
||||||
|
// If Chart.js isn't available, we'll need to add it to the project
|
||||||
|
|
||||||
|
class StatisticsManager {
|
||||||
|
constructor() {
|
||||||
|
this.charts = {};
|
||||||
|
this.data = {};
|
||||||
|
this.initialized = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize() {
|
||||||
|
if (this.initialized) return;
|
||||||
|
|
||||||
|
console.log('StatisticsManager: Initializing...');
|
||||||
|
|
||||||
|
// Initialize tab functionality
|
||||||
|
this.initializeTabs();
|
||||||
|
|
||||||
|
// Load initial data
|
||||||
|
await this.loadAllData();
|
||||||
|
|
||||||
|
// Initialize charts and visualizations
|
||||||
|
this.initializeVisualizations();
|
||||||
|
|
||||||
|
this.initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeTabs() {
|
||||||
|
const tabButtons = document.querySelectorAll('.tab-button');
|
||||||
|
const tabPanels = document.querySelectorAll('.tab-panel');
|
||||||
|
|
||||||
|
tabButtons.forEach(button => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
const tabId = button.dataset.tab;
|
||||||
|
|
||||||
|
// Update active tab button
|
||||||
|
tabButtons.forEach(btn => btn.classList.remove('active'));
|
||||||
|
button.classList.add('active');
|
||||||
|
|
||||||
|
// Update active tab panel
|
||||||
|
tabPanels.forEach(panel => panel.classList.remove('active'));
|
||||||
|
const targetPanel = document.getElementById(`${tabId}-panel`);
|
||||||
|
if (targetPanel) {
|
||||||
|
targetPanel.classList.add('active');
|
||||||
|
|
||||||
|
// Refresh charts when tab becomes visible
|
||||||
|
this.refreshChartsInPanel(tabId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadAllData() {
|
||||||
|
try {
|
||||||
|
// Load all statistics data in parallel
|
||||||
|
const [
|
||||||
|
collectionOverview,
|
||||||
|
usageAnalytics,
|
||||||
|
baseModelDistribution,
|
||||||
|
tagAnalytics,
|
||||||
|
storageAnalytics,
|
||||||
|
insights
|
||||||
|
] = await Promise.all([
|
||||||
|
this.fetchData('/api/stats/collection-overview'),
|
||||||
|
this.fetchData('/api/stats/usage-analytics'),
|
||||||
|
this.fetchData('/api/stats/base-model-distribution'),
|
||||||
|
this.fetchData('/api/stats/tag-analytics'),
|
||||||
|
this.fetchData('/api/stats/storage-analytics'),
|
||||||
|
this.fetchData('/api/stats/insights')
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.data = {
|
||||||
|
collection: collectionOverview.data,
|
||||||
|
usage: usageAnalytics.data,
|
||||||
|
baseModels: baseModelDistribution.data,
|
||||||
|
tags: tagAnalytics.data,
|
||||||
|
storage: storageAnalytics.data,
|
||||||
|
insights: insights.data
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Statistics data loaded:', this.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading statistics data:', error);
|
||||||
|
showToast('Failed to load statistics data', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchData(endpoint) {
|
||||||
|
const response = await fetch(endpoint);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch ${endpoint}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeVisualizations() {
|
||||||
|
// Initialize metrics cards
|
||||||
|
this.renderMetricsCards();
|
||||||
|
|
||||||
|
// Initialize charts
|
||||||
|
this.initializeCharts();
|
||||||
|
|
||||||
|
// Initialize lists and other components
|
||||||
|
this.renderTopModelsLists();
|
||||||
|
this.renderTagCloud();
|
||||||
|
this.renderInsights();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderMetricsCards() {
|
||||||
|
const metricsGrid = document.getElementById('metricsGrid');
|
||||||
|
if (!metricsGrid || !this.data.collection) return;
|
||||||
|
|
||||||
|
const metrics = [
|
||||||
|
{
|
||||||
|
icon: 'fas fa-magic',
|
||||||
|
value: this.data.collection.total_models,
|
||||||
|
label: 'Total Models',
|
||||||
|
format: 'number'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'fas fa-database',
|
||||||
|
value: this.data.collection.total_size,
|
||||||
|
label: 'Total Storage',
|
||||||
|
format: 'size'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'fas fa-play-circle',
|
||||||
|
value: this.data.collection.total_generations,
|
||||||
|
label: 'Total Generations',
|
||||||
|
format: 'number'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'fas fa-chart-line',
|
||||||
|
value: this.calculateUsageRate(),
|
||||||
|
label: 'Usage Rate',
|
||||||
|
format: 'percentage'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'fas fa-layer-group',
|
||||||
|
value: this.data.collection.lora_count,
|
||||||
|
label: 'LoRAs',
|
||||||
|
format: 'number'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'fas fa-check-circle',
|
||||||
|
value: this.data.collection.checkpoint_count,
|
||||||
|
label: 'Checkpoints',
|
||||||
|
format: 'number'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
metricsGrid.innerHTML = metrics.map(metric => this.createMetricCard(metric)).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
createMetricCard(metric) {
|
||||||
|
const formattedValue = this.formatValue(metric.value, metric.format);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="metric-card">
|
||||||
|
<div class="metric-icon">
|
||||||
|
<i class="${metric.icon}"></i>
|
||||||
|
</div>
|
||||||
|
<div class="metric-value">${formattedValue}</div>
|
||||||
|
<div class="metric-label">${metric.label}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
formatValue(value, format) {
|
||||||
|
switch (format) {
|
||||||
|
case 'number':
|
||||||
|
return new Intl.NumberFormat().format(value);
|
||||||
|
case 'size':
|
||||||
|
return this.formatFileSize(value);
|
||||||
|
case 'percentage':
|
||||||
|
return `${value.toFixed(1)}%`;
|
||||||
|
default:
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatFileSize(bytes) {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateUsageRate() {
|
||||||
|
if (!this.data.collection) return 0;
|
||||||
|
|
||||||
|
const totalModels = this.data.collection.total_models;
|
||||||
|
const unusedModels = this.data.collection.unused_loras + this.data.collection.unused_checkpoints;
|
||||||
|
const usedModels = totalModels - unusedModels;
|
||||||
|
|
||||||
|
return totalModels > 0 ? (usedModels / totalModels) * 100 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeCharts() {
|
||||||
|
// Check if Chart.js is available
|
||||||
|
if (typeof Chart === 'undefined') {
|
||||||
|
console.warn('Chart.js is not available. Charts will not be rendered.');
|
||||||
|
this.showChartPlaceholders();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collection pie chart
|
||||||
|
this.createCollectionPieChart();
|
||||||
|
|
||||||
|
// Base model distribution chart
|
||||||
|
this.createBaseModelChart();
|
||||||
|
|
||||||
|
// Usage timeline chart
|
||||||
|
this.createUsageTimelineChart();
|
||||||
|
|
||||||
|
// Usage distribution chart
|
||||||
|
this.createUsageDistributionChart();
|
||||||
|
|
||||||
|
// Storage chart
|
||||||
|
this.createStorageChart();
|
||||||
|
|
||||||
|
// Storage efficiency chart
|
||||||
|
this.createStorageEfficiencyChart();
|
||||||
|
}
|
||||||
|
|
||||||
|
createCollectionPieChart() {
|
||||||
|
const ctx = document.getElementById('collectionPieChart');
|
||||||
|
if (!ctx || !this.data.collection) return;
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
labels: ['LoRAs', 'Checkpoints'],
|
||||||
|
datasets: [{
|
||||||
|
data: [this.data.collection.lora_count, this.data.collection.checkpoint_count],
|
||||||
|
backgroundColor: [
|
||||||
|
'oklch(68% 0.28 256)',
|
||||||
|
'oklch(68% 0.28 200)'
|
||||||
|
],
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: getComputedStyle(document.documentElement).getPropertyValue('--border-color')
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
|
this.charts.collection = new Chart(ctx, {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: data,
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'bottom'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createBaseModelChart() {
|
||||||
|
const ctx = document.getElementById('baseModelChart');
|
||||||
|
if (!ctx || !this.data.baseModels) return;
|
||||||
|
|
||||||
|
const loraData = this.data.baseModels.loras;
|
||||||
|
const checkpointData = this.data.baseModels.checkpoints;
|
||||||
|
|
||||||
|
const allModels = new Set([...Object.keys(loraData), ...Object.keys(checkpointData)]);
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
labels: Array.from(allModels),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'LoRAs',
|
||||||
|
data: Array.from(allModels).map(model => loraData[model] || 0),
|
||||||
|
backgroundColor: 'oklch(68% 0.28 256 / 0.7)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Checkpoints',
|
||||||
|
data: Array.from(allModels).map(model => checkpointData[model] || 0),
|
||||||
|
backgroundColor: 'oklch(68% 0.28 200 / 0.7)'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
this.charts.baseModels = new Chart(ctx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: data,
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
stacked: true
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
stacked: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createUsageTimelineChart() {
|
||||||
|
const ctx = document.getElementById('usageTimelineChart');
|
||||||
|
if (!ctx || !this.data.usage) return;
|
||||||
|
|
||||||
|
const timeline = this.data.usage.usage_timeline || [];
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
labels: timeline.map(item => new Date(item.date).toLocaleDateString()),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'LoRA Usage',
|
||||||
|
data: timeline.map(item => item.lora_usage),
|
||||||
|
borderColor: 'oklch(68% 0.28 256)',
|
||||||
|
backgroundColor: 'oklch(68% 0.28 256 / 0.1)',
|
||||||
|
fill: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Checkpoint Usage',
|
||||||
|
data: timeline.map(item => item.checkpoint_usage),
|
||||||
|
borderColor: 'oklch(68% 0.28 200)',
|
||||||
|
backgroundColor: 'oklch(68% 0.28 200 / 0.1)',
|
||||||
|
fill: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
this.charts.timeline = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: data,
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
display: true,
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Date'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
display: true,
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Usage Count'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createUsageDistributionChart() {
|
||||||
|
const ctx = document.getElementById('usageDistributionChart');
|
||||||
|
if (!ctx || !this.data.usage) return;
|
||||||
|
|
||||||
|
const topLoras = this.data.usage.top_loras || [];
|
||||||
|
const topCheckpoints = this.data.usage.top_checkpoints || [];
|
||||||
|
|
||||||
|
// Combine and sort all models by usage
|
||||||
|
const allModels = [
|
||||||
|
...topLoras.map(m => ({ ...m, type: 'LoRA' })),
|
||||||
|
...topCheckpoints.map(m => ({ ...m, type: 'Checkpoint' }))
|
||||||
|
].sort((a, b) => b.usage_count - a.usage_count).slice(0, 10);
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
labels: allModels.map(model => model.name),
|
||||||
|
datasets: [{
|
||||||
|
label: 'Usage Count',
|
||||||
|
data: allModels.map(model => model.usage_count),
|
||||||
|
backgroundColor: allModels.map(model =>
|
||||||
|
model.type === 'LoRA' ? 'oklch(68% 0.28 256)' : 'oklch(68% 0.28 200)'
|
||||||
|
)
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
|
this.charts.distribution = new Chart(ctx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: data,
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
indexAxis: 'y',
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createStorageChart() {
|
||||||
|
const ctx = document.getElementById('storageChart');
|
||||||
|
if (!ctx || !this.data.collection) return;
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
labels: ['LoRAs', 'Checkpoints'],
|
||||||
|
datasets: [{
|
||||||
|
data: [this.data.collection.lora_size, this.data.collection.checkpoint_size],
|
||||||
|
backgroundColor: [
|
||||||
|
'oklch(68% 0.28 256)',
|
||||||
|
'oklch(68% 0.28 200)'
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
|
this.charts.storage = new Chart(ctx, {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: data,
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'bottom'
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: (context) => {
|
||||||
|
const value = this.formatFileSize(context.raw);
|
||||||
|
return `${context.label}: ${value}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createStorageEfficiencyChart() {
|
||||||
|
const ctx = document.getElementById('storageEfficiencyChart');
|
||||||
|
if (!ctx || !this.data.storage) return;
|
||||||
|
|
||||||
|
const loraData = this.data.storage.loras || [];
|
||||||
|
const checkpointData = this.data.storage.checkpoints || [];
|
||||||
|
|
||||||
|
const allData = [
|
||||||
|
...loraData.map(item => ({ ...item, type: 'LoRA' })),
|
||||||
|
...checkpointData.map(item => ({ ...item, type: 'Checkpoint' }))
|
||||||
|
];
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
datasets: [{
|
||||||
|
label: 'Models',
|
||||||
|
data: allData.map(item => ({
|
||||||
|
x: item.size,
|
||||||
|
y: item.usage_count,
|
||||||
|
name: item.name,
|
||||||
|
type: item.type
|
||||||
|
})),
|
||||||
|
backgroundColor: allData.map(item =>
|
||||||
|
item.type === 'LoRA' ? 'oklch(68% 0.28 256 / 0.6)' : 'oklch(68% 0.28 200 / 0.6)'
|
||||||
|
)
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
|
this.charts.efficiency = new Chart(ctx, {
|
||||||
|
type: 'scatter',
|
||||||
|
data: data,
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'File Size (bytes)'
|
||||||
|
},
|
||||||
|
type: 'logarithmic'
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Usage Count'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: (context) => {
|
||||||
|
const point = context.raw;
|
||||||
|
return `${point.name}: ${this.formatFileSize(point.x)}, ${point.y} uses`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
renderTopModelsLists() {
|
||||||
|
this.renderTopLorasList();
|
||||||
|
this.renderTopCheckpointsList();
|
||||||
|
this.renderLargestModelsList();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderTopLorasList() {
|
||||||
|
const container = document.getElementById('topLorasList');
|
||||||
|
if (!container || !this.data.usage?.top_loras) return;
|
||||||
|
|
||||||
|
const topLoras = this.data.usage.top_loras;
|
||||||
|
|
||||||
|
if (topLoras.length === 0) {
|
||||||
|
container.innerHTML = '<div class="loading-placeholder">No usage data available</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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('');
|
||||||
|
}
|
||||||
|
|
||||||
|
renderTopCheckpointsList() {
|
||||||
|
const container = document.getElementById('topCheckpointsList');
|
||||||
|
if (!container || !this.data.usage?.top_checkpoints) return;
|
||||||
|
|
||||||
|
const topCheckpoints = this.data.usage.top_checkpoints;
|
||||||
|
|
||||||
|
if (topCheckpoints.length === 0) {
|
||||||
|
container.innerHTML = '<div class="loading-placeholder">No usage data available</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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('');
|
||||||
|
}
|
||||||
|
|
||||||
|
renderLargestModelsList() {
|
||||||
|
const container = document.getElementById('largestModelsList');
|
||||||
|
if (!container || !this.data.storage) return;
|
||||||
|
|
||||||
|
const loraModels = this.data.storage.loras || [];
|
||||||
|
const checkpointModels = this.data.storage.checkpoints || [];
|
||||||
|
|
||||||
|
// Combine and sort by size
|
||||||
|
const allModels = [
|
||||||
|
...loraModels.map(m => ({ ...m, type: 'LoRA' })),
|
||||||
|
...checkpointModels.map(m => ({ ...m, type: 'Checkpoint' }))
|
||||||
|
].sort((a, b) => b.size - a.size).slice(0, 10);
|
||||||
|
|
||||||
|
if (allModels.length === 0) {
|
||||||
|
container.innerHTML = '<div class="loading-placeholder">No storage data available</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = allModels.map(model => `
|
||||||
|
<div class="model-item">
|
||||||
|
<div class="model-info">
|
||||||
|
<div class="model-name" title="${model.name}">${model.name}</div>
|
||||||
|
<div class="model-meta">${model.type} • ${model.base_model}</div>
|
||||||
|
</div>
|
||||||
|
<div class="model-usage">${this.formatFileSize(model.size)}</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
renderTagCloud() {
|
||||||
|
const container = document.getElementById('tagCloud');
|
||||||
|
if (!container || !this.data.tags?.top_tags) return;
|
||||||
|
|
||||||
|
const topTags = this.data.tags.top_tags.slice(0, 30); // Show top 30 tags
|
||||||
|
const maxCount = Math.max(...topTags.map(tag => tag.count));
|
||||||
|
|
||||||
|
container.innerHTML = topTags.map(tagData => {
|
||||||
|
const size = Math.ceil((tagData.count / maxCount) * 5);
|
||||||
|
return `
|
||||||
|
<span class="tag-cloud-item size-${size}"
|
||||||
|
title="${tagData.tag}: ${tagData.count} models">
|
||||||
|
${tagData.tag}
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
renderInsights() {
|
||||||
|
const container = document.getElementById('insightsList');
|
||||||
|
if (!container || !this.data.insights?.insights) return;
|
||||||
|
|
||||||
|
const insights = this.data.insights.insights;
|
||||||
|
|
||||||
|
if (insights.length === 0) {
|
||||||
|
container.innerHTML = '<div class="loading-placeholder">No insights available</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = insights.map(insight => `
|
||||||
|
<div class="insight-card type-${insight.type}">
|
||||||
|
<div class="insight-title">${insight.title}</div>
|
||||||
|
<div class="insight-description">${insight.description}</div>
|
||||||
|
<div class="insight-suggestion">${insight.suggestion}</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
// Render collection analysis cards
|
||||||
|
this.renderCollectionAnalysis();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCollectionAnalysis() {
|
||||||
|
const container = document.getElementById('collectionAnalysis');
|
||||||
|
if (!container || !this.data.collection) return;
|
||||||
|
|
||||||
|
const analysis = [
|
||||||
|
{
|
||||||
|
icon: 'fas fa-percentage',
|
||||||
|
value: this.calculateUsageRate(),
|
||||||
|
label: 'Usage Rate',
|
||||||
|
format: 'percentage'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'fas fa-tags',
|
||||||
|
value: this.data.tags?.total_unique_tags || 0,
|
||||||
|
label: 'Unique Tags',
|
||||||
|
format: 'number'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'fas fa-clock',
|
||||||
|
value: this.data.collection.unused_loras + this.data.collection.unused_checkpoints,
|
||||||
|
label: 'Unused Models',
|
||||||
|
format: 'number'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'fas fa-chart-line',
|
||||||
|
value: this.calculateAverageUsage(),
|
||||||
|
label: 'Avg. Uses/Model',
|
||||||
|
format: 'decimal'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
container.innerHTML = analysis.map(item => `
|
||||||
|
<div class="analysis-card">
|
||||||
|
<div class="card-icon">
|
||||||
|
<i class="${item.icon}"></i>
|
||||||
|
</div>
|
||||||
|
<div class="card-value">${this.formatValue(item.value, item.format)}</div>
|
||||||
|
<div class="card-label">${item.label}</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateAverageUsage() {
|
||||||
|
if (!this.data.usage || !this.data.collection) return 0;
|
||||||
|
|
||||||
|
const totalGenerations = this.data.collection.total_generations;
|
||||||
|
const totalModels = this.data.collection.total_models;
|
||||||
|
|
||||||
|
return totalModels > 0 ? totalGenerations / totalModels : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
showChartPlaceholders() {
|
||||||
|
const chartCanvases = document.querySelectorAll('canvas');
|
||||||
|
chartCanvases.forEach(canvas => {
|
||||||
|
const container = canvas.parentElement;
|
||||||
|
container.innerHTML = '<div class="loading-placeholder"><i class="fas fa-chart-bar"></i> Chart requires Chart.js library</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshChartsInPanel(panelId) {
|
||||||
|
// Refresh charts when panels become visible
|
||||||
|
setTimeout(() => {
|
||||||
|
Object.values(this.charts).forEach(chart => {
|
||||||
|
if (chart && typeof chart.resize === 'function') {
|
||||||
|
chart.resize();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
// Clean up charts
|
||||||
|
Object.values(this.charts).forEach(chart => {
|
||||||
|
if (chart && typeof chart.destroy === 'function') {
|
||||||
|
chart.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.charts = {};
|
||||||
|
this.initialized = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize statistics page when DOM is ready
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
// Wait for app core to initialize
|
||||||
|
await appCore.initialize();
|
||||||
|
|
||||||
|
// Initialize statistics functionality
|
||||||
|
const statsManager = new StatisticsManager();
|
||||||
|
await statsManager.initialize();
|
||||||
|
|
||||||
|
// Make statsManager globally available for debugging
|
||||||
|
window.statsManager = statsManager;
|
||||||
|
|
||||||
|
console.log('Statistics page initialized successfully');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle page unload
|
||||||
|
window.addEventListener('beforeunload', () => {
|
||||||
|
if (window.statsManager) {
|
||||||
|
window.statsManager.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -40,7 +40,8 @@ export const apiRoutes = {
|
|||||||
export const pageRoutes = {
|
export const pageRoutes = {
|
||||||
loras: '/loras',
|
loras: '/loras',
|
||||||
recipes: '/loras/recipes',
|
recipes: '/loras/recipes',
|
||||||
checkpoints: '/checkpoints'
|
checkpoints: '/checkpoints',
|
||||||
|
statistics: '/statistics'
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to get current page type
|
// Helper function to get current page type
|
||||||
@@ -48,6 +49,7 @@ export function getCurrentPageType() {
|
|||||||
const path = window.location.pathname;
|
const path = window.location.pathname;
|
||||||
if (path.includes('/loras/recipes')) return 'recipes';
|
if (path.includes('/loras/recipes')) return 'recipes';
|
||||||
if (path.includes('/checkpoints')) return 'checkpoints';
|
if (path.includes('/checkpoints')) return 'checkpoints';
|
||||||
|
if (path.includes('/statistics')) return 'statistics';
|
||||||
if (path.includes('/loras')) return 'loras';
|
if (path.includes('/loras')) return 'loras';
|
||||||
return 'unknown';
|
return 'unknown';
|
||||||
}
|
}
|
||||||
@@ -16,10 +16,13 @@
|
|||||||
<a href="/checkpoints" class="nav-item" id="checkpointsNavItem">
|
<a href="/checkpoints" class="nav-item" id="checkpointsNavItem">
|
||||||
<i class="fas fa-check-circle"></i> Checkpoints
|
<i class="fas fa-check-circle"></i> Checkpoints
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/statistics" class="nav-item" id="statisticsNavItem">
|
||||||
|
<i class="fas fa-chart-bar"></i> Statistics
|
||||||
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Context-aware search container -->
|
<!-- Context-aware search container -->
|
||||||
<div class="header-search">
|
<div class="header-search" id="headerSearch">
|
||||||
<div class="search-container">
|
<div class="search-container">
|
||||||
<input type="text" id="searchInput" placeholder="Search..." />
|
<input type="text" id="searchInput" placeholder="Search..." />
|
||||||
<i class="fas fa-search search-icon"></i>
|
<i class="fas fa-search search-icon"></i>
|
||||||
@@ -153,6 +156,7 @@
|
|||||||
const lorasNavItem = document.getElementById('lorasNavItem');
|
const lorasNavItem = document.getElementById('lorasNavItem');
|
||||||
const recipesNavItem = document.getElementById('recipesNavItem');
|
const recipesNavItem = document.getElementById('recipesNavItem');
|
||||||
const checkpointsNavItem = document.getElementById('checkpointsNavItem');
|
const checkpointsNavItem = document.getElementById('checkpointsNavItem');
|
||||||
|
const statisticsNavItem = document.getElementById('statisticsNavItem');
|
||||||
|
|
||||||
if (currentPath === '/loras') {
|
if (currentPath === '/loras') {
|
||||||
lorasNavItem.classList.add('active');
|
lorasNavItem.classList.add('active');
|
||||||
@@ -160,6 +164,8 @@
|
|||||||
recipesNavItem.classList.add('active');
|
recipesNavItem.classList.add('active');
|
||||||
} else if (currentPath === '/checkpoints') {
|
} else if (currentPath === '/checkpoints') {
|
||||||
checkpointsNavItem.classList.add('active');
|
checkpointsNavItem.classList.add('active');
|
||||||
|
} else if (currentPath === '/statistics') {
|
||||||
|
statisticsNavItem.classList.add('active');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
195
templates/statistics.html
Normal file
195
templates/statistics.html
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Statistics - LoRA Manager{% endblock %}
|
||||||
|
{% block page_id %}statistics{% endblock %}
|
||||||
|
|
||||||
|
{% block preload %}
|
||||||
|
{% if not is_initializing %}
|
||||||
|
<link rel="preload" href="/loras_static/js/statistics.js" as="script" crossorigin="anonymous">
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block head_scripts %}
|
||||||
|
<!-- Add Chart.js for statistics page -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block init_title %}Initializing Statistics{% endblock %}
|
||||||
|
{% block init_message %}Loading model data and usage statistics. This may take a moment...{% endblock %}
|
||||||
|
{% block init_check_url %}/api/stats/collection-overview{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% if not is_initializing %}
|
||||||
|
<!-- Key Metrics Panel -->
|
||||||
|
<div class="metrics-panel">
|
||||||
|
<div class="metrics-grid" id="metricsGrid">
|
||||||
|
<!-- Metrics cards will be populated by JavaScript -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Dashboard Content -->
|
||||||
|
<div class="dashboard-content">
|
||||||
|
<!-- Navigation Tabs -->
|
||||||
|
<div class="dashboard-tabs">
|
||||||
|
<button class="tab-button active" data-tab="overview">
|
||||||
|
<i class="fas fa-chart-bar"></i> Overview
|
||||||
|
</button>
|
||||||
|
<button class="tab-button" data-tab="usage">
|
||||||
|
<i class="fas fa-chart-line"></i> Usage Analysis
|
||||||
|
</button>
|
||||||
|
<button class="tab-button" data-tab="collection">
|
||||||
|
<i class="fas fa-layer-group"></i> Collection
|
||||||
|
</button>
|
||||||
|
<button class="tab-button" data-tab="storage">
|
||||||
|
<i class="fas fa-hdd"></i> Storage
|
||||||
|
</button>
|
||||||
|
<button class="tab-button" data-tab="insights">
|
||||||
|
<i class="fas fa-lightbulb"></i> Insights
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Content Panels -->
|
||||||
|
<div class="tab-content">
|
||||||
|
<!-- Overview Tab -->
|
||||||
|
<div class="tab-panel active" id="overview-panel">
|
||||||
|
<div class="panel-grid">
|
||||||
|
<!-- Collection Overview Chart -->
|
||||||
|
<div class="chart-container">
|
||||||
|
<h3><i class="fas fa-pie-chart"></i> Collection Overview</h3>
|
||||||
|
<div class="chart-wrapper">
|
||||||
|
<canvas id="collectionPieChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Base Model Distribution -->
|
||||||
|
<div class="chart-container">
|
||||||
|
<h3><i class="fas fa-layer-group"></i> Base Model Distribution</h3>
|
||||||
|
<div class="chart-wrapper">
|
||||||
|
<canvas id="baseModelChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Usage Timeline -->
|
||||||
|
<div class="chart-container full-width">
|
||||||
|
<h3><i class="fas fa-chart-line"></i> Usage Trends (Last 30 Days)</h3>
|
||||||
|
<div class="chart-wrapper">
|
||||||
|
<canvas id="usageTimelineChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Usage Analysis Tab -->
|
||||||
|
<div class="tab-panel" id="usage-panel">
|
||||||
|
<div class="panel-grid">
|
||||||
|
<!-- Top Used LoRAs -->
|
||||||
|
<div class="list-container">
|
||||||
|
<h3><i class="fas fa-star"></i> Most Used LoRAs</h3>
|
||||||
|
<div class="model-list" id="topLorasList">
|
||||||
|
<!-- List will be populated by JavaScript -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Top Used Checkpoints -->
|
||||||
|
<div class="list-container">
|
||||||
|
<h3><i class="fas fa-check-circle"></i> Most Used Checkpoints</h3>
|
||||||
|
<div class="model-list" id="topCheckpointsList">
|
||||||
|
<!-- List will be populated by JavaScript -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Usage Distribution Chart -->
|
||||||
|
<div class="chart-container full-width">
|
||||||
|
<h3><i class="fas fa-chart-bar"></i> Usage Distribution</h3>
|
||||||
|
<div class="chart-wrapper">
|
||||||
|
<canvas id="usageDistributionChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Collection Tab -->
|
||||||
|
<div class="tab-panel" id="collection-panel">
|
||||||
|
<div class="panel-grid">
|
||||||
|
<!-- Tag Cloud -->
|
||||||
|
<div class="chart-container">
|
||||||
|
<h3><i class="fas fa-tags"></i> Popular Tags</h3>
|
||||||
|
<div class="tag-cloud" id="tagCloud">
|
||||||
|
<!-- Tag cloud will be populated by JavaScript -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Base Model Timeline -->
|
||||||
|
<div class="chart-container">
|
||||||
|
<h3><i class="fas fa-history"></i> Model Types</h3>
|
||||||
|
<div class="chart-wrapper">
|
||||||
|
<canvas id="modelTypesChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Collection Growth -->
|
||||||
|
<div class="chart-container full-width">
|
||||||
|
<h3><i class="fas fa-chart-area"></i> Collection Analysis</h3>
|
||||||
|
<div class="analysis-cards" id="collectionAnalysis">
|
||||||
|
<!-- Analysis cards will be populated by JavaScript -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Storage Tab -->
|
||||||
|
<div class="tab-panel" id="storage-panel">
|
||||||
|
<div class="panel-grid">
|
||||||
|
<!-- Storage by Model Type -->
|
||||||
|
<div class="chart-container">
|
||||||
|
<h3><i class="fas fa-database"></i> Storage Usage</h3>
|
||||||
|
<div class="chart-wrapper">
|
||||||
|
<canvas id="storageChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Largest Models -->
|
||||||
|
<div class="list-container">
|
||||||
|
<h3><i class="fas fa-weight-hanging"></i> Largest Models</h3>
|
||||||
|
<div class="model-list" id="largestModelsList">
|
||||||
|
<!-- List will be populated by JavaScript -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Storage Efficiency -->
|
||||||
|
<div class="chart-container full-width">
|
||||||
|
<h3><i class="fas fa-chart-scatter"></i> Storage vs Usage Efficiency</h3>
|
||||||
|
<div class="chart-wrapper">
|
||||||
|
<canvas id="storageEfficiencyChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Insights Tab -->
|
||||||
|
<div class="tab-panel" id="insights-panel">
|
||||||
|
<div class="insights-container">
|
||||||
|
<h3><i class="fas fa-lightbulb"></i> Smart Insights</h3>
|
||||||
|
<div class="insights-list" id="insightsList">
|
||||||
|
<!-- Insights will be populated by JavaScript -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recommendations -->
|
||||||
|
<div class="recommendations-section">
|
||||||
|
<h4><i class="fas fa-tasks"></i> Recommendations</h4>
|
||||||
|
<div class="recommendations-list" id="recommendationsList">
|
||||||
|
<!-- Recommendations will be populated by JavaScript -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block main_script %}
|
||||||
|
{% if not is_initializing %}
|
||||||
|
<script type="module" src="/loras_static/js/statistics.js"></script>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user