mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-23 19:57:09 +00:00
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:
+32
-3
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -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
|
||||
};
|
||||
})();
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user