feat: improve screenshots and mobile UI responsiveness
- Update screenshot tool to use MacBook Air resolution (2560x1600) - Remove empty side borders from screenshots - Use mock data for all screenshots for privacy - Fix mobile alert buttons overflowing viewport - Exempt localhost from API rate limiting for better dev experience - Update documentation to showcase all features with screenshots - Reorganize README visual tour into feature sections
@@ -539,10 +539,26 @@ make dev
|
||||
|
||||
See Pulse in action with our [complete screenshot gallery →](docs/SCREENSHOTS.md)
|
||||
|
||||
| Dashboard | Alert History | Mobile View |
|
||||
|-----------|---------------|-------------|
|
||||
|  |  |  |
|
||||
| *Monitor all nodes, VMs, and containers* | *Track and analyze alert patterns* | *Full mobile responsiveness* |
|
||||
### Core Features
|
||||
|
||||
| Dashboard | Storage | Backups |
|
||||
|-----------|---------|---------|
|
||||
|  |  |  |
|
||||
| *Real-time monitoring of nodes, VMs & containers* | *Storage pool usage across all nodes* | *Unified backup management & PBS integration* |
|
||||
|
||||
### Alerts & Configuration
|
||||
|
||||
| Alert Configuration | Alert History | Settings |
|
||||
|---------------------|---------------|----------|
|
||||
|  |  |  |
|
||||
| *Configure thresholds & notifications* | *Track patterns with visual timeline* | *Manage nodes & authentication* |
|
||||
|
||||
### Mobile Experience
|
||||
|
||||
| Mobile Dashboard |
|
||||
|------------------|
|
||||
|  |
|
||||
| *Fully responsive interface for monitoring on the go* |
|
||||
|
||||
## Links
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Pulse Screenshots
|
||||
|
||||
## Dashboard Overview
|
||||
## Dashboard Overview (Dark Mode)
|
||||

|
||||
*Real-time monitoring dashboard showing 7 Proxmox nodes with 35 VMs and 56 containers. Color-coded resource usage (CPU, RAM, storage) with quick status indicators for running/stopped guests. Automatic layout adapts to cluster size - compact cards for 5-9 nodes.*
|
||||
*Real-time monitoring dashboard showing 7 Proxmox nodes with 35 VMs and 56 containers. Color-coded resource usage (CPU, RAM, storage) with quick status indicators for running/stopped guests. Automatic layout adapts to cluster size - compact cards for 5-9 nodes. Professional dark theme optimized for 24/7 monitoring setups.*
|
||||
|
||||
## Storage Management
|
||||

|
||||
@@ -12,9 +12,9 @@
|
||||

|
||||
*Centralized backup management showing PBS backups, PVE backup tasks, and VM snapshots in one place. Track backup status, sizes, retention, and quickly identify failed or missing backups across your entire infrastructure.*
|
||||
|
||||
## Alert Configuration
|
||||

|
||||
*Configure monitoring thresholds and notification settings. Set global defaults or per-resource overrides for CPU, memory, and disk alerts. Supports quiet hours, cooldown periods, and alert grouping to prevent notification spam.*
|
||||
## Alerts & Configuration
|
||||

|
||||
*Unified alerts view showing active alerts and configuration settings. Monitor current system alerts with severity indicators, affected resources, and acknowledgment status. Configure thresholds, notification settings, quiet hours, and alert grouping. Bulk actions allow managing multiple alerts simultaneously.*
|
||||
|
||||
## Alert History & Analytics
|
||||

|
||||
@@ -24,10 +24,6 @@
|
||||

|
||||
*Manage Proxmox nodes and PBS instances through the UI. Add/remove nodes, configure credentials securely (encrypted at rest), set polling intervals, and manage authentication settings. Quick health check shows connection status for all nodes.*
|
||||
|
||||
## Dark Mode Theme
|
||||

|
||||
*Professional dark theme optimized for 24/7 monitoring setups. High contrast design reduces eye strain during extended viewing sessions while maintaining excellent readability of metrics and status indicators.*
|
||||
|
||||
## Mobile Responsive Design
|
||||

|
||||
*Fully responsive mobile interface for monitoring on the go. Touch-optimized controls, collapsible navigation, and adaptive layouts ensure full functionality on smartphones and tablets without compromising usability.*
|
||||
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 484 KiB |
|
Before Width: | Height: | Size: 911 KiB After Width: | Height: | Size: 396 KiB |
|
Before Width: | Height: | Size: 943 KiB After Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 405 KiB After Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 827 KiB After Width: | Height: | Size: 312 KiB |
|
Before Width: | Height: | Size: 386 KiB After Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 202 KiB After Width: | Height: | Size: 112 KiB |
@@ -897,29 +897,31 @@ function OverviewTab(props: {
|
||||
|
||||
{/* Recent Alerts */}
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">Active Alerts</h3>
|
||||
<Show when={Object.keys(props.activeAlerts).length > 0}>
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={selectedAlerts().size > 0}>
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400">
|
||||
{selectedAlerts().size} selected
|
||||
</span>
|
||||
<button
|
||||
class="px-3 py-1 text-xs bg-yellow-600 text-white rounded hover:bg-yellow-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={processingBulk()}
|
||||
onClick={bulkAcknowledge}
|
||||
>
|
||||
{processingBulk() ? 'Processing...' : 'Acknowledge Selected'}
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1 text-xs bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={processingBulk()}
|
||||
onClick={bulkClear}
|
||||
>
|
||||
{processingBulk() ? 'Processing...' : 'Clear Selected'}
|
||||
</button>
|
||||
</Show>
|
||||
<div class="flex flex-col gap-2 mb-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">Active Alerts</h3>
|
||||
<Show when={Object.keys(props.activeAlerts).length > 0 && selectedAlerts().size > 0}>
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400">
|
||||
{selectedAlerts().size} selected
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={Object.keys(props.activeAlerts).length > 0 && selectedAlerts().size > 0}>
|
||||
<div class="flex gap-1 justify-start sm:justify-end max-w-full">
|
||||
<button
|
||||
class="flex-shrink px-2 py-1 text-xs bg-yellow-600 text-white rounded hover:bg-yellow-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={processingBulk()}
|
||||
onClick={bulkAcknowledge}
|
||||
>
|
||||
Ack
|
||||
</button>
|
||||
<button
|
||||
class="flex-shrink px-2 py-1 text-xs bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={processingBulk()}
|
||||
onClick={bulkClear}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -954,37 +956,39 @@ function OverviewTab(props: {
|
||||
} ${
|
||||
selectedAlerts().has(alert.id) ? 'ring-2 ring-blue-500' : ''
|
||||
}`}>
|
||||
<div class="flex items-start">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="mt-1 mr-3 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 dark:bg-gray-700"
|
||||
checked={selectedAlerts().has(alert.id)}
|
||||
onChange={() => toggleAlertSelection(alert.id)}
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class={`text-sm font-medium ${
|
||||
alert.level === 'critical' ? 'text-red-700 dark:text-red-400' : 'text-yellow-700 dark:text-yellow-400'
|
||||
}`}>
|
||||
{alert.resourceName}
|
||||
</span>
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400">
|
||||
({alert.type})
|
||||
</span>
|
||||
<Show when={alert.acknowledged}>
|
||||
<span class="px-2 py-0.5 text-xs bg-yellow-200 dark:bg-yellow-800 text-yellow-800 dark:text-yellow-200 rounded">
|
||||
Acknowledged
|
||||
<div class="flex flex-col sm:flex-row sm:items-start">
|
||||
<div class="flex items-start flex-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="mt-1 mr-3 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 dark:bg-gray-700"
|
||||
checked={selectedAlerts().has(alert.id)}
|
||||
onChange={() => toggleAlertSelection(alert.id)}
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class={`text-sm font-medium truncate ${
|
||||
alert.level === 'critical' ? 'text-red-700 dark:text-red-400' : 'text-yellow-700 dark:text-yellow-400'
|
||||
}`}>
|
||||
{alert.resourceName}
|
||||
</span>
|
||||
</Show>
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400">
|
||||
({alert.type})
|
||||
</span>
|
||||
<Show when={alert.acknowledged}>
|
||||
<span class="px-2 py-0.5 text-xs bg-yellow-200 dark:bg-yellow-800 text-yellow-800 dark:text-yellow-200 rounded">
|
||||
Acknowledged
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300 mt-1 break-words">
|
||||
{alert.message}
|
||||
</p>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
Started: {new Date(alert.startTime).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300 mt-1">
|
||||
{alert.message}
|
||||
</p>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
Started: {new Date(alert.startTime).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2 ml-4">
|
||||
<div class="flex gap-2 mt-3 sm:mt-0 sm:ml-4 self-end sm:self-start">
|
||||
<Show when={!alert.acknowledged}>
|
||||
<button
|
||||
class="px-3 py-1 text-xs bg-yellow-600 text-white rounded hover:bg-yellow-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
|
||||
@@ -127,12 +127,18 @@ func UniversalRateLimitMiddleware(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
// Get appropriate rate limiter for this endpoint
|
||||
limiter := GetRateLimiterForEndpoint(r.URL.Path, r.Method)
|
||||
|
||||
// Extract client IP
|
||||
ip := GetClientIP(r)
|
||||
|
||||
// Skip rate limiting for localhost/development
|
||||
if ip == "127.0.0.1" || ip == "::1" || ip == "localhost" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Get appropriate rate limiter for this endpoint
|
||||
limiter := GetRateLimiterForEndpoint(r.URL.Path, r.Method)
|
||||
|
||||
// Check rate limit
|
||||
if !limiter.Allow(ip) {
|
||||
// Add retry-after header
|
||||
|
||||
@@ -32,13 +32,13 @@ async function main() {
|
||||
|
||||
console.log('Starting simple Puppeteer screenshot capture...\n');
|
||||
|
||||
// Launch browser with high-quality rendering options
|
||||
// Launch browser with MacBook Air-like display settings
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new', // Use new headless mode
|
||||
defaultViewport: null, // Don't override viewport
|
||||
args: [
|
||||
'--window-size=1920,1080',
|
||||
'--force-device-scale-factor=3', // Force 3x DPI rendering at browser level
|
||||
'--window-size=1280,800', // MacBook Air effective resolution
|
||||
'--force-device-scale-factor=2', // Retina display (2x)
|
||||
'--disable-dev-shm-usage', // Better memory handling
|
||||
'--enable-font-antialiasing', // Smooth fonts
|
||||
'--font-render-hinting=none' // Disable hinting for sharper text
|
||||
@@ -49,10 +49,11 @@ async function main() {
|
||||
|
||||
// CRITICAL: Set viewport BEFORE navigating for sharp rendering!
|
||||
// This must happen before page.goto() or text will be blurry
|
||||
// MacBook Air 13" display dimensions
|
||||
await page.setViewport({
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
deviceScaleFactor: 3 // Use 3x for ultra-sharp text (better than 2x)
|
||||
width: 1280,
|
||||
height: 800,
|
||||
deviceScaleFactor: 2 // Retina display
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -115,7 +116,7 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Take screenshot
|
||||
// Take screenshot - MacBook Air resolution should fit content perfectly
|
||||
await page.screenshot({
|
||||
path: path.join(OUTPUT_DIR, `${view.file}.png`),
|
||||
type: 'png'
|
||||
@@ -124,6 +125,9 @@ async function main() {
|
||||
console.log(`Saved ${view.file}.png`);
|
||||
}
|
||||
|
||||
// Note: Active alerts are shown on the main Alerts tab (04-alerts.png)
|
||||
// The tab has Config and History sub-tabs, no separate Active tab
|
||||
|
||||
// Take alert history screenshot - need to click on History sub-tab
|
||||
console.log('Capturing alert history...');
|
||||
|
||||
@@ -142,31 +146,53 @@ async function main() {
|
||||
if (historyTab) historyTab.click();
|
||||
});
|
||||
|
||||
// Wait for history to load naturally from the mock API
|
||||
await wait(5000);
|
||||
// Wait for alert history to load with retry logic
|
||||
console.log('Waiting for alert history to load...');
|
||||
let retries = 0;
|
||||
const maxRetries = 15; // 15 seconds max
|
||||
|
||||
// Take the screenshot
|
||||
while (retries < maxRetries) {
|
||||
await wait(1000);
|
||||
|
||||
// Check if data has loaded by looking for alert rows in the DOM
|
||||
const hasData = await page.evaluate(() => {
|
||||
// Look for alert history rows or the "No alerts" message
|
||||
const rows = document.querySelectorAll('tbody tr, [data-testid="alert-row"], .alert-history-row');
|
||||
const noDataMsg = Array.from(document.querySelectorAll('*')).find(
|
||||
el => el.textContent && el.textContent.includes('No alerts in selected time range')
|
||||
);
|
||||
return rows.length > 0 || noDataMsg;
|
||||
});
|
||||
|
||||
if (hasData) {
|
||||
console.log('Alert history loaded after', retries + 1, 'seconds');
|
||||
break;
|
||||
}
|
||||
|
||||
retries++;
|
||||
}
|
||||
|
||||
// Extra wait for any animations
|
||||
await wait(1000);
|
||||
|
||||
// Take the screenshot - MacBook Air resolution
|
||||
await page.screenshot({
|
||||
path: path.join(OUTPUT_DIR, '05-alert-history.png'),
|
||||
type: 'png'
|
||||
});
|
||||
console.log('Saved 05-alert-history.png');
|
||||
|
||||
// Copy dashboard as dark mode showcase
|
||||
await fs.copyFile(
|
||||
path.join(OUTPUT_DIR, '01-dashboard.png'),
|
||||
path.join(OUTPUT_DIR, '07-dark-mode.png')
|
||||
);
|
||||
// Note: Dashboard is already in dark mode, no need for duplicate or light mode
|
||||
|
||||
// Mobile view
|
||||
console.log('\nCapturing mobile view...');
|
||||
|
||||
// Set mobile viewport BEFORE reload for sharp rendering
|
||||
await page.setViewport({
|
||||
width: 390,
|
||||
height: 844,
|
||||
width: 375, // iPhone standard width
|
||||
height: 667, // iPhone standard height
|
||||
isMobile: true,
|
||||
deviceScaleFactor: 3 // 3x for ultra-sharp mobile screenshots
|
||||
deviceScaleFactor: 2 // 2x for standard retina
|
||||
});
|
||||
|
||||
// Now reload with the new viewport already set
|
||||
|
||||