Merge pull request #252 from willmiao/stats-page

Add statistics page with metrics, charts, and insights functionality
This commit is contained in:
pixelpaws
2025-06-24 21:37:06 +08:00
committed by GitHub
11 changed files with 1915 additions and 11 deletions

View File

@@ -10,6 +10,7 @@ from .routes.lora_routes import LoraRoutes
from .routes.api_routes import ApiRoutes
from .routes.recipe_routes import RecipeRoutes
from .routes.checkpoints_routes import CheckpointsRoutes
from .routes.stats_routes import StatsRoutes
from .routes.update_routes import UpdateRoutes
from .routes.misc_routes import MiscRoutes
from .routes.example_images_routes import ExampleImagesRoutes
@@ -112,10 +113,12 @@ class LoraManager:
# Setup feature routes
lora_routes = LoraRoutes()
checkpoints_routes = CheckpointsRoutes()
stats_routes = StatsRoutes()
# Initialize routes
lora_routes.setup_routes(app)
checkpoints_routes.setup_routes(app)
stats_routes.setup_routes(app) # Add statistics routes
ApiRoutes.setup_routes(app)
RecipeRoutes.setup_routes(app)
UpdateRoutes.setup_routes(app)

438
py/routes/stats_routes.py Normal file
View 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()

View File

@@ -301,13 +301,16 @@ class StandaloneLoraManager(LoraManager):
from py.routes.update_routes import UpdateRoutes
from py.routes.misc_routes import MiscRoutes
from py.routes.example_images_routes import ExampleImagesRoutes
from py.routes.stats_routes import StatsRoutes
lora_routes = LoraRoutes()
checkpoints_routes = CheckpointsRoutes()
stats_routes = StatsRoutes()
# Initialize routes
lora_routes.setup_routes(app)
checkpoints_routes.setup_routes(app)
stats_routes.setup_routes(app)
ApiRoutes.setup_routes(app)
RecipeRoutes.setup_routes(app)
UpdateRoutes.setup_routes(app)

View 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);
}

View File

@@ -30,6 +30,7 @@
@import 'components/alphabet-bar.css'; /* Add alphabet bar component */
@import 'components/duplicates.css'; /* Add duplicates component */
@import 'components/keyboard-nav.css'; /* Add keyboard navigation component */
@import 'components/statistics.css'; /* Add statistics component */
.initialization-notice {
display: flex;

View File

@@ -26,6 +26,7 @@ export class HeaderManager {
const path = window.location.pathname;
if (path.includes('/loras/recipes')) return 'recipes';
if (path.includes('/checkpoints')) return 'checkpoints';
if (path.includes('/statistics')) return 'statistics';
if (path.includes('/loras')) return 'loras';
return 'unknown';
}
@@ -121,14 +122,17 @@ export class HeaderManager {
});
}
// Handle help toggle
// const helpToggle = document.querySelector('.help-toggle');
// if (helpToggle) {
// helpToggle.addEventListener('click', () => {
// if (window.modalManager) {
// window.modalManager.toggleModal('helpModal');
// }
// });
// }
// Hide search functionality on Statistics page
this.updateHeaderForPage();
}
updateHeaderForPage() {
const headerSearch = document.getElementById('headerSearch');
if (this.currentPage === 'statistics' && headerSearch) {
headerSearch.style.display = 'none';
} else if (headerSearch) {
headerSearch.style.display = 'flex';
}
}
}

View File

@@ -148,6 +148,8 @@ export class FilterManager {
apiEndpoint = '/api/recipes/base-models';
} else if (this.currentPage === 'checkpoints') {
apiEndpoint = '/api/checkpoints/base-models';
} else {
return;
}
// Fetch base models

730
static/js/statistics.js Normal file
View 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();
}
});

View File

@@ -40,7 +40,8 @@ export const apiRoutes = {
export const pageRoutes = {
loras: '/loras',
recipes: '/loras/recipes',
checkpoints: '/checkpoints'
checkpoints: '/checkpoints',
statistics: '/statistics'
};
// Helper function to get current page type
@@ -48,6 +49,7 @@ export function getCurrentPageType() {
const path = window.location.pathname;
if (path.includes('/loras/recipes')) return 'recipes';
if (path.includes('/checkpoints')) return 'checkpoints';
if (path.includes('/statistics')) return 'statistics';
if (path.includes('/loras')) return 'loras';
return 'unknown';
}

View File

@@ -16,10 +16,13 @@
<a href="/checkpoints" class="nav-item" id="checkpointsNavItem">
<i class="fas fa-check-circle"></i> Checkpoints
</a>
<a href="/statistics" class="nav-item" id="statisticsNavItem">
<i class="fas fa-chart-bar"></i> Statistics
</a>
</nav>
<!-- Context-aware search container -->
<div class="header-search">
<div class="header-search" id="headerSearch">
<div class="search-container">
<input type="text" id="searchInput" placeholder="Search..." />
<i class="fas fa-search search-icon"></i>
@@ -153,6 +156,7 @@
const lorasNavItem = document.getElementById('lorasNavItem');
const recipesNavItem = document.getElementById('recipesNavItem');
const checkpointsNavItem = document.getElementById('checkpointsNavItem');
const statisticsNavItem = document.getElementById('statisticsNavItem');
if (currentPath === '/loras') {
lorasNavItem.classList.add('active');
@@ -160,6 +164,8 @@
recipesNavItem.classList.add('active');
} else if (currentPath === '/checkpoints') {
checkpointsNavItem.classList.add('active');
} else if (currentPath === '/statistics') {
statisticsNavItem.classList.add('active');
}
});
</script>

195
templates/statistics.html Normal file
View 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 %}