feat: add backup calendar heatmap and enhanced backup visualization

- Add comprehensive backup calendar heatmap with monthly view
- Implement interactive calendar filtering linked to backup table
- Add backup summary cards for quick overview statistics
- Enhance backup table with click-to-filter calendar functionality
- Improve PBS and backup data visualization
- Add responsive design for mobile and desktop views
- Include backup type indicators and failure detection
- Persistent filter state across API updates

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
courtmanr@gmail.com
2025-05-29 00:06:03 +01:00
parent 9f0403df55
commit 8de0d4780c
5 changed files with 1808 additions and 44 deletions
+32 -3
View File
@@ -717,6 +717,33 @@
</div>
<div id="backups" class="tab-content hidden bg-white dark:bg-gray-800 rounded-b rounded-tr shadow p-3 mb-2">
<p id="backups-loading-message" class="text-gray-500 dark:text-gray-400 p-4 text-center mb-2">Loading backup status overview...</p>
<!-- Consolidated Backup Summary -->
<div id="backup-summary-container" class="hidden mb-4">
<!-- Consolidated backup summary will be inserted here -->
</div>
<!-- Hidden: Node backup cards (replaced by consolidated summary) -->
<div id="node-backup-cards" class="hidden mb-3">
<!-- No longer used - replaced by consolidated summary -->
</div>
<!-- Backup History Visualization -->
<div id="backup-visualization-section" class="hidden mb-6 space-y-4">
<!-- Hidden: Summary cards (replaced by consolidated summary) -->
<div id="backup-summary-cards-container" class="hidden">
<!-- No longer used - replaced by consolidated summary -->
</div>
<div id="backup-calendar-container" class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-300">Backup History Calendar</h3>
</div>
<div id="backup-calendar-heatmap">
<!-- Calendar heatmap will be inserted here -->
</div>
</div>
</div>
<div class="backups-filter flex flex-col md:flex-row justify-between items-stretch md:items-center gap-3 mb-3 p-2 bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-700 rounded">
<div class="dashboard-filter-controls flex-grow flex items-center gap-2">
<input type="text" id="backups-search" placeholder="Search Name, ID, Node (use ',' for OR)" class="flex-grow min-w-[200px] max-w-full p-1 px-2 h-7 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 focus:ring-1 focus:ring-blue-500 focus:border-blue-500 outline-none" />
@@ -808,13 +835,13 @@
<span id="backups-status-text" class="text-xs text-gray-500 dark:text-gray-400">Loading backup status...</span>
<div class="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-3">
<span class="flex items-center gap-1">
<span class="w-2 h-2 bg-purple-500 rounded-full"></span>PBS
<span class="w-2 h-2 bg-green-500 rounded-full"></span>PBS
</span>
<span class="flex items-center gap-1">
<span class="w-2 h-2 bg-orange-500 rounded-full"></span>PVE
<span class="w-2 h-2 bg-yellow-400 rounded-full"></span>PVE
</span>
<span class="flex items-center gap-1">
<span class="w-2 h-2 bg-blue-500 rounded-full"></span>Snapshots
<span class="w-2 h-2 bg-blue-400 rounded-full"></span>Snapshots
</span>
<span class="mx-2"></span>
<span class="flex items-center gap-1">
@@ -920,6 +947,8 @@
<script src="/js/ui/dashboard.js" defer></script>
<script src="/js/ui/storage.js" defer></script>
<script src="/js/ui/pbs.js" defer></script>
<script src="/js/ui/backup-summary-cards.js" defer></script>
<script src="/js/ui/calendar-heatmap.js" defer></script>
<script src="/js/ui/backups.js" defer></script>
<script src="/js/alertsHandler.js"></script>
<script src="/js/hotReload.js" defer></script>
+77
View File
@@ -0,0 +1,77 @@
PulseApp.ui = PulseApp.ui || {};
PulseApp.ui.backupSummaryCards = (() => {
function calculateBackupStatistics(backupData, guestId) {
const now = Date.now();
const stats = {
lastBackup: { time: null, type: null, status: 'none' },
coverage: { daily: 0, weekly: 0, monthly: 0 },
protected: { count: 0, oldestDate: null, coverage: 0 },
health: { score: 0, issues: [] }
};
// Find most recent backup across all types
const allBackups = [];
['pbsSnapshots', 'pveBackups', 'vmSnapshots'].forEach(type => {
if (!backupData[type]) return;
const items = guestId
? backupData[type].filter(item => item.vmid == guestId)
: backupData[type];
items.forEach(item => {
const timestamp = item.ctime || item.snaptime || item['backup-time'];
if (timestamp) {
allBackups.push({
time: timestamp * 1000,
type: type,
protected: item.protected || false,
verification: item.verification
});
}
});
});
// Sort by time descending
allBackups.sort((a, b) => b.time - a.time);
// Last backup info
if (allBackups.length > 0) {
const last = allBackups[0];
stats.lastBackup = {
time: last.time,
type: last.type,
status: last.verification?.state === 'failed' ? 'failed' : 'success',
age: now - last.time
};
}
// Calculate coverage (how many backups in each period)
const oneDayAgo = now - 24 * 60 * 60 * 1000;
const oneWeekAgo = now - 7 * 24 * 60 * 60 * 1000;
const oneMonthAgo = now - 30 * 24 * 60 * 60 * 1000;
allBackups.forEach(backup => {
if (backup.time >= oneDayAgo) stats.coverage.daily++;
if (backup.time >= oneWeekAgo) stats.coverage.weekly++;
if (backup.time >= oneMonthAgo) stats.coverage.monthly++;
});
// Protected backups analysis
const protectedBackups = allBackups.filter(b => b.protected);
stats.protected.count = protectedBackups.length;
if (protectedBackups.length > 0) {
const oldestProtected = protectedBackups[protectedBackups.length - 1];
stats.protected.oldestDate = oldestProtected.time;
stats.protected.coverage = Math.floor((now - oldestProtected.time) / (24 * 60 * 60 * 1000));
}
return stats;
}
return {
calculateBackupStatistics
};
})();
+786 -40
View File
@@ -68,6 +68,259 @@ PulseApp.ui.backups = (() => {
_initSnapshotModal();
}
function calculateBackupSummary(backupStatusByGuest) {
let totalGuests = backupStatusByGuest.length;
let healthyCount = 0;
let warningCount = 0;
let errorCount = 0;
let noneCount = 0;
let totalPbsBackups = 0;
let totalPveBackups = 0;
let totalSnapshots = 0;
backupStatusByGuest.forEach(guest => {
switch (guest.backupHealthStatus) {
case 'ok':
case 'stale':
healthyCount++;
break;
case 'old':
warningCount++;
break;
case 'failed':
errorCount++;
break;
case 'none':
noneCount++;
break;
}
totalPbsBackups += guest.pbsBackups || 0;
totalPveBackups += guest.pveBackups || 0;
totalSnapshots += guest.snapshotCount || 0;
});
return {
totalGuests,
healthyCount,
warningCount,
errorCount,
noneCount,
totalPbsBackups,
totalPveBackups,
totalSnapshots,
healthyPercent: totalGuests > 0 ? (healthyCount / totalGuests) * 100 : 0
};
}
function createConsolidatedBackupSummary(summary, backupData, backupStatusByGuest) {
// Calculate additional stats for consolidated view
const stats = PulseApp.ui.backupSummaryCards ?
PulseApp.ui.backupSummaryCards.calculateBackupStatistics(backupData) :
{ lastBackup: { time: null }, coverage: { daily: 0, weekly: 0, monthly: 0 }, protected: { count: 0 } };
// For backup health, reverse the color logic - higher percentage should be green
const healthColorClass = summary.healthyPercent >= 80 ? 'text-green-600' :
summary.healthyPercent >= 60 ? 'text-yellow-600' : 'text-red-600';
const progressColor = summary.healthyPercent >= 80 ? 'green' :
summary.healthyPercent >= 60 ? 'yellow' : 'red';
// Format last backup time
const lastBackupText = stats.lastBackup.time ?
formatTimeAgo(Date.now() - stats.lastBackup.time) :
'No backups';
const lastBackupClass = stats.lastBackup.time ? 'text-gray-900 dark:text-gray-100' : 'text-red-600 dark:text-red-400';
// Determine coverage status
let coverageStatus = 'Good';
let coverageClass = 'text-green-600 dark:text-green-400';
if (stats.coverage.daily === 0) {
coverageStatus = 'Poor';
coverageClass = 'text-red-600 dark:text-red-400';
} else if (stats.coverage.weekly < 3) {
coverageStatus = 'Fair';
coverageClass = 'text-yellow-600 dark:text-yellow-400';
}
return `
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 mb-4">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<!-- Overview Section -->
<div class="lg:col-span-1">
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Backup Overview</h3>
<div class="space-y-2 text-sm">
<div class="flex justify-between items-center">
<span class="text-gray-500 dark:text-gray-400">Total Guests:</span>
<span class="font-medium text-gray-900 dark:text-gray-100">${summary.totalGuests}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-500 dark:text-gray-400">Last Backup:</span>
<span class="font-medium ${lastBackupClass}">${lastBackupText}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-500 dark:text-gray-400">Coverage:</span>
<span class="font-medium ${coverageClass}">${coverageStatus}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-500 dark:text-gray-400">Protected:</span>
<span class="font-medium text-gray-900 dark:text-gray-100">${stats.protected.count}</span>
</div>
</div>
</div>
<!-- Backup Types Section -->
<div class="lg:col-span-1">
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Backup Types</h3>
<div class="space-y-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="inline-block w-2 h-2 bg-blue-500 rounded-full"></span>
<span class="text-sm text-gray-600 dark:text-gray-400">Snapshots</span>
</div>
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">${summary.totalSnapshots}</span>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="inline-block w-2 h-2 bg-yellow-500 rounded-full"></span>
<span class="text-sm text-gray-600 dark:text-gray-400">PVE Backups</span>
</div>
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">${summary.totalPveBackups}</span>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="inline-block w-2 h-2 bg-green-500 rounded-full"></span>
<span class="text-sm text-gray-600 dark:text-gray-400">PBS Backups</span>
</div>
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">${summary.totalPbsBackups}</span>
</div>
</div>
</div>
<!-- Health Status Section -->
<div class="lg:col-span-1">
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Health Status</h3>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400">Overall Health</span>
<span class="text-sm font-medium ${healthColorClass}">${summary.healthyPercent.toFixed(0)}%</span>
</div>
${PulseApp.utils.createProgressTextBarHTML(summary.healthyPercent, '', progressColor, '')}
<div class="grid grid-cols-2 gap-2 text-xs mt-2">
${summary.healthyCount > 0 ? `<span class="text-green-600 dark:text-green-400">● ${summary.healthyCount} healthy</span>` : ''}
${summary.warningCount > 0 ? `<span class="text-yellow-600 dark:text-yellow-400">● ${summary.warningCount} warning</span>` : ''}
${summary.errorCount > 0 ? `<span class="text-red-600 dark:text-red-400">● ${summary.errorCount} failed</span>` : ''}
${summary.noneCount > 0 ? `<span class="text-gray-600 dark:text-gray-400">● ${summary.noneCount} none</span>` : ''}
</div>
</div>
</div>
</div>
</div>
`;
}
function formatTimeAgo(milliseconds) {
const seconds = Math.floor(milliseconds / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days}d ago`;
if (hours > 0) return `${hours}h ago`;
if (minutes > 0) return `${minutes}m ago`;
return 'Just now';
}
function createNodeBackupSummaryCard(nodeName, guestStatuses) {
const card = document.createElement('div');
card.className = 'bg-white dark:bg-gray-800 shadow-md rounded-lg p-2 border border-gray-200 dark:border-gray-700 flex flex-col gap-1';
let healthyCount = 0;
let warningCount = 0;
let errorCount = 0;
let noneCount = 0;
let pbsTotal = 0;
let pveTotal = 0;
let snapshotTotal = 0;
guestStatuses.forEach(guest => {
switch (guest.backupHealthStatus) {
case 'ok':
case 'stale':
healthyCount++;
break;
case 'old':
warningCount++;
break;
case 'failed':
errorCount++;
break;
case 'none':
noneCount++;
break;
}
pbsTotal += guest.pbsBackups || 0;
pveTotal += guest.pveBackups || 0;
snapshotTotal += guest.snapshotCount || 0;
});
const totalGuests = guestStatuses.length;
const healthyPercent = totalGuests > 0 ? (healthyCount / totalGuests) * 100 : 0;
// Sort guests by backup health (worst first for visibility)
const sortedGuests = [...guestStatuses].sort((a, b) => {
const priority = { 'failed': 0, 'none': 1, 'old': 2, 'stale': 3, 'ok': 4 };
return priority[a.backupHealthStatus] - priority[b.backupHealthStatus];
}); // Show all guests
card.innerHTML = `
<div class="flex justify-between items-center">
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200 truncate">${nodeName}</h3>
<span class="text-xs text-gray-500 dark:text-gray-400">${totalGuests} guest${totalGuests > 1 ? 's' : ''}</span>
</div>
<div class="flex items-center gap-2 text-[10px] text-gray-600 dark:text-gray-400">
<div class="flex items-center gap-1">
<div class="w-2 h-2 bg-blue-500 rounded-sm"></div>
<span>${snapshotTotal}</span>
</div>
<div class="flex items-center gap-1">
<div class="w-2 h-2 bg-yellow-500 rounded-sm"></div>
<span>${pveTotal}</span>
</div>
<div class="flex items-center gap-1">
<div class="w-2 h-2 bg-green-500 rounded-sm"></div>
<span>${pbsTotal}</span>
</div>
</div>
${sortedGuests.map(guest => {
const statusColor = {
'ok': 'text-green-600 dark:text-green-400',
'stale': 'text-green-600 dark:text-green-400',
'old': 'text-yellow-600 dark:text-yellow-400',
'failed': 'text-red-600 dark:text-red-400',
'none': 'text-gray-600 dark:text-gray-400'
}[guest.backupHealthStatus] || 'text-gray-600 dark:text-gray-400';
const statusIcon = {
'ok': '●',
'stale': '●',
'old': '●',
'failed': '●',
'none': '○'
}[guest.backupHealthStatus] || '○';
return `
<div class="text-[10px] text-gray-600 dark:text-gray-400 flex items-center gap-1">
<span class="${statusColor}">${statusIcon}</span>
<span class="truncate flex-1">${guest.guestName}</span>
<span class="text-[9px]">${guest.guestId}</span>
</div>
`;
}).join('')}
`;
return card;
}
function _getInitialBackupData() {
const vmsData = PulseApp.state.get('vmsData') || [];
const containersData = PulseApp.state.get('containersData') || [];
@@ -178,10 +431,7 @@ PulseApp.ui.backups = (() => {
.filter(snap => parseInt(snap.vmid, 10) === parseInt(guest.vmid, 10))
.length;
// Debug logging for troubleshooting
if (guest.vmid === 100 || allSnapshots.some(s => s.vmid == 100)) {
console.log(`[Backups Debug] Guest ${guest.vmid}: Found ${guestSnapshotCount} snapshots out of ${allSnapshots.length} total`);
}
// Debug disabled
// Use pre-filtered data instead of filtering large arrays
const totalBackups = guestSnapshots ? guestSnapshots.length : 0;
@@ -219,9 +469,10 @@ PulseApp.ui.backups = (() => {
displayTimestamp = null;
}
// Enhanced 7-day backup status calculation with detailed activity info
// Enhanced 7-day backup status calculation with backup type tracking
const last7DaysBackupStatus = dayBoundaries.map((day, index) => {
let dailyStatus = 'none';
let backupTypes = new Set();
let hasFailures = false;
let activityDetails = [];
// Check tasks for this day - using pre-filtered guest tasks
@@ -233,42 +484,51 @@ PulseApp.ui.backups = (() => {
task.startTime >= day.start && task.startTime < day.end && task.status === 'OK'
);
// Add task details
// Track successful backup types
successfulTasksOnThisDay.forEach(task => {
const source = task.source === 'pbs' ? 'PBS' : 'PVE';
const location = task.source === 'pbs' ? task.pbsInstanceName : 'Local';
backupTypes.add(task.source);
activityDetails.push(`${source} backup${location ? ` (${location})` : ''}`);
});
// Track failed backup attempts
failedTasksOnThisDay.forEach(task => {
const source = task.source === 'pbs' ? 'PBS' : 'PVE';
const location = task.source === 'pbs' ? task.pbsInstanceName : 'Local';
hasFailures = true;
activityDetails.push(`${source} backup failed${location ? ` (${location})` : ''}`);
});
if (failedTasksOnThisDay.length > 0) {
dailyStatus = 'failed';
} else if (successfulTasksOnThisDay.length > 0) {
dailyStatus = 'ok';
}
}
// Check for backup storage activity (snapshots/backups created)
if (guestSnapshots && dailyStatus === 'none') {
if (guestSnapshots) {
const snapshotsOnThisDay = guestSnapshots.filter(
snap => snap['backup-time'] >= day.start && snap['backup-time'] < day.end
);
if (snapshotsOnThisDay.length > 0) {
snapshotsOnThisDay.forEach(snap => {
if (snap.source === 'pbs') {
activityDetails.push(`✓ PBS backup stored (${snap.pbsInstanceName})`);
} else if (snap.source === 'pve') {
activityDetails.push(`✓ PVE backup stored (${snap.storage || 'Local'})`);
}
});
dailyStatus = 'ok';
}
snapshotsOnThisDay.forEach(snap => {
if (snap.source === 'pbs') {
backupTypes.add('pbs');
activityDetails.push(`✓ PBS backup stored (${snap.pbsInstanceName})`);
} else if (snap.source === 'pve') {
backupTypes.add('pve');
activityDetails.push(`✓ PVE backup stored (${snap.storage || 'Local'})`);
}
});
}
// Check for VM/CT snapshots on this day (if we have that data)
const pveBackups = PulseApp.state.get('pveBackups') || {};
const allSnapshots = pveBackups.guestSnapshots || [];
const guestDaySnapshots = allSnapshots.filter(snap =>
parseInt(snap.vmid, 10) === parseInt(guestId, 10) &&
snap.snaptime >= day.start && snap.snaptime < day.end
);
if (guestDaySnapshots.length > 0) {
backupTypes.add('snapshot');
activityDetails.push(`${guestDaySnapshots.length} VM/CT snapshot${guestDaySnapshots.length > 1 ? 's' : ''} created`);
}
// Create day label for tooltip
@@ -280,7 +540,8 @@ PulseApp.ui.backups = (() => {
});
return {
status: dailyStatus,
backupTypes: Array.from(backupTypes),
hasFailures: hasFailures,
details: activityDetails.length > 0 ? activityDetails.join('\n') : 'No backup activity',
date: dayLabel
};
@@ -415,6 +676,7 @@ PulseApp.ui.backups = (() => {
function _renderBackupTableRow(guestStatus) {
const row = document.createElement('tr');
row.className = 'border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700';
row.dataset.guestId = guestStatus.guestId;
const latestBackupFormatted = guestStatus.latestBackupTime
? PulseApp.utils.formatPbsTimestamp(guestStatus.latestBackupTime)
@@ -425,24 +687,68 @@ PulseApp.ui.backups = (() => {
: 'ct-icon bg-green-100 dark:bg-green-900/50 text-green-700 dark:text-green-300 px-1.5 py-0.5 font-medium';
const typeIcon = `<span class="type-icon inline-block rounded text-xs align-middle ${typeIconClass}">${guestStatus.guestType}</span>`;
// Generate 7-day backup dots with enhanced tooltips
// Generate 7-day backup indicators as horizontal bars
let sevenDayDots = '<div class="flex space-x-0.5">';
if (guestStatus.last7DaysBackupStatus && guestStatus.last7DaysBackupStatus.length === 7) {
guestStatus.last7DaysBackupStatus.forEach(dayInfo => {
let dotClass = 'bg-gray-300 dark:bg-gray-600'; // Default for 'none'
let dotTitle = `${dayInfo.date}: ${dayInfo.details}`;
let barContent = '';
let barTitle = `${dayInfo.date}: ${dayInfo.details}`;
if (dayInfo.status === 'ok') {
dotClass = 'bg-green-500';
} else if (dayInfo.status === 'failed') {
dotClass = 'bg-red-500';
if (dayInfo.backupTypes && dayInfo.backupTypes.length > 0) {
// Sort types by priority: snapshot -> pve -> pbs
const sortedTypes = dayInfo.backupTypes.sort((a, b) => {
const order = { 'snapshot': 0, 'pve': 1, 'pbs': 2 };
return order[a] - order[b];
});
const typeClasses = {
'snapshot': 'bg-blue-500', // Blue for snapshots (local)
'pve': 'bg-yellow-500', // Yellow for PVE (cluster)
'pbs': 'bg-green-500' // Green for PBS (remote)
};
// Use dots for multiple backup types to avoid overlap
if (sortedTypes.length === 1) {
// Single backup type - solid square
const type = sortedTypes[0];
const colorClass = typeClasses[type];
const failureBorder = dayInfo.hasFailures ? 'border border-red-500' : '';
barContent = `<div class="w-2 h-2 ${colorClass} ${failureBorder} rounded-sm" title="${barTitle}"></div>`;
} else {
// Multiple backup types - stacked dots
const failureBorder = dayInfo.hasFailures ? 'border border-red-500' : '';
barContent = `<div class="flex flex-col w-2 h-2 ${failureBorder} rounded-sm" title="${barTitle}">`;
if (sortedTypes.length === 2) {
// Two types - split top/bottom
sortedTypes.forEach((type, index) => {
const colorClass = typeClasses[type];
barContent += `<div class="flex-1 ${colorClass} ${index === 0 ? 'rounded-t-sm' : 'rounded-b-sm'}"></div>`;
});
} else {
// Three types - show as striped pattern
const primaryType = sortedTypes[0]; // Show most important type
const colorClass = typeClasses[primaryType];
barContent = `<div class="w-2 h-2 ${colorClass} ${failureBorder} rounded-sm relative" title="${barTitle}">`;
barContent += `<div class="absolute inset-0 bg-gradient-to-r from-transparent via-white via-50% to-transparent opacity-30 rounded-sm"></div>`;
barContent += '</div>';
}
if (sortedTypes.length === 2) {
barContent += '</div>';
}
}
} else {
// No backup activity
barContent = `<div class="w-2 h-2 bg-gray-300 dark:bg-gray-600 rounded-sm" title="${barTitle}"></div>`;
}
sevenDayDots += `<span class="w-2 h-2 ${dotClass} rounded-full" title="${dotTitle}"></span>`;
sevenDayDots += barContent;
});
} else {
for (let i = 0; i < 7; i++) {
sevenDayDots += '<span class="w-2 h-2 bg-gray-200 dark:bg-gray-700 rounded-full" title="Data unavailable"></span>';
sevenDayDots += '<div class="w-2 h-2 bg-gray-200 dark:bg-gray-700 rounded-sm" title="Data unavailable"></div>';
}
}
sevenDayDots += '</div>';
@@ -450,8 +756,8 @@ PulseApp.ui.backups = (() => {
// Create PBS backup cell with visual indicator
let pbsBackupCell = '';
if (guestStatus.pbsBackups > 0) {
const pbsIcon = '<span class="inline-block w-2 h-2 bg-purple-500 rounded-full mr-1" title="PBS Backup"></span>';
pbsBackupCell = `<span class="text-purple-700 dark:text-purple-300" ${guestStatus.pbsBackupInfo ? `title="${guestStatus.pbsBackupInfo}"` : ''}>${pbsIcon}${guestStatus.pbsBackups}</span>`;
const pbsIcon = '<span class="inline-block w-2 h-2 bg-green-500 rounded-full mr-1" title="PBS Backup"></span>';
pbsBackupCell = `<span class="text-green-700 dark:text-green-300" ${guestStatus.pbsBackupInfo ? `title="${guestStatus.pbsBackupInfo}"` : ''}>${pbsIcon}${guestStatus.pbsBackups}</span>`;
} else {
pbsBackupCell = '<span class="text-gray-400 dark:text-gray-500">0</span>';
}
@@ -459,8 +765,8 @@ PulseApp.ui.backups = (() => {
// Create PVE backup cell with visual indicator
let pveBackupCell = '';
if (guestStatus.pveBackups > 0) {
const pveIcon = '<span class="inline-block w-2 h-2 bg-orange-500 rounded-full mr-1" title="PVE Backup"></span>';
pveBackupCell = `<span class="text-orange-700 dark:text-orange-300" ${guestStatus.pveBackupInfo ? `title="${guestStatus.pveBackupInfo}"` : ''}>${pveIcon}${guestStatus.pveBackups}</span>`;
const pveIcon = '<span class="inline-block w-2 h-2 bg-yellow-500 rounded-full mr-1" title="PVE Backup"></span>';
pveBackupCell = `<span class="text-yellow-700 dark:text-yellow-300" ${guestStatus.pveBackupInfo ? `title="${guestStatus.pveBackupInfo}"` : ''}>${pveIcon}${guestStatus.pveBackups}</span>`;
} else {
pveBackupCell = '<span class="text-gray-400 dark:text-gray-500">0</span>';
}
@@ -511,6 +817,320 @@ PulseApp.ui.backups = (() => {
statusTextElement.textContent = statusBaseText + statusFilterText + statusCountText;
}
function _initTableCalendarClick() {
const backupsTableBody = document.getElementById('backups-overview-tbody');
const calendarContainer = document.getElementById('backup-calendar-heatmap');
if (!backupsTableBody || !calendarContainer) return;
// Get current filtered guest from state (persists across API updates)
let currentFilteredGuest = PulseApp.state.get('currentFilteredGuest') || null;
// Add click listeners to table rows
const tableRows = backupsTableBody.querySelectorAll('tr[data-guest-id]');
tableRows.forEach(row => {
const guestId = row.dataset.guestId;
// Add cursor pointer to indicate clickability
row.style.cursor = 'pointer';
// Restore visual indication if this row was previously selected
if (currentFilteredGuest === guestId) {
row.classList.add('bg-blue-50', 'dark:bg-blue-900/20');
// Re-apply calendar filter on restore
_filterCalendarToGuest(guestId);
}
row.addEventListener('click', () => {
if (currentFilteredGuest === guestId) {
// Clicking the same row again resets the filter
_resetCalendarFilter();
currentFilteredGuest = null;
PulseApp.state.set('currentFilteredGuest', null);
// Remove visual indication
tableRows.forEach(r => r.classList.remove('bg-blue-50', 'dark:bg-blue-900/20'));
} else {
// Filter to this guest
_filterCalendarToGuest(guestId);
currentFilteredGuest = guestId;
PulseApp.state.set('currentFilteredGuest', guestId);
// Add visual indication
tableRows.forEach(r => r.classList.remove('bg-blue-50', 'dark:bg-blue-900/20'));
row.classList.add('bg-blue-50', 'dark:bg-blue-900/20');
}
});
});
// If we had a filtered guest but the row no longer exists (e.g., due to filtering), clear the state
if (currentFilteredGuest && !document.querySelector(`tr[data-guest-id="${currentFilteredGuest}"]`)) {
PulseApp.state.set('currentFilteredGuest', null);
_resetCalendarFilter();
}
}
function _filterCalendarToGuest(guestId) {
// Re-render the calendar with only this guest's data
const calendarContainer = document.getElementById('backup-calendar-heatmap');
if (!calendarContainer || !PulseApp.ui.calendarHeatmap) return;
// Get the current backup data
const pbsDataArray = PulseApp.state.get('pbsDataArray') || [];
const pveBackups = PulseApp.state.get('pveBackups') || {};
// Get PBS snapshots
const pbsSnapshots = pbsDataArray.flatMap(pbsInstance =>
(pbsInstance.datastores || []).flatMap(ds =>
(ds.snapshots || []).map(snap => ({
...snap,
pbsInstanceName: pbsInstance.pbsInstanceName,
datastoreName: ds.name,
source: 'pbs'
}))
)
);
// Get PVE storage backups
const pveStorageBackups = [];
if (pveBackups?.storageBackups) {
Object.entries(pveBackups.storageBackups).forEach(([nodeName, nodeData]) => {
if (nodeData && typeof nodeData === 'object') {
Object.entries(nodeData).forEach(([storage, backups]) => {
if (Array.isArray(backups)) {
backups.forEach(backup => {
pveStorageBackups.push({
...backup,
node: nodeName,
storage: storage,
source: 'pve'
});
});
}
});
}
});
}
// Get VM snapshots
const vmSnapshots = (pveBackups.guestSnapshots || []).map(snap => ({
...snap,
source: 'vmSnapshots'
}));
// Get backup tasks
const pbsBackupTasks = [];
pbsDataArray.forEach(pbs => {
if (pbs.backupTasks?.recentTasks && Array.isArray(pbs.backupTasks.recentTasks)) {
pbs.backupTasks.recentTasks.forEach(task => {
pbsBackupTasks.push({
...task,
pbsInstanceName: pbs.pbsInstanceName,
source: 'pbs'
});
});
}
});
const pveBackupTasks = [];
if (Array.isArray(pveBackups?.backupTasks)) {
pveBackups.backupTasks.forEach(task => {
pveBackupTasks.push({
...task,
source: 'pve'
});
});
}
const backupData = {
pbsSnapshots: pbsSnapshots,
pveBackups: pveStorageBackups,
vmSnapshots: vmSnapshots,
backupTasks: [...pbsBackupTasks, ...pveBackupTasks]
};
// Create filtered calendar for this specific guest
const filteredCalendar = PulseApp.ui.calendarHeatmap.createCalendarHeatmap(backupData, guestId, [guestId]);
calendarContainer.innerHTML = '';
calendarContainer.appendChild(filteredCalendar);
}
function _resetCalendarFilter() {
// Re-render the calendar with all filtered guests (respecting table filters)
const calendarContainer = document.getElementById('backup-calendar-heatmap');
if (!calendarContainer || !PulseApp.ui.calendarHeatmap) return;
// Get current filtered backup status to determine which guests to show
const vmsData = PulseApp.state.get('vmsData') || [];
const containersData = PulseApp.state.get('containersData') || [];
const allGuests = [...vmsData, ...containersData];
const { tasksByGuest, snapshotsByGuest, dayBoundaries, threeDaysAgo, sevenDaysAgo } = _getInitialBackupData();
const backupStatusByGuest = allGuests.map(guest => _determineGuestBackupStatus(guest, snapshotsByGuest.get(`${guest.vmid}-${guest.type === 'qemu' ? 'vm' : 'ct'}`) || [], tasksByGuest.get(`${guest.vmid}-${guest.type === 'qemu' ? 'vm' : 'ct'}`) || [], dayBoundaries, threeDaysAgo, sevenDaysAgo));
const filteredBackupStatus = _filterBackupData(backupStatusByGuest, backupsSearchInput);
// Get the current backup data
const pbsDataArray = PulseApp.state.get('pbsDataArray') || [];
const pveBackups = PulseApp.state.get('pveBackups') || {};
// Prepare backup data same as in updateBackupsTab
const pbsSnapshots = pbsDataArray.flatMap(pbsInstance =>
(pbsInstance.datastores || []).flatMap(ds =>
(ds.snapshots || []).map(snap => ({
...snap,
pbsInstanceName: pbsInstance.pbsInstanceName,
datastoreName: ds.name,
source: 'pbs'
}))
)
);
const pveStorageBackups = [];
if (pveBackups?.storageBackups) {
Object.entries(pveBackups.storageBackups).forEach(([nodeName, nodeData]) => {
if (nodeData && typeof nodeData === 'object') {
Object.entries(nodeData).forEach(([storage, backups]) => {
if (Array.isArray(backups)) {
backups.forEach(backup => {
pveStorageBackups.push({
...backup,
node: nodeName,
storage: storage,
source: 'pve'
});
});
}
});
}
});
}
const vmSnapshots = (pveBackups.guestSnapshots || []).map(snap => ({
...snap,
source: 'vmSnapshots'
}));
const pbsBackupTasks = [];
pbsDataArray.forEach(pbs => {
if (pbs.backupTasks?.recentTasks && Array.isArray(pbs.backupTasks.recentTasks)) {
pbs.backupTasks.recentTasks.forEach(task => {
pbsBackupTasks.push({
...task,
pbsInstanceName: pbs.pbsInstanceName,
source: 'pbs'
});
});
}
});
const pveBackupTasks = [];
if (Array.isArray(pveBackups?.backupTasks)) {
pveBackups.backupTasks.forEach(task => {
pveBackupTasks.push({
...task,
source: 'pve'
});
});
}
const backupData = {
pbsSnapshots: pbsSnapshots,
pveBackups: pveStorageBackups,
vmSnapshots: vmSnapshots,
backupTasks: [...pbsBackupTasks, ...pveBackupTasks]
};
// Create calendar respecting current table filters
const filteredGuestIds = filteredBackupStatus.map(guest => guest.guestId.toString());
const restoredCalendar = PulseApp.ui.calendarHeatmap.createCalendarHeatmap(backupData, null, filteredGuestIds);
calendarContainer.innerHTML = '';
calendarContainer.appendChild(restoredCalendar);
}
function _dayHasGuestBackup(dateKey, guestId) {
const vmsData = PulseApp.state.get('vmsData') || [];
const containersData = PulseApp.state.get('containersData') || [];
const pbsDataArray = PulseApp.state.get('pbsDataArray') || [];
const pveBackups = PulseApp.state.get('pveBackups') || {};
const targetDate = new Date(dateKey);
const startOfDay = new Date(targetDate.getFullYear(), targetDate.getMonth(), targetDate.getDate());
const endOfDay = new Date(startOfDay.getTime() + 24 * 60 * 60 * 1000);
const startTimestamp = Math.floor(startOfDay.getTime() / 1000);
const endTimestamp = Math.floor(endOfDay.getTime() / 1000);
// Check PBS snapshots
const pbsSnapshots = pbsDataArray.flatMap(pbsInstance =>
(pbsInstance.datastores || []).flatMap(ds =>
(ds.snapshots || []).filter(snap => {
const vmid = snap['backup-id'];
const timestamp = snap['backup-time'];
return vmid == guestId && timestamp >= startTimestamp && timestamp < endTimestamp;
})
)
);
if (pbsSnapshots.length > 0) return true;
// Check PVE storage backups
if (pveBackups.storageBackups) {
for (const [nodeName, nodeData] of Object.entries(pveBackups.storageBackups)) {
if (nodeData && typeof nodeData === 'object') {
for (const [storage, backups] of Object.entries(nodeData)) {
if (Array.isArray(backups)) {
const matchingBackups = backups.filter(backup => {
return backup.vmid == guestId &&
backup.ctime >= startTimestamp &&
backup.ctime < endTimestamp;
});
if (matchingBackups.length > 0) return true;
}
}
}
}
}
// Check VM snapshots
const vmSnapshots = (pveBackups.guestSnapshots || []).filter(snap => {
return parseInt(snap.vmid, 10) === parseInt(guestId, 10) &&
snap.snaptime >= startTimestamp &&
snap.snaptime < endTimestamp;
});
if (vmSnapshots.length > 0) return true;
return false;
}
function _highlightTableRows(guestIds, highlight) {
const backupsTableBody = document.getElementById('backups-overview-tbody');
if (!backupsTableBody) return;
guestIds.forEach(guestId => {
const row = backupsTableBody.querySelector(`tr[data-guest-id="${guestId}"]`);
if (row) {
if (highlight) {
// Apply highlighting to non-sticky cells only to avoid layout shift
const cells = row.querySelectorAll('td:not(.sticky)');
cells.forEach(cell => {
cell.classList.add('bg-blue-50/50', 'dark:bg-blue-900/10');
});
// Add a subtle left border to the second cell (ID column)
const idCell = row.querySelector('td:nth-child(2)');
if (idCell) {
idCell.classList.add('border-l-2', 'border-l-blue-400', 'dark:border-l-blue-500');
}
} else {
const cells = row.querySelectorAll('td:not(.sticky)');
cells.forEach(cell => {
cell.classList.remove('bg-blue-50/50', 'dark:bg-blue-900/10');
});
const idCell = row.querySelector('td:nth-child(2)');
if (idCell) {
idCell.classList.remove('border-l-2', 'border-l-blue-400', 'dark:border-l-blue-500');
}
}
}
});
}
function updateBackupsTab() {
const tableContainer = document.getElementById('backups-table-container');
@@ -556,8 +1176,127 @@ PulseApp.ui.backups = (() => {
const backupStatusByGuest = allGuests.map(guest => _determineGuestBackupStatus(guest, snapshotsByGuest.get(`${guest.vmid}-${guest.type === 'qemu' ? 'vm' : 'ct'}`) || [], tasksByGuest.get(`${guest.vmid}-${guest.type === 'qemu' ? 'vm' : 'ct'}`) || [], dayBoundaries, threeDaysAgo, sevenDaysAgo));
const filteredBackupStatus = _filterBackupData(backupStatusByGuest, backupsSearchInput);
// Calculate PBS instances summary - only show if multiple PBS instances
// Prepare backup data for consolidated summary
const pbsDataArray = PulseApp.state.get('pbsDataArray') || [];
const pveBackups = PulseApp.state.get('pveBackups') || {};
// Get PBS snapshots
const pbsSnapshots = pbsDataArray.flatMap(pbsInstance =>
(pbsInstance.datastores || []).flatMap(ds =>
(ds.snapshots || []).map(snap => ({
...snap,
pbsInstanceName: pbsInstance.pbsInstanceName,
datastoreName: ds.name,
source: 'pbs'
}))
)
);
// Get PVE storage backups
const pveStorageBackups = [];
if (pveBackups?.storageBackups) {
Object.entries(pveBackups.storageBackups).forEach(([nodeName, nodeData]) => {
if (nodeData && typeof nodeData === 'object') {
Object.entries(nodeData).forEach(([storage, backups]) => {
if (Array.isArray(backups)) {
backups.forEach(backup => {
pveStorageBackups.push({
...backup,
node: nodeName,
storage: storage,
source: 'pve'
});
});
}
});
}
});
}
// Get VM snapshots
const vmSnapshots = (pveBackups.guestSnapshots || []).map(snap => ({
...snap,
source: 'vmSnapshots'
}));
const backupData = {
pbsSnapshots: pbsSnapshots,
pveBackups: pveStorageBackups,
vmSnapshots: vmSnapshots
};
// Calculate and display consolidated backup summary
const backupSummary = calculateBackupSummary(backupStatusByGuest);
const backupSummaryContainer = document.getElementById('backup-summary-container');
if (backupSummaryContainer && backupStatusByGuest.length > 0) {
backupSummaryContainer.innerHTML = createConsolidatedBackupSummary(backupSummary, backupData, backupStatusByGuest);
backupSummaryContainer.classList.remove('hidden');
} else if (backupSummaryContainer) {
backupSummaryContainer.classList.add('hidden');
}
// Hide node backup cards - no longer needed with consolidated view
const nodeBackupCards = document.getElementById('node-backup-cards');
if (nodeBackupCards) {
nodeBackupCards.classList.add('hidden');
}
// Display backup calendar visualization section
const visualizationSection = document.getElementById('backup-visualization-section');
const summaryCardsContainer = document.getElementById('backup-summary-cards-container');
const calendarContainer = document.getElementById('backup-calendar-heatmap');
if (visualizationSection && backupStatusByGuest.length > 0) {
// Hide the summary cards container - we're using consolidated summary now
if (summaryCardsContainer) {
summaryCardsContainer.classList.add('hidden');
}
// Get backup tasks for calendar
const pbsBackupTasks = [];
pbsDataArray.forEach(pbs => {
if (pbs.backupTasks?.recentTasks && Array.isArray(pbs.backupTasks.recentTasks)) {
pbs.backupTasks.recentTasks.forEach(task => {
pbsBackupTasks.push({
...task,
pbsInstanceName: pbs.pbsInstanceName,
source: 'pbs'
});
});
}
});
const pveBackupTasks = [];
if (Array.isArray(pveBackups?.backupTasks)) {
pveBackups.backupTasks.forEach(task => {
pveBackupTasks.push({
...task,
source: 'pve'
});
});
}
// Extend backupData with tasks for calendar
const extendedBackupData = {
...backupData,
backupTasks: [...pbsBackupTasks, ...pveBackupTasks]
};
// Create and display calendar heatmap
if (calendarContainer && PulseApp.ui.calendarHeatmap) {
// Get filtered guest IDs for calendar filtering
const filteredGuestIds = filteredBackupStatus.map(guest => guest.guestId.toString());
const calendarHeatmap = PulseApp.ui.calendarHeatmap.createCalendarHeatmap(extendedBackupData, null, filteredGuestIds);
calendarContainer.innerHTML = '';
calendarContainer.appendChild(calendarHeatmap);
}
visualizationSection.classList.remove('hidden');
} else if (visualizationSection) {
visualizationSection.classList.add('hidden');
}
// Calculate PBS instances summary - only show if multiple PBS instances
const pbsSummaryDismissed = PulseApp.state.get('pbsSummaryDismissed') || false;
if (pbsSummaryElement) {
@@ -664,6 +1403,9 @@ PulseApp.ui.backups = (() => {
}
}); // End of preserveScrollPosition
// Setup click filtering between table and calendar
_initTableCalendarClick();
// Additional scroll position restoration for horizontal scrolling
if (scrollableContainer && (currentScrollLeft > 0 || currentScrollTop > 0)) {
requestAnimationFrame(() => {
@@ -697,6 +1439,9 @@ PulseApp.ui.backups = (() => {
PulseApp.state.setSortState('backups', 'latestBackupTime', 'desc');
// Clear calendar filter selection
PulseApp.state.set('currentFilteredGuest', null);
updateBackupsTab();
PulseApp.state.saveFilterState(); // Save reset state
}
@@ -812,6 +1557,7 @@ PulseApp.ui.backups = (() => {
return {
init,
updateBackupsTab,
resetBackupsView
resetBackupsView,
_highlightTableRows
};
})();
+912
View File
@@ -0,0 +1,912 @@
PulseApp.ui = PulseApp.ui || {};
PulseApp.ui.calendarHeatmap = (() => {
const CSS_CLASSES = {
CALENDAR_CONTAINER: 'calendar-heatmap-container max-w-4xl mx-auto',
MONTH_LABEL: 'text-xs text-gray-600 dark:text-gray-400 font-medium mb-1',
DAY_TOOLTIP: 'fixed z-50 px-3 py-2 text-xs text-white bg-gray-900/90 rounded shadow-lg pointer-events-none opacity-0 transition-opacity duration-100 max-w-sm max-h-96 overflow-y-auto',
LEGEND_CONTAINER: 'flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400',
LEGEND_BOX: 'w-3 h-3 rounded-sm',
YEAR_NAVIGATION: 'flex items-center justify-between mb-4',
YEAR_BUTTON: 'text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 cursor-pointer',
CURRENT_YEAR: 'text-sm font-medium text-gray-700 dark:text-gray-300'
};
const BACKUP_COLORS = {
NONE: 'bg-gray-200 dark:bg-gray-700',
LOW: 'bg-green-200 dark:bg-green-900',
MEDIUM: 'bg-green-400 dark:bg-green-700',
HIGH: 'bg-green-600 dark:bg-green-500',
FAILED: 'border-2 border-red-500 dark:border-red-400',
MIXED: 'bg-gradient-to-br from-green-400 to-yellow-400 dark:from-green-700 dark:to-yellow-700'
};
function createCalendarHeatmap(backupData, guestId = null, filteredGuestIds = null) {
const container = document.createElement('div');
container.className = CSS_CLASSES.CALENDAR_CONTAINER;
// Add header with summary stats (will be updated by updateCalendarContent)
const header = createCalendarHeader(backupData, guestId, filteredGuestIds);
container.appendChild(header);
const calendarContent = document.createElement('div');
calendarContent.className = 'calendar-content';
container.appendChild(calendarContent);
const legend = createLegend();
container.appendChild(legend);
updateCalendarContent(container, backupData, guestId, filteredGuestIds);
return container;
}
function createCalendarHeader(backupData, guestId = null, filteredGuestIds = null) {
const header = document.createElement('div');
header.className = 'mb-4 space-y-3';
// Add backup statistics summary
const stats = calculateBackupStats(backupData, guestId, filteredGuestIds);
const statsDiv = document.createElement('div');
statsDiv.className = 'grid grid-cols-2 md:grid-cols-4 gap-3 text-sm';
statsDiv.innerHTML = `
<div class="bg-gray-50 dark:bg-gray-700/50 rounded p-2">
<div class="text-xs text-gray-500 dark:text-gray-400">Total Backups</div>
<div class="font-semibold text-gray-900 dark:text-gray-100">${stats.totalBackups}</div>
</div>
<div class="bg-gray-50 dark:bg-gray-700/50 rounded p-2">
<div class="text-xs text-gray-500 dark:text-gray-400">Days with Backups</div>
<div class="font-semibold text-gray-900 dark:text-gray-100">${stats.daysWithBackups}</div>
</div>
<div class="bg-gray-50 dark:bg-gray-700/50 rounded p-2">
<div class="text-xs text-gray-500 dark:text-gray-400">Active Guests</div>
<div class="font-semibold text-gray-900 dark:text-gray-100">${stats.activeGuests}</div>
</div>
<div class="bg-gray-50 dark:bg-gray-700/50 rounded p-2">
<div class="text-xs text-gray-500 dark:text-gray-400">Backup Types</div>
<div class="flex gap-2 mt-1">
${stats.hasPBS ? '<span class="inline-block w-2 h-2 bg-green-500 rounded-full" title="PBS"></span>' : ''}
${stats.hasPVE ? '<span class="inline-block w-2 h-2 bg-yellow-500 rounded-full" title="PVE"></span>' : ''}
${stats.hasSnapshots ? '<span class="inline-block w-2 h-2 bg-blue-500 rounded-full" title="Snapshots"></span>' : ''}
</div>
</div>
`;
header.appendChild(statsDiv);
return header;
}
function calculateBackupStats(backupData, guestId = null, filteredGuestIds = null) {
const stats = {
totalBackups: 0,
daysWithBackups: 0,
activeGuests: new Set(),
hasPBS: false,
hasPVE: false,
hasSnapshots: false
};
const daysWithData = new Set();
// Process all backup sources
['pbsSnapshots', 'pveBackups', 'vmSnapshots'].forEach(source => {
if (!backupData[source]) return;
backupData[source].forEach(item => {
const timestamp = item.ctime || item.snaptime || item['backup-time'];
if (!timestamp) return;
const date = new Date(timestamp * 1000);
const dateKey = date.toISOString().split('T')[0];
const vmid = item.vmid || item['backup-id'] || item.backupVMID;
if (!vmid) return;
// Apply filtering logic
if (guestId && vmid != guestId) return;
if (filteredGuestIds && !filteredGuestIds.includes(vmid.toString())) return;
stats.activeGuests.add(vmid);
daysWithData.add(dateKey);
stats.totalBackups++;
if (source === 'pbsSnapshots') stats.hasPBS = true;
if (source === 'pveBackups') stats.hasPVE = true;
if (source === 'vmSnapshots') stats.hasSnapshots = true;
});
});
stats.daysWithBackups = daysWithData.size;
stats.activeGuests = stats.activeGuests.size;
return stats;
}
function createYearNavigation(currentYear, onYearChange) {
const nav = document.createElement('div');
nav.className = CSS_CLASSES.YEAR_NAVIGATION;
const prevButton = document.createElement('button');
prevButton.className = CSS_CLASSES.YEAR_BUTTON;
prevButton.innerHTML = '← Previous Year';
prevButton.onclick = () => {
const currentYearValue = parseInt(nav.dataset.year || currentYear);
onYearChange(currentYearValue - 1);
};
const yearLabel = document.createElement('span');
yearLabel.className = CSS_CLASSES.CURRENT_YEAR;
yearLabel.textContent = currentYear;
nav.dataset.year = currentYear;
const nextButton = document.createElement('button');
nextButton.className = CSS_CLASSES.YEAR_BUTTON;
nextButton.innerHTML = 'Next Year →';
nextButton.onclick = () => {
const currentYearValue = parseInt(nav.dataset.year || currentYear);
const nextYear = currentYearValue + 1;
// Don't allow navigation beyond current year
if (nextYear <= new Date().getFullYear()) {
onYearChange(nextYear);
}
};
nav.appendChild(prevButton);
nav.appendChild(yearLabel);
nav.appendChild(nextButton);
return nav;
}
function updateCalendarContent(container, backupData, guestId, filteredGuestIds = null) {
if (!container) {
console.error('[Calendar Heatmap] Container is null');
return;
}
const calendarContent = container.querySelector('.calendar-content');
if (!calendarContent) {
console.error('[Calendar Heatmap] Calendar content element not found');
return;
}
calendarContent.innerHTML = '';
// Update statistics in header
const header = container.querySelector('.mb-4.space-y-3');
if (header) {
const stats = calculateBackupStats(backupData, guestId, filteredGuestIds);
const statsDiv = header.querySelector('.grid');
if (statsDiv) {
statsDiv.innerHTML = `
<div class="bg-gray-50 dark:bg-gray-700/50 rounded p-2">
<div class="text-xs text-gray-500 dark:text-gray-400">Total Backups</div>
<div class="font-semibold text-gray-900 dark:text-gray-100">${stats.totalBackups}</div>
</div>
<div class="bg-gray-50 dark:bg-gray-700/50 rounded p-2">
<div class="text-xs text-gray-500 dark:text-gray-400">Days with Backups</div>
<div class="font-semibold text-gray-900 dark:text-gray-100">${stats.daysWithBackups}</div>
</div>
<div class="bg-gray-50 dark:bg-gray-700/50 rounded p-2">
<div class="text-xs text-gray-500 dark:text-gray-400">Active Guests</div>
<div class="font-semibold text-gray-900 dark:text-gray-100">${stats.activeGuests}</div>
</div>
<div class="bg-gray-50 dark:bg-gray-700/50 rounded p-2">
<div class="text-xs text-gray-500 dark:text-gray-400">Backup Types</div>
<div class="flex gap-2 mt-1">
${stats.hasPBS ? '<span class="inline-block w-2 h-2 bg-green-500 rounded-full" title="PBS"></span>' : ''}
${stats.hasPVE ? '<span class="inline-block w-2 h-2 bg-yellow-500 rounded-full" title="PVE"></span>' : ''}
${stats.hasSnapshots ? '<span class="inline-block w-2 h-2 bg-blue-500 rounded-full" title="Snapshots"></span>' : ''}
</div>
</div>
`;
}
}
const allData = processBackupDataForAllYears(backupData, guestId, filteredGuestIds);
const monthsWithData = generateMonthsWithBackupData(allData);
// Set responsive grid based on number of months
calendarContent.className = getResponsiveGridClass(monthsWithData.length);
monthsWithData.forEach(month => {
const monthSection = createMonthSection(month, allData, monthsWithData.length);
calendarContent.appendChild(monthSection);
});
// Show message if no months have data
if (monthsWithData.length === 0) {
const noDataMessage = document.createElement('div');
noDataMessage.className = 'text-center text-gray-500 dark:text-gray-400 py-8';
noDataMessage.textContent = guestId
? `No backup data found for guest ${guestId}`
: `No backup data found`;
calendarContent.appendChild(noDataMessage);
}
}
function processBackupDataForAllYears(backupData, guestId, filteredGuestIds = null) {
const allData = {};
// Get guest data for hostname lookup
const vmsData = PulseApp.state.get('vmsData') || [];
const containersData = PulseApp.state.get('containersData') || [];
const allGuests = [...vmsData, ...containersData];
const guestLookup = {};
allGuests.forEach(guest => {
guestLookup[guest.vmid] = {
name: guest.name,
type: guest.type === 'qemu' ? 'VM' : 'CT'
};
});
// Group all backups by guest and date (no year restriction)
const backupsByGuestAndDate = {};
// Process all backup sources
const sources = ['pbsSnapshots', 'pveBackups', 'vmSnapshots'];
sources.forEach(source => {
if (!backupData[source]) return;
const items = backupData[source];
items.forEach(item => {
const timestamp = item.ctime || item.snaptime || item['backup-time'];
if (!timestamp) return;
const date = new Date(timestamp * 1000);
const dateKey = date.toISOString().split('T')[0];
// Skip future dates
const now = new Date();
if (date > now) return;
const vmid = item.vmid || item['backup-id'] || item.backupVMID;
if (!vmid) return;
// Skip if filtering by specific guest
if (guestId && vmid != guestId) return;
// Skip if filtered guest list is provided and this guest is not in it
if (filteredGuestIds && !filteredGuestIds.includes(vmid.toString())) return;
if (!backupsByGuestAndDate[vmid]) {
backupsByGuestAndDate[vmid] = {};
}
if (!backupsByGuestAndDate[vmid][dateKey]) {
backupsByGuestAndDate[vmid][dateKey] = {
date: date,
types: new Set(),
backups: []
};
}
backupsByGuestAndDate[vmid][dateKey].types.add(source);
backupsByGuestAndDate[vmid][dateKey].backups.push({
type: source,
time: date.toLocaleTimeString(),
name: item.volid || item.name || item['backup-id'] || 'Backup'
});
});
});
// Process all backup days and group by date
Object.entries(backupsByGuestAndDate).forEach(([vmid, dateData]) => {
Object.keys(dateData).forEach(dateKey => {
// Initialize day data if not exists
if (!allData[dateKey]) {
allData[dateKey] = {
guests: [],
allTypes: new Set(),
hasBackups: true
};
}
const guestInfo = guestLookup[vmid] || { name: `Unknown-${vmid}`, type: 'VM' };
allData[dateKey].guests.push({
vmid: vmid,
name: guestInfo.name,
type: guestInfo.type,
types: Array.from(dateData[dateKey].types),
backupCount: dateData[dateKey].backups.length
});
dateData[dateKey].types.forEach(type => {
allData[dateKey].allTypes.add(type);
});
});
});
// Process backup tasks for failure detection
if (backupData.backupTasks) {
const tasks = guestId
? backupData.backupTasks.filter(task => task.vmid == guestId)
: backupData.backupTasks;
tasks.forEach(task => {
if (!task.starttime || task.starttime <= 0) return;
const date = new Date(task.starttime * 1000);
if (isNaN(date.getTime())) return;
const dateKey = date.toISOString().split('T')[0];
if (task.status !== 'OK' && allData[dateKey]) {
allData[dateKey].hasFailures = true;
}
});
}
return allData;
}
function generateMonthsWithBackupData(allData) {
const monthsSet = new Set();
// Get all unique year-month combinations from backup data
Object.keys(allData).forEach(dateKey => {
const date = new Date(dateKey);
const monthKey = `${date.getFullYear()}-${date.getMonth()}`;
monthsSet.add(monthKey);
});
// Convert to sorted month objects
const months = Array.from(monthsSet).sort().map(monthKey => {
const [year, month] = monthKey.split('-').map(Number);
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
return {
name: firstDay.toLocaleString('default', { month: 'short', year: 'numeric' }),
year: year,
month: month,
firstDay: firstDay.getDay(),
daysInMonth: lastDay.getDate()
};
});
return months;
}
function processBackupDataForYear(backupData, guestId, year, filteredGuestIds = null) {
const yearData = {};
const startOfYear = new Date(year, 0, 1);
const endOfYear = new Date(year, 11, 31, 23, 59, 59);
const today = new Date();
// Get guest data for hostname lookup
const vmsData = PulseApp.state.get('vmsData') || [];
const containersData = PulseApp.state.get('containersData') || [];
const allGuests = [...vmsData, ...containersData];
const guestLookup = {};
allGuests.forEach(guest => {
guestLookup[guest.vmid] = {
name: guest.name,
type: guest.type === 'qemu' ? 'VM' : 'CT'
};
});
// Group all backups by guest and date
const backupsByGuestAndDate = {};
// Process all backup sources
const sources = ['pbsSnapshots', 'pveBackups', 'vmSnapshots'];
sources.forEach(source => {
if (!backupData[source]) return;
const items = backupData[source];
items.forEach(item => {
const timestamp = item.ctime || item.snaptime || item['backup-time'];
if (!timestamp) {
return;
}
const date = new Date(timestamp * 1000);
if (date < startOfYear || date > endOfYear) return;
const dateKey = date.toISOString().split('T')[0];
// Skip future dates
const now = new Date();
if (date > now) {
return;
}
const vmid = item.vmid || item['backup-id'] || item.backupVMID;
if (!vmid) {
return;
}
// Skip if filtering by specific guest
if (guestId && vmid != guestId) return;
// Skip if filtered guest list is provided and this guest is not in it
if (filteredGuestIds && !filteredGuestIds.includes(vmid.toString())) return;
if (!backupsByGuestAndDate[vmid]) {
backupsByGuestAndDate[vmid] = {};
}
if (!backupsByGuestAndDate[vmid][dateKey]) {
backupsByGuestAndDate[vmid][dateKey] = {
date: date,
types: new Set(),
backups: []
};
}
backupsByGuestAndDate[vmid][dateKey].types.add(source);
backupsByGuestAndDate[vmid][dateKey].backups.push({
type: source,
time: date.toLocaleTimeString(),
name: item.volid || item.name || item['backup-id'] || 'Backup'
});
});
});
// Process all backup days and determine retention markers
Object.entries(backupsByGuestAndDate).forEach(([vmid, dateData]) => {
const sortedDates = Object.keys(dateData).sort().reverse(); // Most recent first
let lastDaily = null;
let lastWeekly = null;
let lastMonthly = null;
let dailyCount = 0;
sortedDates.forEach(dateKey => {
const date = new Date(dateKey);
const daysSinceBackup = Math.floor((today - date) / (1000 * 60 * 60 * 24));
// Initialize day data if not exists
if (!yearData[dateKey]) {
yearData[dateKey] = {
retentionLevels: new Set(),
guestsByRetention: {},
allTypes: new Set(),
hasBackups: true // Mark that this day has backups
};
}
// Determine retention level (optional - for special highlighting)
let retentionLevel = null;
if (daysSinceBackup <= 7 && dailyCount < 7) {
// Last 7 days - daily retention
retentionLevel = 'daily';
dailyCount++;
} else if (date.getDay() === 0 && daysSinceBackup <= 28) {
// Sunday within last 4 weeks - weekly retention
if (!lastWeekly || date > lastWeekly) {
retentionLevel = 'weekly';
lastWeekly = date;
}
} else if (date.getDate() <= 7 && date.getDay() === 0) {
// First Sunday of month - monthly retention
if (!lastMonthly || date.getMonth() !== lastMonthly.getMonth()) {
retentionLevel = 'monthly';
lastMonthly = date;
}
} else if (date.getMonth() === 0 && date.getDate() === 1) {
// January 1st - yearly retention
retentionLevel = 'yearly';
} else {
// Default retention level for any backup that doesn't fit other categories
retentionLevel = 'general';
}
// Always add backup data, regardless of retention level
yearData[dateKey].retentionLevels.add(retentionLevel);
if (!yearData[dateKey].guestsByRetention[retentionLevel]) {
yearData[dateKey].guestsByRetention[retentionLevel] = [];
}
const guestInfo = guestLookup[vmid] || { name: `Unknown-${vmid}`, type: 'VM' };
// Check if this guest already exists in this retention level
const existingGuestIndex = yearData[dateKey].guestsByRetention[retentionLevel].findIndex(g => g.vmid === vmid);
if (existingGuestIndex >= 0) {
// Merge backup types if guest already exists
const existingGuest = yearData[dateKey].guestsByRetention[retentionLevel][existingGuestIndex];
const mergedTypes = new Set([...existingGuest.types, ...Array.from(dateData[dateKey].types)]);
existingGuest.types = Array.from(mergedTypes);
existingGuest.backupCount += dateData[dateKey].backups.length;
} else {
// Add new guest
yearData[dateKey].guestsByRetention[retentionLevel].push({
vmid: vmid,
name: guestInfo.name,
type: guestInfo.type,
types: Array.from(dateData[dateKey].types),
backupCount: dateData[dateKey].backups.length
});
}
dateData[dateKey].types.forEach(type => {
yearData[dateKey].allTypes.add(type);
});
});
});
// Process backup tasks for failure detection
if (backupData.backupTasks) {
const tasks = guestId
? backupData.backupTasks.filter(task => task.vmid == guestId)
: backupData.backupTasks;
tasks.forEach(task => {
if (!task.starttime || task.starttime <= 0) {
return;
}
const date = new Date(task.starttime * 1000);
if (isNaN(date.getTime())) {
return;
}
if (date < startOfYear || date > endOfYear) return;
const dateKey = date.toISOString().split('T')[0];
if (task.status !== 'OK' && yearData[dateKey]) {
yearData[dateKey].hasFailures = true;
}
});
}
return yearData;
}
function generateYearMonths(year) {
const months = [];
const currentDate = new Date();
const currentYear = currentDate.getFullYear();
const currentMonth = currentDate.getMonth();
// Determine how many months to show
const maxMonth = (year === currentYear) ? currentMonth : 11;
for (let month = 0; month <= maxMonth; month++) {
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
months.push({
name: firstDay.toLocaleString('default', { month: 'short' }),
year: year,
month: month,
firstDay: firstDay.getDay(),
daysInMonth: lastDay.getDate()
});
}
return months;
}
function getResponsiveGridClass(monthCount) {
if (monthCount === 0) {
return 'flex items-center justify-center'; // For no data message
} else if (monthCount === 1) {
return 'grid grid-cols-1 place-items-center gap-6'; // Center single month
} else if (monthCount === 2) {
return 'grid grid-cols-1 lg:grid-cols-2 gap-8 place-items-center'; // 2 months with nice spacing
} else if (monthCount === 3) {
return 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6'; // 3 months
} else if (monthCount === 4) {
return 'grid grid-cols-1 md:grid-cols-2 gap-4'; // 2x2 grid
} else if (monthCount <= 6) {
return 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4'; // Up to 3x2
} else {
return 'grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4'; // Many months
}
}
function monthHasBackupData(month, yearData) {
// Check if any day in this month has backup data
for (let day = 1; day <= month.daysInMonth; day++) {
const date = new Date(month.year, month.month, day);
const dateKey = date.toISOString().split('T')[0];
if (yearData[dateKey] && yearData[dateKey].hasBackups) {
return true;
}
}
return false;
}
function getMonthLabelClass(totalMonthCount) {
if (totalMonthCount <= 2) {
return 'text-sm font-medium text-gray-600 dark:text-gray-400 mb-2'; // Larger label for fewer months
} else if (totalMonthCount <= 4) {
return 'text-sm text-gray-600 dark:text-gray-400 font-medium mb-1.5';
} else {
return 'text-xs text-gray-600 dark:text-gray-400 font-medium mb-1'; // Original size
}
}
function getCalendarGridClass(totalMonthCount) {
if (totalMonthCount === 1) {
return 'grid grid-cols-7 gap-1.5'; // Largest cells and spacing for single month
} else if (totalMonthCount === 2) {
return 'grid grid-cols-7 gap-1'; // Large cells for 2 months
} else if (totalMonthCount <= 4) {
return 'grid grid-cols-7 gap-0.5'; // Medium cells
} else {
return 'grid grid-cols-7 gap-0.5'; // Original small cells for many months
}
}
function getDayCellClass(totalMonthCount) {
if (totalMonthCount === 1) {
return 'w-5 h-5 rounded cursor-pointer transition-all duration-200 relative group'; // Largest cells
} else if (totalMonthCount === 2) {
return 'w-4 h-4 rounded cursor-pointer transition-all duration-200 relative group'; // Large cells
} else if (totalMonthCount <= 4) {
return 'w-3.5 h-3.5 rounded cursor-pointer transition-all duration-200 relative group'; // Medium cells
} else {
return 'w-3 h-3 rounded cursor-pointer transition-all duration-200 relative group'; // Original small cells
}
}
function createMonthSection(month, yearData, totalMonthCount) {
const section = document.createElement('div');
section.className = '';
const label = document.createElement('div');
label.className = getMonthLabelClass(totalMonthCount);
label.textContent = month.name;
section.appendChild(label);
const grid = document.createElement('div');
grid.className = getCalendarGridClass(totalMonthCount);
// Add empty cells for days before month starts
for (let i = 0; i < month.firstDay; i++) {
const emptyCell = document.createElement('div');
emptyCell.className = getDayCellClass(totalMonthCount).replace('cursor-pointer transition-all duration-200 relative group', ''); // Same size but no interactions
grid.appendChild(emptyCell);
}
// Add day cells - show all days but only color those with backup data
for (let day = 1; day <= month.daysInMonth; day++) {
// Use UTC to avoid timezone conversion issues
const date = new Date(Date.UTC(month.year, month.month, day));
const dateKey = date.toISOString().split('T')[0];
const dayData = yearData[dateKey];
const dayCell = createDayCell(date, dayData, totalMonthCount);
grid.appendChild(dayCell);
}
section.appendChild(grid);
return section;
}
function createDayCell(date, dayData, totalMonthCount) {
const cell = document.createElement('div');
cell.className = getDayCellClass(totalMonthCount) + ' relative overflow-hidden hover:scale-110 hover:z-10 transform transition-transform duration-200';
// Check if this is today
const today = new Date();
const isToday = date.toDateString() === today.toDateString();
if (isToday) {
// Add today indicator with just a static outline
cell.className += ' ring-2 ring-blue-500 dark:ring-blue-400';
}
if (dayData && dayData.hasBackups) {
// Remove clickable appearance
// cell.style.cursor = 'pointer';
// Determine color based purely on backup types
const backupTypes = Array.from(dayData.allTypes || []);
if (backupTypes.length > 1) {
// Multiple backup types - create split background
if (backupTypes.includes('pbsSnapshots') && backupTypes.includes('vmSnapshots')) {
// PBS (green) + VM Snapshots (blue) - diagonal split
cell.style.background = `linear-gradient(135deg, rgb(34 197 94) 50%, rgb(59 130 246) 50%)`;
} else if (backupTypes.includes('pveBackups') && backupTypes.includes('vmSnapshots')) {
// PVE (yellow) + VM Snapshots (blue) - diagonal split
cell.style.background = `linear-gradient(135deg, rgb(250 204 21) 50%, rgb(59 130 246) 50%)`;
} else if (backupTypes.includes('pbsSnapshots') && backupTypes.includes('pveBackups')) {
// PBS (green) + PVE (yellow) - diagonal split
cell.style.background = `linear-gradient(135deg, rgb(34 197 94) 50%, rgb(250 204 21) 50%)`;
} else if (backupTypes.length === 3) {
// All three types - use stripes
cell.style.background = `linear-gradient(45deg,
rgb(34 197 94) 0%, rgb(34 197 94) 33%,
rgb(250 204 21) 33%, rgb(250 204 21) 66%,
rgb(59 130 246) 66%, rgb(59 130 246) 100%)`;
}
} else if (backupTypes.length === 1) {
// Single backup type - use solid color based on type
if (backupTypes.includes('pbsSnapshots')) {
cell.className += ' bg-green-500'; // PBS - green
} else if (backupTypes.includes('pveBackups')) {
cell.className += ' bg-yellow-400'; // PVE - yellow
} else if (backupTypes.includes('vmSnapshots')) {
cell.className += ' bg-blue-400'; // Snapshots - blue
}
} else {
cell.className += ' ' + BACKUP_COLORS.NONE;
}
// Add failure indicator if present
if (dayData.hasFailures) {
cell.className += ' ring-1 ring-red-500 dark:ring-red-400';
}
// Add small indicator for number of guests
const totalGuests = dayData.guests ? dayData.guests.length : 0;
if (totalGuests > 3) {
const countIndicator = document.createElement('div');
countIndicator.className = 'absolute inset-0 flex items-center justify-center text-[8px] font-bold text-white drop-shadow';
countIndicator.textContent = totalGuests;
cell.appendChild(countIndicator);
}
} else {
// No retention markers - gray
cell.className += ' ' + BACKUP_COLORS.NONE;
}
cell.dataset.date = date.toISOString().split('T')[0];
// Store guest IDs for this day if available
if (dayData && dayData.guests && dayData.guests.length > 0) {
const allGuests = dayData.guests.map(g => g.vmid);
cell.dataset.guestIds = allGuests.join(',');
}
// Always add tooltip
const tooltip = createTooltip(date, dayData);
cell.appendChild(tooltip);
// Add mouse event handlers for tooltip only
cell.addEventListener('mouseenter', (e) => {
const rect = cell.getBoundingClientRect();
// Smart positioning to avoid viewport cutoff
let left = rect.right + 10;
let top = rect.top;
// Check if tooltip would go off-screen horizontally
if (left + 300 > window.innerWidth) {
left = rect.left - 310; // Position to the left instead
}
// Check if tooltip would go off-screen vertically
if (top + 200 > window.innerHeight) {
top = window.innerHeight - 210; // Position higher
}
tooltip.style.left = left + 'px';
tooltip.style.top = top + 'px';
tooltip.style.opacity = '1';
tooltip.style.zIndex = '100';
// If tooltip shows no data but cell is colored, try to recreate tooltip
if (tooltip.textContent.includes('No backup') && dayData && dayData.hasBackups) {
const newTooltip = createTooltip(date, dayData);
tooltip.innerHTML = newTooltip.innerHTML;
}
});
cell.addEventListener('mouseleave', () => {
tooltip.style.opacity = '0';
});
// Calendar cells are no longer clickable - interaction moved to table rows
return cell;
}
function highlightGuestsInTable(guestIds) {
// Call the backups UI function to highlight table rows (non-intrusive)
if (PulseApp.ui && PulseApp.ui.backups && PulseApp.ui.backups._highlightTableRows) {
// Remove any existing highlights first
PulseApp.ui.backups._highlightTableRows([], false);
// Then highlight the selected guests
PulseApp.ui.backups._highlightTableRows(guestIds, true);
// Scroll to first matching row
const firstRow = document.querySelector(`#backups-overview-tbody tr[data-guest-id="${guestIds[0]}"]`);
if (firstRow) {
firstRow.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
}
function createTooltip(date, dayData) {
const tooltip = document.createElement('div');
tooltip.className = CSS_CLASSES.DAY_TOOLTIP;
// Use consistent date formatting without timezone conversion
const dateStr = date.toLocaleDateString(undefined, {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
timeZone: 'UTC' // Force UTC to prevent timezone shifts
});
if (!dayData || !dayData.guests || dayData.guests.length === 0) {
tooltip.innerHTML = `<div class="text-xs">${dateStr}<br>No backup activity</div>`;
return tooltip;
}
let content = `<div class="font-medium mb-2">${dateStr}</div>`;
// Show guests and their backup types
content += `<div class="text-xs space-y-1">`;
dayData.guests.forEach(guest => {
const typeLabels = {
pbsSnapshots: '<span class="text-green-400">PBS</span>',
pveBackups: '<span class="text-yellow-400">PVE</span>',
vmSnapshots: '<span class="text-blue-400">SNAP</span>'
};
const typesArray = Array.isArray(guest.types) ? guest.types : Array.from(guest.types);
const labels = typesArray.map(t => {
return typeLabels[t] || `<span class="text-gray-400">${t}</span>`;
}).join(' ');
content += `<div>${guest.type} ${guest.vmid} (${guest.name}) ${labels} (${guest.backupCount})</div>`;
});
content += `</div>`;
if (dayData.hasFailures) {
content += '<div class="text-red-300 text-xs mt-2">⚠ Contains failures</div>';
}
tooltip.innerHTML = content;
return tooltip;
}
function createLegend() {
const legend = document.createElement('div');
legend.className = 'mt-4 space-y-2';
legend.innerHTML = `
<div class="flex flex-wrap items-center justify-center gap-4 text-xs">
<div class="flex items-center gap-2">
<div class="flex items-center gap-1">
<div class="${CSS_CLASSES.LEGEND_BOX} bg-green-500 dark:bg-green-400"></div>
<span>PBS</span>
</div>
<div class="flex items-center gap-1">
<div class="${CSS_CLASSES.LEGEND_BOX} bg-yellow-400 dark:bg-yellow-500"></div>
<span>PVE</span>
</div>
<div class="flex items-center gap-1">
<div class="${CSS_CLASSES.LEGEND_BOX} bg-blue-400 dark:bg-blue-500"></div>
<span>Snapshots</span>
</div>
</div>
</div>
`;
return legend;
}
function showBackupDetails(date, dayData) {
// This will be implemented in the next step to show drill-down details
// TODO: Create modal or expand section with backup details
}
return {
createCalendarHeatmap
};
})();
+1 -1
View File
@@ -1041,7 +1041,7 @@ PulseApp.ui.pbs = (() => {
const heading = document.createElement('h4');
heading.className = `${CSS_CLASSES.TEXT_MD} ${CSS_CLASSES.FONT_SEMIBOLD} ${CSS_CLASSES.MB2} ${CSS_CLASSES.TEXT_GRAY_700_DARK_GRAY_300}`;
heading.textContent = 'PBS Task Summary (Last 30 Days)';
heading.textContent = 'PBS Task Summary';
sectionDiv.appendChild(heading);
const tableContainer = document.createElement('div');