perf(webui): refactor dashboard modules and optimize API performance
This commit is contained in:
493
public/js/components/dashboard/charts.js
Normal file
493
public/js/components/dashboard/charts.js
Normal file
@@ -0,0 +1,493 @@
|
||||
/**
|
||||
* Dashboard Charts Module
|
||||
* Handles Chart.js visualizations (quota distribution & usage trend)
|
||||
*/
|
||||
window.DashboardCharts = window.DashboardCharts || {};
|
||||
|
||||
// Helper to get CSS variable values (alias to window.utils.getThemeColor)
|
||||
const getThemeColor = (name) => window.utils.getThemeColor(name);
|
||||
|
||||
// Color palette for different families and models
|
||||
const FAMILY_COLORS = {
|
||||
get claude() {
|
||||
return getThemeColor("--color-neon-purple");
|
||||
},
|
||||
get gemini() {
|
||||
return getThemeColor("--color-neon-green");
|
||||
},
|
||||
get other() {
|
||||
return getThemeColor("--color-neon-cyan");
|
||||
},
|
||||
};
|
||||
|
||||
const MODEL_COLORS = Array.from({ length: 16 }, (_, i) =>
|
||||
getThemeColor(`--color-chart-${i + 1}`)
|
||||
);
|
||||
|
||||
// Export constants for filter module
|
||||
window.DashboardConstants = { FAMILY_COLORS, MODEL_COLORS };
|
||||
|
||||
// Module-level lock to prevent concurrent chart updates (fixes race condition)
|
||||
let _trendChartUpdateLock = false;
|
||||
|
||||
/**
|
||||
* Convert hex color to rgba
|
||||
* @param {string} hex - Hex color string
|
||||
* @param {number} alpha - Alpha value (0-1)
|
||||
* @returns {string} rgba color string
|
||||
*/
|
||||
window.DashboardCharts.hexToRgba = function (hex, alpha) {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
if (result) {
|
||||
return `rgba(${parseInt(result[1], 16)}, ${parseInt(
|
||||
result[2],
|
||||
16
|
||||
)}, ${parseInt(result[3], 16)}, ${alpha})`;
|
||||
}
|
||||
return hex;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if canvas is ready for Chart creation
|
||||
* @param {HTMLCanvasElement} canvas - Canvas element
|
||||
* @returns {boolean} True if canvas is ready
|
||||
*/
|
||||
function isCanvasReady(canvas) {
|
||||
if (!canvas || !canvas.isConnected) return false;
|
||||
if (canvas.offsetWidth === 0 || canvas.offsetHeight === 0) return false;
|
||||
|
||||
try {
|
||||
const ctx = canvas.getContext("2d");
|
||||
return !!ctx;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Chart.js dataset with gradient fill
|
||||
* @param {string} label - Dataset label
|
||||
* @param {Array} data - Data points
|
||||
* @param {string} color - Line color
|
||||
* @param {HTMLCanvasElement} canvas - Canvas element
|
||||
* @returns {object} Chart.js dataset configuration
|
||||
*/
|
||||
window.DashboardCharts.createDataset = function (label, data, color, canvas) {
|
||||
let gradient;
|
||||
|
||||
try {
|
||||
// Safely create gradient with fallback
|
||||
if (canvas && canvas.getContext) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (ctx && ctx.createLinearGradient) {
|
||||
gradient = ctx.createLinearGradient(0, 0, 0, 200);
|
||||
gradient.addColorStop(0, window.DashboardCharts.hexToRgba(color, 0.12));
|
||||
gradient.addColorStop(
|
||||
0.6,
|
||||
window.DashboardCharts.hexToRgba(color, 0.05)
|
||||
);
|
||||
gradient.addColorStop(1, "rgba(0, 0, 0, 0)");
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to create gradient, using solid color fallback:", e);
|
||||
gradient = null;
|
||||
}
|
||||
|
||||
// Fallback to solid color if gradient creation failed
|
||||
const backgroundColor =
|
||||
gradient || window.DashboardCharts.hexToRgba(color, 0.08);
|
||||
|
||||
return {
|
||||
label,
|
||||
data,
|
||||
borderColor: color,
|
||||
backgroundColor: backgroundColor,
|
||||
borderWidth: 2.5,
|
||||
tension: 0.35,
|
||||
fill: true,
|
||||
pointRadius: 2.5,
|
||||
pointHoverRadius: 6,
|
||||
pointBackgroundColor: color,
|
||||
pointBorderColor: "rgba(9, 9, 11, 0.8)",
|
||||
pointBorderWidth: 1.5,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Update quota distribution donut chart
|
||||
* @param {object} component - Dashboard component instance
|
||||
*/
|
||||
window.DashboardCharts.updateCharts = function (component) {
|
||||
// Safely destroy existing chart instance FIRST
|
||||
if (component.charts.quotaDistribution) {
|
||||
try {
|
||||
component.charts.quotaDistribution.destroy();
|
||||
} catch (e) {
|
||||
console.error("Failed to destroy quota chart:", e);
|
||||
}
|
||||
component.charts.quotaDistribution = null;
|
||||
}
|
||||
|
||||
const canvas = document.getElementById("quotaChart");
|
||||
|
||||
// Safety checks
|
||||
if (!canvas) {
|
||||
console.warn("quotaChart canvas not found");
|
||||
return;
|
||||
}
|
||||
if (typeof Chart === "undefined") {
|
||||
console.warn("Chart.js not loaded");
|
||||
return;
|
||||
}
|
||||
if (!isCanvasReady(canvas)) {
|
||||
console.warn("quotaChart canvas not ready, skipping update");
|
||||
return;
|
||||
}
|
||||
|
||||
// Use UNFILTERED data for global health chart
|
||||
const rows = Alpine.store("data").getUnfilteredQuotaData();
|
||||
if (!rows || rows.length === 0) return;
|
||||
|
||||
const healthByFamily = {};
|
||||
let totalHealthSum = 0;
|
||||
let totalModelCount = 0;
|
||||
|
||||
rows.forEach((row) => {
|
||||
const family = row.family || "unknown";
|
||||
if (!healthByFamily[family]) {
|
||||
healthByFamily[family] = { total: 0, weighted: 0 };
|
||||
}
|
||||
|
||||
// Calculate average health from quotaInfo (each entry has { pct })
|
||||
// Health = average of all account quotas for this model
|
||||
const quotaInfo = row.quotaInfo || [];
|
||||
if (quotaInfo.length > 0) {
|
||||
const avgHealth = quotaInfo.reduce((sum, q) => sum + (q.pct || 0), 0) / quotaInfo.length;
|
||||
healthByFamily[family].total++;
|
||||
healthByFamily[family].weighted += avgHealth;
|
||||
totalHealthSum += avgHealth;
|
||||
totalModelCount++;
|
||||
}
|
||||
});
|
||||
|
||||
// Update overall health for dashboard display
|
||||
component.stats.overallHealth = totalModelCount > 0
|
||||
? Math.round(totalHealthSum / totalModelCount)
|
||||
: 0;
|
||||
|
||||
const familyColors = {
|
||||
claude: getThemeColor("--color-neon-purple"),
|
||||
gemini: getThemeColor("--color-neon-green"),
|
||||
unknown: getThemeColor("--color-neon-cyan"),
|
||||
};
|
||||
|
||||
const data = [];
|
||||
const colors = [];
|
||||
const labels = [];
|
||||
|
||||
const totalFamilies = Object.keys(healthByFamily).length;
|
||||
const segmentSize = 100 / totalFamilies;
|
||||
|
||||
Object.entries(healthByFamily).forEach(([family, { total, weighted }]) => {
|
||||
const health = weighted / total;
|
||||
const activeVal = (health / 100) * segmentSize;
|
||||
const inactiveVal = segmentSize - activeVal;
|
||||
|
||||
const familyColor = familyColors[family] || familyColors["unknown"];
|
||||
|
||||
// Get translation keys
|
||||
const store = Alpine.store("global");
|
||||
const familyKey =
|
||||
"family" + family.charAt(0).toUpperCase() + family.slice(1);
|
||||
const familyName = store.t(familyKey);
|
||||
|
||||
// Labels using translations if possible
|
||||
const activeLabel =
|
||||
family === "claude"
|
||||
? store.t("claudeActive")
|
||||
: family === "gemini"
|
||||
? store.t("geminiActive")
|
||||
: `${familyName} ${store.t("activeSuffix")}`;
|
||||
|
||||
const depletedLabel =
|
||||
family === "claude"
|
||||
? store.t("claudeEmpty")
|
||||
: family === "gemini"
|
||||
? store.t("geminiEmpty")
|
||||
: `${familyName} ${store.t("depleted")}`;
|
||||
|
||||
// Active segment
|
||||
data.push(activeVal);
|
||||
colors.push(familyColor);
|
||||
labels.push(activeLabel);
|
||||
|
||||
// Inactive segment
|
||||
data.push(inactiveVal);
|
||||
colors.push(window.DashboardCharts.hexToRgba(familyColor, 0.1));
|
||||
labels.push(depletedLabel);
|
||||
});
|
||||
|
||||
try {
|
||||
component.charts.quotaDistribution = new Chart(canvas, {
|
||||
type: "doughnut",
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [
|
||||
{
|
||||
data: data,
|
||||
backgroundColor: colors,
|
||||
borderColor: getThemeColor("--color-space-950"),
|
||||
borderWidth: 2,
|
||||
hoverOffset: 0,
|
||||
borderRadius: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: "85%",
|
||||
rotation: -90,
|
||||
circumference: 360,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: { enabled: false },
|
||||
title: { display: false },
|
||||
},
|
||||
animation: {
|
||||
animateScale: true,
|
||||
animateRotate: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to create quota chart:", e);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update usage trend line chart
|
||||
* @param {object} component - Dashboard component instance
|
||||
*/
|
||||
window.DashboardCharts.updateTrendChart = function (component) {
|
||||
// Prevent concurrent updates (fixes race condition on rapid toggling)
|
||||
if (_trendChartUpdateLock) {
|
||||
console.log("[updateTrendChart] Update already in progress, skipping");
|
||||
return;
|
||||
}
|
||||
_trendChartUpdateLock = true;
|
||||
|
||||
console.log("[updateTrendChart] Starting update...");
|
||||
|
||||
// Safely destroy existing chart instance FIRST
|
||||
if (component.charts.usageTrend) {
|
||||
console.log("[updateTrendChart] Destroying existing chart");
|
||||
try {
|
||||
// Stop all animations before destroying to prevent null context errors
|
||||
component.charts.usageTrend.stop();
|
||||
component.charts.usageTrend.destroy();
|
||||
} catch (e) {
|
||||
console.error("[updateTrendChart] Failed to destroy chart:", e);
|
||||
}
|
||||
component.charts.usageTrend = null;
|
||||
}
|
||||
|
||||
const canvas = document.getElementById("usageTrendChart");
|
||||
|
||||
// Safety checks
|
||||
if (!canvas) {
|
||||
console.error("[updateTrendChart] Canvas not found in DOM!");
|
||||
return;
|
||||
}
|
||||
if (typeof Chart === "undefined") {
|
||||
console.error("[updateTrendChart] Chart.js not loaded");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[updateTrendChart] Canvas element:", {
|
||||
exists: !!canvas,
|
||||
isConnected: canvas.isConnected,
|
||||
width: canvas.offsetWidth,
|
||||
height: canvas.offsetHeight,
|
||||
parentElement: canvas.parentElement?.tagName,
|
||||
});
|
||||
|
||||
if (!isCanvasReady(canvas)) {
|
||||
console.error("[updateTrendChart] Canvas not ready!", {
|
||||
isConnected: canvas.isConnected,
|
||||
width: canvas.offsetWidth,
|
||||
height: canvas.offsetHeight,
|
||||
});
|
||||
_trendChartUpdateLock = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear canvas to ensure clean state after destroy
|
||||
try {
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (ctx) {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[updateTrendChart] Failed to clear canvas:", e);
|
||||
}
|
||||
|
||||
console.log(
|
||||
"[updateTrendChart] Canvas is ready, proceeding with chart creation"
|
||||
);
|
||||
|
||||
const history = component.historyData;
|
||||
if (!history || Object.keys(history).length === 0) {
|
||||
console.warn("No history data available for trend chart");
|
||||
_trendChartUpdateLock = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const labels = [];
|
||||
const datasets = [];
|
||||
|
||||
if (component.displayMode === "family") {
|
||||
// Aggregate by family
|
||||
const dataByFamily = {};
|
||||
component.selectedFamilies.forEach((family) => {
|
||||
dataByFamily[family] = [];
|
||||
});
|
||||
|
||||
Object.entries(history).forEach(([iso, hourData]) => {
|
||||
const date = new Date(iso);
|
||||
labels.push(
|
||||
date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
);
|
||||
|
||||
component.selectedFamilies.forEach((family) => {
|
||||
const familyData = hourData[family];
|
||||
const count = familyData?._subtotal || 0;
|
||||
dataByFamily[family].push(count);
|
||||
});
|
||||
});
|
||||
|
||||
// Build datasets for families
|
||||
component.selectedFamilies.forEach((family) => {
|
||||
const color = window.DashboardFilters.getFamilyColor(family);
|
||||
const familyKey =
|
||||
"family" + family.charAt(0).toUpperCase() + family.slice(1);
|
||||
const label = Alpine.store("global").t(familyKey);
|
||||
datasets.push(
|
||||
window.DashboardCharts.createDataset(
|
||||
label,
|
||||
dataByFamily[family],
|
||||
color,
|
||||
canvas
|
||||
)
|
||||
);
|
||||
});
|
||||
} else {
|
||||
// Show individual models
|
||||
const dataByModel = {};
|
||||
|
||||
// Initialize data arrays
|
||||
component.families.forEach((family) => {
|
||||
(component.selectedModels[family] || []).forEach((model) => {
|
||||
const key = `${family}:${model}`;
|
||||
dataByModel[key] = [];
|
||||
});
|
||||
});
|
||||
|
||||
Object.entries(history).forEach(([iso, hourData]) => {
|
||||
const date = new Date(iso);
|
||||
labels.push(
|
||||
date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
);
|
||||
|
||||
component.families.forEach((family) => {
|
||||
const familyData = hourData[family] || {};
|
||||
(component.selectedModels[family] || []).forEach((model) => {
|
||||
const key = `${family}:${model}`;
|
||||
dataByModel[key].push(familyData[model] || 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Build datasets for models
|
||||
component.families.forEach((family) => {
|
||||
(component.selectedModels[family] || []).forEach((model, modelIndex) => {
|
||||
const key = `${family}:${model}`;
|
||||
const color = window.DashboardFilters.getModelColor(family, modelIndex);
|
||||
datasets.push(
|
||||
window.DashboardCharts.createDataset(
|
||||
model,
|
||||
dataByModel[key],
|
||||
color,
|
||||
canvas
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
component.charts.usageTrend = new Chart(canvas, {
|
||||
type: "line",
|
||||
data: { labels, datasets },
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: {
|
||||
duration: 300, // Reduced animation for faster updates
|
||||
},
|
||||
interaction: {
|
||||
mode: "index",
|
||||
intersect: false,
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor:
|
||||
getThemeColor("--color-space-950") || "rgba(24, 24, 27, 0.9)",
|
||||
titleColor: getThemeColor("--color-text-main"),
|
||||
bodyColor: getThemeColor("--color-text-bright"),
|
||||
borderColor: getThemeColor("--color-space-border"),
|
||||
borderWidth: 1,
|
||||
padding: 10,
|
||||
displayColors: true,
|
||||
callbacks: {
|
||||
label: function (context) {
|
||||
return context.dataset.label + ": " + context.parsed.y;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: true,
|
||||
grid: { display: false },
|
||||
ticks: {
|
||||
color: getThemeColor("--color-text-muted"),
|
||||
font: { size: 10 },
|
||||
},
|
||||
},
|
||||
y: {
|
||||
display: true,
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
display: true,
|
||||
color:
|
||||
getThemeColor("--color-space-border") + "1a" ||
|
||||
"rgba(255,255,255,0.05)",
|
||||
},
|
||||
ticks: {
|
||||
color: getThemeColor("--color-text-muted"),
|
||||
font: { size: 10 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to create trend chart:", e);
|
||||
} finally {
|
||||
// Always release lock
|
||||
_trendChartUpdateLock = false;
|
||||
}
|
||||
};
|
||||
275
public/js/components/dashboard/filters.js
Normal file
275
public/js/components/dashboard/filters.js
Normal file
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Dashboard Filters Module
|
||||
* Handles model/family filter selection and persistence
|
||||
*/
|
||||
window.DashboardFilters = window.DashboardFilters || {};
|
||||
|
||||
/**
|
||||
* Get initial filter state
|
||||
* @returns {object} Initial state for filter properties
|
||||
*/
|
||||
window.DashboardFilters.getInitialState = function() {
|
||||
return {
|
||||
displayMode: 'model',
|
||||
selectedFamilies: [],
|
||||
selectedModels: {},
|
||||
showModelFilter: false
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Load filter preferences from localStorage
|
||||
* @param {object} component - Dashboard component instance
|
||||
*/
|
||||
window.DashboardFilters.loadPreferences = function(component) {
|
||||
try {
|
||||
const saved = localStorage.getItem('dashboard_chart_prefs');
|
||||
if (saved) {
|
||||
const prefs = JSON.parse(saved);
|
||||
component.displayMode = prefs.displayMode || 'model';
|
||||
component.selectedFamilies = prefs.selectedFamilies || [];
|
||||
component.selectedModels = prefs.selectedModels || {};
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load dashboard preferences:', e);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Save filter preferences to localStorage
|
||||
* @param {object} component - Dashboard component instance
|
||||
*/
|
||||
window.DashboardFilters.savePreferences = function(component) {
|
||||
try {
|
||||
localStorage.setItem('dashboard_chart_prefs', JSON.stringify({
|
||||
displayMode: component.displayMode,
|
||||
selectedFamilies: component.selectedFamilies,
|
||||
selectedModels: component.selectedModels
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error('Failed to save dashboard preferences:', e);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Set display mode (family or model)
|
||||
* @param {object} component - Dashboard component instance
|
||||
* @param {string} mode - 'family' or 'model'
|
||||
*/
|
||||
window.DashboardFilters.setDisplayMode = function(component, mode) {
|
||||
component.displayMode = mode;
|
||||
window.DashboardFilters.savePreferences(component);
|
||||
// updateTrendChart uses debounce internally, call directly
|
||||
component.updateTrendChart();
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle family selection
|
||||
* @param {object} component - Dashboard component instance
|
||||
* @param {string} family - Family name (e.g., 'claude', 'gemini')
|
||||
*/
|
||||
window.DashboardFilters.toggleFamily = function(component, family) {
|
||||
const index = component.selectedFamilies.indexOf(family);
|
||||
if (index > -1) {
|
||||
component.selectedFamilies.splice(index, 1);
|
||||
} else {
|
||||
component.selectedFamilies.push(family);
|
||||
}
|
||||
window.DashboardFilters.savePreferences(component);
|
||||
// updateTrendChart uses debounce internally, call directly
|
||||
component.updateTrendChart();
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle model selection within a family
|
||||
* @param {object} component - Dashboard component instance
|
||||
* @param {string} family - Family name
|
||||
* @param {string} model - Model name
|
||||
*/
|
||||
window.DashboardFilters.toggleModel = function(component, family, model) {
|
||||
if (!component.selectedModels[family]) {
|
||||
component.selectedModels[family] = [];
|
||||
}
|
||||
const index = component.selectedModels[family].indexOf(model);
|
||||
if (index > -1) {
|
||||
component.selectedModels[family].splice(index, 1);
|
||||
} else {
|
||||
component.selectedModels[family].push(model);
|
||||
}
|
||||
window.DashboardFilters.savePreferences(component);
|
||||
// updateTrendChart uses debounce internally, call directly
|
||||
component.updateTrendChart();
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if family is selected
|
||||
* @param {object} component - Dashboard component instance
|
||||
* @param {string} family - Family name
|
||||
* @returns {boolean}
|
||||
*/
|
||||
window.DashboardFilters.isFamilySelected = function(component, family) {
|
||||
return component.selectedFamilies.includes(family);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if model is selected
|
||||
* @param {object} component - Dashboard component instance
|
||||
* @param {string} family - Family name
|
||||
* @param {string} model - Model name
|
||||
* @returns {boolean}
|
||||
*/
|
||||
window.DashboardFilters.isModelSelected = function(component, family, model) {
|
||||
return component.selectedModels[family]?.includes(model) || false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Select all families and models
|
||||
* @param {object} component - Dashboard component instance
|
||||
*/
|
||||
window.DashboardFilters.selectAll = function(component) {
|
||||
component.selectedFamilies = [...component.families];
|
||||
component.families.forEach(family => {
|
||||
component.selectedModels[family] = [...(component.modelTree[family] || [])];
|
||||
});
|
||||
window.DashboardFilters.savePreferences(component);
|
||||
// updateTrendChart uses debounce internally, call directly
|
||||
component.updateTrendChart();
|
||||
};
|
||||
|
||||
/**
|
||||
* Deselect all families and models
|
||||
* @param {object} component - Dashboard component instance
|
||||
*/
|
||||
window.DashboardFilters.deselectAll = function(component) {
|
||||
component.selectedFamilies = [];
|
||||
component.selectedModels = {};
|
||||
window.DashboardFilters.savePreferences(component);
|
||||
// updateTrendChart uses debounce internally, call directly
|
||||
component.updateTrendChart();
|
||||
};
|
||||
|
||||
/**
|
||||
* Get color for a family
|
||||
* @param {string} family - Family name
|
||||
* @returns {string} Color value
|
||||
*/
|
||||
window.DashboardFilters.getFamilyColor = function(family) {
|
||||
const FAMILY_COLORS = window.DashboardConstants?.FAMILY_COLORS || {};
|
||||
return FAMILY_COLORS[family] || FAMILY_COLORS.other;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get color for a model (with index for variation within family)
|
||||
* @param {string} family - Family name
|
||||
* @param {number} modelIndex - Index of model within family
|
||||
* @returns {string} Color value
|
||||
*/
|
||||
window.DashboardFilters.getModelColor = function(family, modelIndex) {
|
||||
const MODEL_COLORS = window.DashboardConstants?.MODEL_COLORS || [];
|
||||
const baseIndex = family === 'claude' ? 0 : (family === 'gemini' ? 4 : 8);
|
||||
return MODEL_COLORS[(baseIndex + modelIndex) % MODEL_COLORS.length];
|
||||
};
|
||||
|
||||
/**
|
||||
* Get count of selected items for display
|
||||
* @param {object} component - Dashboard component instance
|
||||
* @returns {string} Selected count string (e.g., "3/5")
|
||||
*/
|
||||
window.DashboardFilters.getSelectedCount = function(component) {
|
||||
if (component.displayMode === 'family') {
|
||||
return `${component.selectedFamilies.length}/${component.families.length}`;
|
||||
}
|
||||
let selected = 0, total = 0;
|
||||
component.families.forEach(family => {
|
||||
const models = component.modelTree[family] || [];
|
||||
total += models.length;
|
||||
selected += (component.selectedModels[family] || []).length;
|
||||
});
|
||||
return `${selected}/${total}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Auto-select new families/models that haven't been configured
|
||||
* @param {object} component - Dashboard component instance
|
||||
*/
|
||||
window.DashboardFilters.autoSelectNew = function(component) {
|
||||
// If no preferences saved, select all
|
||||
if (component.selectedFamilies.length === 0 && Object.keys(component.selectedModels).length === 0) {
|
||||
component.selectedFamilies = [...component.families];
|
||||
component.families.forEach(family => {
|
||||
component.selectedModels[family] = [...(component.modelTree[family] || [])];
|
||||
});
|
||||
window.DashboardFilters.savePreferences(component);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add new families/models that appeared
|
||||
component.families.forEach(family => {
|
||||
if (!component.selectedFamilies.includes(family)) {
|
||||
component.selectedFamilies.push(family);
|
||||
}
|
||||
if (!component.selectedModels[family]) {
|
||||
component.selectedModels[family] = [];
|
||||
}
|
||||
(component.modelTree[family] || []).forEach(model => {
|
||||
if (!component.selectedModels[family].includes(model)) {
|
||||
component.selectedModels[family].push(model);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Auto-select top N models by usage (past 24 hours)
|
||||
* @param {object} component - Dashboard component instance
|
||||
* @param {number} n - Number of models to select (default: 5)
|
||||
*/
|
||||
window.DashboardFilters.autoSelectTopN = function(component, n = 5) {
|
||||
// Calculate usage for each model over past 24 hours
|
||||
const usage = {};
|
||||
const now = Date.now();
|
||||
const dayAgo = now - 24 * 60 * 60 * 1000;
|
||||
|
||||
Object.entries(component.historyData).forEach(([iso, hourData]) => {
|
||||
const timestamp = new Date(iso).getTime();
|
||||
if (timestamp < dayAgo) return;
|
||||
|
||||
Object.entries(hourData).forEach(([family, familyData]) => {
|
||||
if (typeof familyData === 'object' && family !== '_total') {
|
||||
Object.entries(familyData).forEach(([model, count]) => {
|
||||
if (model !== '_subtotal') {
|
||||
const key = `${family}:${model}`;
|
||||
usage[key] = (usage[key] || 0) + count;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Sort by usage and take top N
|
||||
const sorted = Object.entries(usage)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, n);
|
||||
|
||||
// Clear current selection
|
||||
component.selectedFamilies = [];
|
||||
component.selectedModels = {};
|
||||
|
||||
// Select top models and their families
|
||||
sorted.forEach(([key, _]) => {
|
||||
const [family, model] = key.split(':');
|
||||
if (!component.selectedFamilies.includes(family)) {
|
||||
component.selectedFamilies.push(family);
|
||||
}
|
||||
if (!component.selectedModels[family]) {
|
||||
component.selectedModels[family] = [];
|
||||
}
|
||||
if (!component.selectedModels[family].includes(model)) {
|
||||
component.selectedModels[family].push(model);
|
||||
}
|
||||
});
|
||||
|
||||
window.DashboardFilters.savePreferences(component);
|
||||
// updateTrendChart uses debounce internally, call directly
|
||||
component.updateTrendChart();
|
||||
};
|
||||
43
public/js/components/dashboard/stats.js
Normal file
43
public/js/components/dashboard/stats.js
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Dashboard Stats Module
|
||||
* Handles account statistics calculation
|
||||
*/
|
||||
window.DashboardStats = window.DashboardStats || {};
|
||||
|
||||
/**
|
||||
* Update account statistics (active, limited, total)
|
||||
* @param {object} component - Dashboard component instance
|
||||
*/
|
||||
window.DashboardStats.updateStats = function(component) {
|
||||
const accounts = Alpine.store('data').accounts;
|
||||
let active = 0, limited = 0;
|
||||
|
||||
const isCore = (id) => /sonnet|opus|pro|flash/i.test(id);
|
||||
|
||||
// Only count enabled accounts in statistics
|
||||
const enabledAccounts = accounts.filter(acc => acc.enabled !== false);
|
||||
|
||||
enabledAccounts.forEach(acc => {
|
||||
if (acc.status === 'ok') {
|
||||
const limits = Object.entries(acc.limits || {});
|
||||
let hasActiveCore = limits.some(([id, l]) => l && l.remainingFraction > 0.05 && isCore(id));
|
||||
|
||||
if (!hasActiveCore) {
|
||||
const hasAnyCore = limits.some(([id]) => isCore(id));
|
||||
if (!hasAnyCore) {
|
||||
hasActiveCore = limits.some(([_, l]) => l && l.remainingFraction > 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasActiveCore) active++; else limited++;
|
||||
} else {
|
||||
limited++;
|
||||
}
|
||||
});
|
||||
|
||||
// TOTAL shows only enabled accounts
|
||||
// Disabled accounts are excluded from all statistics
|
||||
component.stats.total = enabledAccounts.length;
|
||||
component.stats.active = active;
|
||||
component.stats.limited = limited;
|
||||
};
|
||||
Reference in New Issue
Block a user