feat: Unified Resource Model and Navigation Redesign

## Summary
Complete implementation of the Unified Resource Model with new navigation.

## Features
- v2 resources API with identity matching across sources (Proxmox, Agent, Docker)
- Infrastructure page with merged host view
- Workloads page for all VMs/LXC/Docker containers
- Global search (Cmd/Ctrl+K) with keyboard navigation
- Mobile navigation with bottom tabs and drawer
- Keyboard shortcuts (g+key navigation, ? for help)
- What's New modal for user onboarding
- Report Incorrect Merge feature for false positive fixes
- Debug tab in resource drawer (enable via localStorage)

## Technical
- Async audit logging for improved performance
- WebSocket-driven real-time updates for unified resources
- Session-based auth achieves <2ms API response times

## Tests
- Backend: 78 tests passed
- Frontend: 397 tests passed
This commit is contained in:
rcourtman
2026-02-05 15:30:30 +00:00
parent 1821cbd2a3
commit 1edfa4311e
27 changed files with 2781 additions and 205 deletions

View File

@@ -22,6 +22,18 @@ Designed for homelabs, sysadmins, and MSPs who need a "single pane of glass" wit
![Pulse Dashboard](docs/images/01-dashboard.jpg)
## 🧭 Unified Navigation
Pulse now groups everything by task instead of data source:
- **Infrastructure** for hosts and nodes
- **Workloads** for VMs and containers
- **Storage** and **Backups** as top-level views
- **Services** for PMG instances (when connected)
Power-user shortcuts:
- `g i` → Infrastructure, `g w` → Workloads, `?` → shortcuts help
- `/` or `Cmd/Ctrl+K` → global search
## ✨ Features
### Core Monitoring

View File

@@ -0,0 +1,43 @@
# Migration Guide: Unified Navigation
This guide explains what changed in the unified navigation release and how to find the new locations for legacy pages.
## What Changed
- Navigation is now organized by **task** (Infrastructure, Workloads, Storage, Backups, Services) instead of by platform.
- Legacy pages (Proxmox Overview, Hosts, Docker) redirect to unified views.
- Global search and keyboard shortcuts make navigation faster across all resources.
## Where Old Pages Moved
| Legacy Page | New Location |
|------------|--------------|
| Proxmox Overview | `/infrastructure` |
| Hosts | `/infrastructure` |
| Docker | `/workloads` (containers) + `/infrastructure` (hosts) |
| Proxmox Storage | `/storage` |
| Proxmox Backups | `/backups` |
| Proxmox Replication | `/backups` (Replication tab) |
| Proxmox Mail Gateway | `/services` |
## New Features to Know
### Global Search
- Press `/` or `Cmd/Ctrl+K` to open search.
- Search by name, node, type, tags, or status.
- Results navigate directly to the relevant view.
### Keyboard Shortcuts
- `g i` → Infrastructure
- `g w` → Workloads
- `g s` → Storage
- `g b` → Backups
- `g a` → Alerts
- `g t` → Settings
- `?` → Shortcut help
### Debug Drawer (Optional)
- Enable with localStorage key `pulse_debug_mode` for raw JSON in resource drawer.
## Tips
- If you used Docker and Hosts pages before, start with **Infrastructure** (hosts) and **Workloads** (containers).
- The new pages support unified filters, tags, and search across all sources.

View File

@@ -35,6 +35,10 @@ import { updateStore } from './stores/updates';
import { UpdateBanner } from './components/UpdateBanner';
import { DemoBanner } from './components/DemoBanner';
import { GitHubStarBanner } from './components/GitHubStarBanner';
import { WhatsNewModal } from './components/shared/WhatsNewModal';
import { KeyboardShortcutsModal } from './components/shared/KeyboardShortcutsModal';
import { CommandPaletteModal } from './components/shared/CommandPaletteModal';
import { MobileNavBar } from './components/shared/MobileNavBar';
import { createTooltipSystem } from './components/shared/Tooltip';
import type { State, Alert } from '@/types/api';
import { ProxmoxIcon } from '@/components/icons/ProxmoxIcon';
@@ -42,6 +46,10 @@ import { startMetricsSampler } from './stores/metricsSampler';
import { seedFromBackend } from './stores/metricsHistory';
import { getMetricsViewMode } from './stores/metricsViewMode';
import BoxesIcon from 'lucide-solid/icons/boxes';
import ServerIcon from 'lucide-solid/icons/server';
import HardDriveIcon from 'lucide-solid/icons/hard-drive';
import ArchiveIcon from 'lucide-solid/icons/archive';
import WrenchIcon from 'lucide-solid/icons/wrench';
import MonitorIcon from 'lucide-solid/icons/monitor';
import BellIcon from 'lucide-solid/icons/bell';
import SettingsIcon from 'lucide-solid/icons/settings';
@@ -56,15 +64,17 @@ import type { UpdateStatus } from './api/updates';
import { AIChat } from './components/AI/Chat';
import { aiChatStore } from './stores/aiChat';
import { useResourcesAsLegacy } from './hooks/useResources';
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
import { updateSystemSettingsFromResponse, markSystemSettingsLoadedWithDefaults } from './stores/systemSettings';
import { initKioskMode, isKioskMode, setKioskMode, subscribeToKioskMode, getKioskModePreference } from './utils/url';
import { showToast } from '@/utils/toast';
import { GlobalSearch } from '@/components/shared/GlobalSearch';
const Dashboard = lazy(() =>
import('./components/Dashboard/Dashboard').then((module) => ({ default: module.Dashboard })),
);
const StorageComponent = lazy(() => import('./components/Storage/Storage'));
const Backups = lazy(() => import('./components/Backups/Backups'));
const UnifiedBackups = lazy(() => import('./components/Backups/UnifiedBackups'));
const Replication = lazy(() => import('./components/Replication/Replication'));
const MailGateway = lazy(() => import('./components/PMG/MailGateway'));
@@ -824,15 +834,6 @@ function App() {
// Pass through the store directly (only when initialized)
const enhancedStore = () => wsStore();
// Dashboard view - uses unified resources via useResourcesAsLegacy hook
const DashboardView = () => {
const { asVMs, asContainers, asNodes } = useResourcesAsLegacy();
return (
<Dashboard vms={asVMs() as any} containers={asContainers() as any} nodes={asNodes() as any} />
);
};
// Workloads view - uses v2 workloads with legacy fallback
const WorkloadsView = () => {
const { asVMs, asContainers, asNodes } = useResourcesAsLegacy();
@@ -847,13 +848,53 @@ function App() {
);
};
const LegacyRedirect = (props: { to: string; toast?: { type: 'info' | 'success' | 'warning' | 'error'; title: string; message?: string } }) => {
const navigate = useNavigate();
onMount(() => {
if (props.toast) {
showToast(props.toast.type, props.toast.title, props.toast.message);
}
navigate(props.to, { replace: true });
});
return null;
};
const SettingsRoute = () => (
<SettingsPage darkMode={darkMode} toggleDarkMode={toggleDarkMode} />
);
// Root layout component for Router
const RootLayout = (props: { children?: JSX.Element }) => {
// Check AI settings on mount and setup keyboard shortcut
const [shortcutsOpen, setShortcutsOpen] = createSignal(false);
const [commandPaletteOpen, setCommandPaletteOpen] = createSignal(false);
const focusGlobalSearch = () => {
if (typeof document === 'undefined') return false;
const el = document.querySelector<HTMLInputElement>('[data-global-search]');
if (!el) return false;
el.focus();
el.select?.();
return true;
};
useKeyboardShortcuts({
enabled: () => !needsAuth(),
isShortcutsOpen: shortcutsOpen,
isCommandPaletteOpen: commandPaletteOpen,
onToggleShortcuts: () => {
setCommandPaletteOpen(false);
setShortcutsOpen((prev) => !prev);
},
onCloseShortcuts: () => setShortcutsOpen(false),
onToggleCommandPalette: () => {
setShortcutsOpen(false);
setCommandPaletteOpen((prev) => !prev);
},
onCloseCommandPalette: () => setCommandPaletteOpen(false),
onFocusSearch: focusGlobalSearch,
});
// Check AI settings on mount and setup escape handling
onMount(() => {
// Only check AI settings if already authenticated (not on login screen)
// Otherwise, the 401 response triggers a redirect loop
@@ -869,18 +910,11 @@ function App() {
})
.catch(() => {
aiChatStore.setEnabled(false);
});
});
});
}
// Keyboard shortcut: Cmd/Ctrl+K to toggle AI
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
if (aiChatStore.enabled) {
aiChatStore.toggle();
}
}
// Escape to close
if (e.key === 'Escape' && aiChatStore.isOpen) {
aiChatStore.close();
@@ -916,6 +950,7 @@ function App() {
<DemoBanner />
<UpdateBanner />
<GitHubStarBanner />
<WhatsNewModal />
<GlobalUpdateProgressWatcher />
</Show>
{/* Main layout container - flexbox to allow AI panel to push content */}
@@ -941,6 +976,14 @@ function App() {
{/* AI Panel - slides in from right, pushes content */}
<AIChat onClose={() => aiChatStore.close()} />
</div>
<KeyboardShortcutsModal
isOpen={shortcutsOpen()}
onClose={() => setShortcutsOpen(false)}
/>
<CommandPaletteModal
isOpen={commandPaletteOpen()}
onClose={() => setCommandPaletteOpen(false)}
/>
<TokenRevealDialog />
{/* AI Assistant Button moved to AppLayout to access kioskMode state */}
<TooltipRoot />
@@ -959,19 +1002,35 @@ function App() {
<Router root={RootLayout}>
<Route path="/" component={() => <Navigate href="/proxmox/overview" />} />
<Route path="/proxmox" component={() => <Navigate href="/proxmox/overview" />} />
<Route path="/proxmox/overview" component={DashboardView} />
<Route
path="/proxmox/overview"
component={() => (
<LegacyRedirect
to="/infrastructure"
toast={{ type: 'info', title: 'Dashboard moved to Infrastructure' }}
/>
)}
/>
<Route path="/workloads" component={WorkloadsView} />
<Route path="/proxmox/storage" component={StorageComponent} />
<Route path="/proxmox/storage" component={() => <LegacyRedirect to="/storage" />} />
<Route path="/proxmox/ceph" component={CephPage} />
<Route path="/proxmox/replication" component={Replication} />
<Route path="/proxmox/mail" component={MailGateway} />
<Route path="/proxmox/backups" component={Backups} />
<Route path="/proxmox/backups" component={() => <LegacyRedirect to="/backups" />} />
<Route path="/storage" component={StorageComponent} />
<Route path="/backups" component={UnifiedBackups} />
<Route path="/services" component={Services} />
<Route path="/docker" component={DockerRoute} />
<Route
path="/docker"
component={() => (
<LegacyRedirect
to="/infrastructure?source=docker"
toast={{ type: 'info', title: 'Docker hosts moved to Infrastructure' }}
/>
)}
/>
<Route path="/kubernetes" component={KubernetesRoute} />
<Route path="/hosts" component={HostsRoute} />
<Route path="/hosts" component={() => <LegacyRedirect to="/infrastructure?source=agent" />} />
<Route path="/infrastructure" component={InfrastructurePage} />
<Route path="/servers" component={() => <Navigate href="/hosts" />} />
@@ -1124,6 +1183,11 @@ function AppLayout(props: {
// Determine active tab from current path
const getActiveTab = () => {
const path = location.pathname;
if (path.startsWith('/infrastructure')) return 'infrastructure';
if (path.startsWith('/workloads')) return 'workloads';
if (path.startsWith('/storage')) return 'storage';
if (path.startsWith('/backups')) return 'backups';
if (path.startsWith('/services')) return 'services';
if (path.startsWith('/proxmox')) return 'proxmox';
if (path.startsWith('/docker')) return 'docker';
if (path.startsWith('/kubernetes')) return 'kubernetes';
@@ -1137,6 +1201,7 @@ function AppLayout(props: {
const hasDockerHosts = createMemo(() => (props.state().dockerHosts?.length ?? 0) > 0);
const hasKubernetesClusters = createMemo(() => (props.state().kubernetesClusters?.length ?? 0) > 0);
const hasHosts = createMemo(() => (props.state().hosts?.length ?? 0) > 0);
const hasPMGServices = createMemo(() => (props.state().pmg?.length ?? 0) > 0);
const hasProxmoxHosts = createMemo(
() =>
(props.state().nodes?.length ?? 0) > 0 ||
@@ -1168,33 +1233,113 @@ function AppLayout(props: {
}
});
type PlatformTab = {
id: string;
label: string;
route: string;
settingsRoute: string;
tooltip: string;
enabled: boolean;
live: boolean;
icon: JSX.Element;
alwaysShow: boolean;
badge?: string;
};
const platformTabs = createMemo(() => {
const allPlatforms = [
const allPlatforms: PlatformTab[] = [
{
id: 'infrastructure' as const,
label: 'Infrastructure',
route: '/infrastructure',
settingsRoute: '/settings',
tooltip: 'All hosts and nodes across platforms',
enabled: true,
live: true,
icon: (
<ServerIcon class="w-4 h-4 shrink-0" />
),
alwaysShow: true,
},
{
id: 'workloads' as const,
label: 'Workloads',
route: '/workloads',
settingsRoute: '/settings',
tooltip: 'VMs, containers, and Kubernetes workloads',
enabled: true,
live: true,
icon: (
<BoxesIcon class="w-4 h-4 shrink-0" />
),
alwaysShow: true,
},
{
id: 'storage' as const,
label: 'Storage',
route: '/storage',
settingsRoute: '/settings',
tooltip: 'Storage pools, disks, and Ceph',
enabled: true,
live: true,
icon: (
<HardDriveIcon class="w-4 h-4 shrink-0" />
),
alwaysShow: true,
},
{
id: 'backups' as const,
label: 'Backups',
route: '/backups',
settingsRoute: '/settings',
tooltip: 'Backup jobs, history, and replication',
enabled: true,
live: true,
icon: (
<ArchiveIcon class="w-4 h-4 shrink-0" />
),
alwaysShow: true,
},
{
id: 'services' as const,
label: 'Services',
route: '/services',
settingsRoute: '/settings',
tooltip: 'Mail gateway status and service health',
enabled: hasPMGServices(),
live: hasPMGServices(),
icon: (
<WrenchIcon class="w-4 h-4 shrink-0" />
),
alwaysShow: false,
},
{
id: 'proxmox' as const,
label: 'Proxmox',
label: 'Proxmox Overview',
route: '/proxmox/overview',
settingsRoute: '/settings',
tooltip: 'Monitor Proxmox clusters and nodes',
tooltip: 'Legacy Proxmox dashboard',
enabled: hasProxmoxHosts() || !!seenPlatforms()['proxmox'],
live: hasProxmoxHosts(),
icon: (
<ProxmoxIcon class="w-4 h-4 shrink-0" />
),
alwaysShow: true, // Proxmox is the default, always show
badge: 'Legacy',
},
{
id: 'docker' as const,
label: 'Docker',
route: '/docker',
settingsRoute: '/settings/docker',
tooltip: 'Monitor Docker hosts and containers',
tooltip: 'Legacy Docker hosts and containers',
enabled: hasDockerHosts() || !!seenPlatforms()['docker'],
live: hasDockerHosts(),
icon: (
<BoxesIcon class="w-4 h-4 shrink-0" />
),
alwaysShow: true, // Docker is commonly used, keep visible
badge: 'Legacy',
},
{
id: 'kubernetes' as const,
@@ -1214,13 +1359,14 @@ function AppLayout(props: {
label: 'Hosts',
route: '/hosts',
settingsRoute: '/settings/host-agents',
tooltip: 'Monitor hosts with the host agent',
tooltip: 'Legacy hosts view',
enabled: hasHosts() || !!seenPlatforms()['hosts'],
live: hasHosts(),
icon: (
<MonitorIcon class="w-4 h-4 shrink-0" />
),
alwaysShow: true, // Hosts is commonly used, keep visible
badge: 'Legacy',
},
];
@@ -1314,7 +1460,9 @@ function AppLayout(props: {
};
return (
<div class={`pulse-shell ${layoutStore.isFullWidth() || kioskMode() ? 'pulse-shell--full-width' : ''}`}>
<div
class={`pulse-shell ${layoutStore.isFullWidth() || kioskMode() ? 'pulse-shell--full-width' : ''} ${!kioskMode() ? 'pb-20 md:pb-0' : ''}`}
>
{/* Header - simplified in kiosk mode */}
<div class={`header mb-3 flex items-center gap-2 ${kioskMode() ? 'justify-end' : 'justify-between sm:grid sm:grid-cols-[1fr_auto_1fr] sm:items-center sm:gap-0'}`}>
<Show when={!kioskMode()}>
@@ -1411,11 +1559,16 @@ function AppLayout(props: {
/>
</div>
</div>
<Show when={!kioskMode()}>
<div class="mb-3 flex items-center justify-center px-2 md:justify-end">
<GlobalSearch />
</div>
</Show>
{/* Tabs - hidden in kiosk mode */}
<Show when={!kioskMode()}>
<div
class="tabs mb-2 flex items-end gap-2 overflow-x-auto overflow-y-hidden whitespace-nowrap border-b border-gray-300 dark:border-gray-700 scrollbar-hide"
class="tabs mb-2 hidden md:flex items-end gap-2 overflow-x-auto overflow-y-hidden whitespace-nowrap border-b border-gray-300 dark:border-gray-700 scrollbar-hide"
role="tablist"
aria-label="Primary navigation"
>
@@ -1451,7 +1604,14 @@ function AppLayout(props: {
title={title()}
>
{platform.icon}
<span class="hidden xs:inline">{platform.label}</span>
<span class="hidden xs:inline-flex items-center gap-1">
<span>{platform.label}</span>
<Show when={platform.badge}>
<span class="px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400 bg-gray-200/70 dark:bg-gray-700/60 rounded">
{platform.badge}
</span>
</Show>
</span>
<span class="xs:hidden">{platform.label.charAt(0)}</span>
</div>
);
@@ -1538,6 +1698,16 @@ function AppLayout(props: {
</div>
</main>
<Show when={!kioskMode()}>
<MobileNavBar
activeTab={getActiveTab}
platformTabs={platformTabs}
utilityTabs={utilityTabs}
onPlatformClick={handlePlatformClick}
onUtilityClick={handleUtilityClick}
/>
</Show>
{/* Footer - hidden in kiosk mode */}
<Show when={!kioskMode()}>
<footer class="text-center text-xs text-gray-500 dark:text-gray-400 py-4">

View File

@@ -1,5 +1,5 @@
import { createSignal, createMemo, createEffect, For, Show, onMount } from 'solid-js';
import { useNavigate } from '@solidjs/router';
import { useLocation, useNavigate } from '@solidjs/router';
import type { VM, Container, Node } from '@/types/api';
import type { WorkloadGuest } from '@/types/workloads';
import { GuestRow, GUEST_COLUMNS, type GuestColumnDef } from './GuestRow';
@@ -209,6 +209,7 @@ type StatusMode = 'all' | 'running' | 'degraded' | 'stopped';
type GroupingMode = 'grouped' | 'flat';
export function Dashboard(props: DashboardProps) {
const navigate = useNavigate();
const location = useLocation();
const ws = useWebSocket();
const { connected, activeAlerts, initialDataReceived, reconnecting, reconnect } = ws;
const { isMobile } = useBreakpoint();
@@ -232,6 +233,22 @@ export function Dashboard(props: DashboardProps) {
const [isSearchLocked, setIsSearchLocked] = createSignal(false);
const [selectedNode, setSelectedNode] = createSignal<string | null>(null);
const [selectedGuestId, setSelectedGuestIdRaw] = createSignal<string | null>(null);
const [handledResourceId, setHandledResourceId] = createSignal<string | null>(null);
createEffect(() => {
const params = new URLSearchParams(location.search);
const resourceId = params.get('resource');
if (!resourceId || resourceId === handledResourceId()) return;
setSelectedGuestId(resourceId);
const [instance, node, vmid] = resourceId.split(':');
if (instance && node && vmid) {
const knownNode = props.nodes.find((item) => item.id === instance || item.node === node || item.name === node);
if (knownNode) {
setSelectedNode(knownNode.id);
}
}
setHandledResourceId(resourceId);
});
// Wrap setSelectedGuestId to preserve scroll position. Opening/closing the
// drawer mounts/unmounts GuestDrawer (which contains DiscoveryTab). The

View File

@@ -0,0 +1,170 @@
import { Component, Show, For, createMemo, createSignal } from 'solid-js';
import { apiFetch } from '@/utils/apiClient';
import { showError, showSuccess } from '@/utils/toast';
import XIcon from 'lucide-solid/icons/x';
interface ReportMergeModalProps {
isOpen: boolean;
resourceId: string;
resourceName: string;
sources: string[];
onClose: () => void;
onReported?: () => void;
}
const formatSourceLabel = (source: string) => {
const normalized = source.toLowerCase();
switch (normalized) {
case 'proxmox':
return 'Proxmox';
case 'agent':
return 'Agent';
case 'docker':
return 'Docker';
case 'pbs':
return 'PBS';
case 'pmg':
return 'PMG';
case 'kubernetes':
return 'Kubernetes';
default:
return source;
}
};
export const ReportMergeModal: Component<ReportMergeModalProps> = (props) => {
const [notes, setNotes] = createSignal('');
const [isSubmitting, setIsSubmitting] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
const sourceLabels = createMemo(() =>
props.sources.map((source) => formatSourceLabel(source)),
);
const handleSubmit = async () => {
if (isSubmitting()) return;
setIsSubmitting(true);
setError(null);
try {
const response = await apiFetch(
`/api/v2/resources/${encodeURIComponent(props.resourceId)}/report-merge`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sources: props.sources,
notes: notes().trim() || undefined,
}),
},
);
if (!response.ok) {
const message = await response.text().catch(() => '');
throw new Error(message || 'Failed to report merge');
}
showSuccess('Thanks! This merge will be reviewed');
props.onReported?.();
props.onClose();
setNotes('');
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to report merge';
setError(message);
showError('Unable to report merge', message);
} finally {
setIsSubmitting(false);
}
};
return (
<Show when={props.isOpen}>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div class="w-full max-w-lg overflow-hidden rounded-xl bg-white shadow-2xl dark:bg-gray-800">
<div class="flex items-start justify-between border-b border-gray-200 px-5 py-4 dark:border-gray-700">
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
Report Incorrect Merge
</h3>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
We&#39;ll split this resource into separate entries in the next refresh.
</p>
</div>
<button
type="button"
onClick={props.onClose}
class="rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300"
aria-label="Close"
>
<XIcon class="h-5 w-5" />
</button>
</div>
<div class="space-y-4 px-5 py-4 text-sm text-gray-700 dark:text-gray-200">
<div>
<div class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Resource
</div>
<div class="mt-1 font-medium">{props.resourceName}</div>
<div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">{props.resourceId}</div>
</div>
<div>
<div class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Merged Sources
</div>
<div class="mt-2 flex flex-wrap gap-2">
<For each={sourceLabels()}>
{(label) => (
<span class="rounded-full bg-blue-100 px-2.5 py-1 text-[11px] font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
{label}
</span>
)}
</For>
</div>
</div>
<div>
<label class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Notes (optional)
</label>
<textarea
value={notes()}
onInput={(event) => setNotes(event.currentTarget.value)}
rows={3}
class="mt-2 w-full rounded-md border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-900/40 dark:text-gray-200"
placeholder="Example: Agent running on a different host with same hostname."
/>
</div>
<Show when={error()}>
<div class="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-900/50 dark:bg-red-900/30 dark:text-red-200">
{error()}
</div>
</Show>
</div>
<div class="flex items-center justify-end gap-2 border-t border-gray-200 bg-gray-50 px-5 py-3 dark:border-gray-700 dark:bg-gray-900/40">
<button
type="button"
onClick={props.onClose}
class="rounded-md px-3 py-2 text-xs font-medium text-gray-600 transition-colors hover:text-gray-800 dark:text-gray-300 dark:hover:text-gray-100"
disabled={isSubmitting()}
>
Cancel
</button>
<button
type="button"
onClick={handleSubmit}
disabled={isSubmitting() || props.sources.length < 2}
class="rounded-md bg-blue-600 px-3 py-2 text-xs font-semibold text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{isSubmitting() ? 'Submitting...' : 'Report Merge'}
</button>
</div>
</div>
</div>
</Show>
);
};
export default ReportMergeModal;

View File

@@ -1,4 +1,4 @@
import { Component, Show, createMemo, For } from 'solid-js';
import { Component, Show, createMemo, For, createSignal, createEffect } from 'solid-js';
import type { Disk, Host, HostNetworkInterface, HostSensorSummary, Memory, Node } from '@/types/api';
import type { Resource, ResourceMetric } from '@/types/resource';
import { getDisplayName, getCpuPercent, getMemoryPercent, getDiskPercent } from '@/types/resource';
@@ -16,6 +16,8 @@ import { RootDiskCard } from '@/components/shared/cards/RootDiskCard';
import { NetworkInterfacesCard } from '@/components/shared/cards/NetworkInterfacesCard';
import { DisksCard } from '@/components/shared/cards/DisksCard';
import { TemperaturesCard } from '@/components/shared/cards/TemperaturesCard';
import { createLocalStorageBooleanSignal, STORAGE_KEYS } from '@/utils/localStorage';
import { ReportMergeModal } from './ReportMergeModal';
interface ResourceDetailDrawerProps {
resource: Resource;
@@ -61,6 +63,15 @@ type PlatformData = {
sources?: string[];
proxmox?: ProxmoxPlatformData;
agent?: AgentPlatformData;
sourceStatus?: Record<string, { status?: string; lastSeen?: string | number; error?: string }>;
docker?: Record<string, unknown>;
pbs?: Record<string, unknown>;
kubernetes?: Record<string, unknown>;
metrics?: Record<string, unknown>;
identityMatch?: unknown;
matchResults?: unknown;
matchCandidates?: unknown;
matches?: unknown;
};
const metricSublabel = (metric?: ResourceMetric) => {
@@ -221,6 +232,12 @@ const buildTemperatureRows = (sensors?: HostSensorSummary) => {
};
export const ResourceDetailDrawer: Component<ResourceDetailDrawerProps> = (props) => {
type DrawerTab = 'overview' | 'discovery' | 'metrics' | 'debug';
const [activeTab, setActiveTab] = createSignal<DrawerTab>('overview');
const [debugEnabled] = createLocalStorageBooleanSignal(STORAGE_KEYS.DEBUG_MODE, false);
const [copied, setCopied] = createSignal(false);
const [showReportModal, setShowReportModal] = createSignal(false);
const displayName = createMemo(() => getDisplayName(props.resource));
const statusIndicator = createMemo(() => getHostStatusIndicator({ status: props.resource.status }));
const lastSeen = createMemo(() => formatRelativeTime(props.resource.lastSeen));
@@ -244,6 +261,99 @@ export const ResourceDetailDrawer: Component<ResourceDetailDrawerProps> = (props
const agentHost = createMemo(() => toHostFromAgent(props.resource));
const temperatureRows = createMemo(() => buildTemperatureRows(agentHost()?.sensors));
const platformData = createMemo(() => props.resource.platformData as PlatformData | undefined);
const sourceStatus = createMemo(() => platformData()?.sourceStatus ?? {});
const mergedSources = createMemo(() => platformData()?.sources ?? []);
const hasMergedSources = createMemo(() => mergedSources().length > 1);
const sourceSections = createMemo(() => {
const data = platformData();
if (!data) return [];
const sections = [
{ id: 'proxmox', label: 'Proxmox', payload: data.proxmox },
{ id: 'agent', label: 'Agent', payload: data.agent },
{ id: 'docker', label: 'Docker', payload: data.docker },
{ id: 'pbs', label: 'PBS', payload: data.pbs },
{ id: 'kubernetes', label: 'Kubernetes', payload: data.kubernetes },
{ id: 'metrics', label: 'Metrics', payload: data.metrics },
];
return sections.filter((section) => section.payload !== undefined);
});
const identityMatchInfo = createMemo(() => {
const data = platformData();
return (
data?.identityMatch ??
data?.matchResults ??
data?.matchCandidates ??
data?.matches ??
undefined
);
});
const debugBundle = createMemo(() => ({
resource: props.resource,
identity: {
resourceIdentity: props.resource.identity,
matchInfo: identityMatchInfo(),
},
sources: {
sourceStatus: sourceStatus(),
proxmox: platformData()?.proxmox,
agent: platformData()?.agent,
docker: platformData()?.docker,
pbs: platformData()?.pbs,
kubernetes: platformData()?.kubernetes,
metrics: platformData()?.metrics,
},
}));
const debugJson = createMemo(() => JSON.stringify(debugBundle(), null, 2));
createEffect(() => {
if (!debugEnabled() && activeTab() === 'debug') {
setActiveTab('overview');
}
});
const tabs = createMemo(() => {
const base = [
{ id: 'overview' as DrawerTab, label: 'Overview' },
{ id: 'discovery' as DrawerTab, label: 'Discovery' },
{ id: 'metrics' as DrawerTab, label: 'Metrics' },
];
if (debugEnabled()) {
base.push({ id: 'debug' as DrawerTab, label: 'Debug' });
}
return base;
});
const formatSourceTime = (value?: string | number) => {
if (!value) return '';
const timestamp = typeof value === 'number' ? value : Date.parse(value);
if (!Number.isFinite(timestamp)) return '';
return formatRelativeTime(timestamp);
};
const handleCopyJson = async () => {
const payload = debugJson();
try {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(payload);
} else {
const textarea = document.createElement('textarea');
textarea.value = payload;
textarea.setAttribute('readonly', 'true');
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
}
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
setCopied(false);
}
};
return (
<div class="space-y-3">
<div class="flex items-start justify-between gap-4">
@@ -313,169 +423,295 @@ export const ResourceDetailDrawer: Component<ResourceDetailDrawerProps> = (props
</Show>
</div>
<Show when={proxmoxNode() || agentHost()}>
<div class="flex flex-wrap gap-3 [&>*]:flex-1 [&>*]:basis-[calc(25%-0.75rem)] [&>*]:min-w-[200px] [&>*]:max-w-full [&>*]:overflow-hidden">
<Show when={proxmoxNode()}>
{(node) => (
<>
<SystemInfoCard variant="node" node={node()} />
<HardwareCard variant="node" node={node()} />
<RootDiskCard node={node()} />
</>
)}
</Show>
<Show when={agentHost()}>
{(host) => (
<>
<SystemInfoCard variant="host" host={host()} />
<HardwareCard variant="host" host={host()} />
<NetworkInterfacesCard interfaces={host().networkInterfaces} />
<DisksCard disks={host().disks} />
<TemperaturesCard rows={temperatureRows()} />
</>
)}
</Show>
</div>
</Show>
<div class="flex items-center gap-6 border-b border-gray-200 dark:border-gray-700 px-1 mb-1">
<For each={tabs()}>
{(tab) => (
<button
onClick={() => setActiveTab(tab.id)}
class={`pb-2 text-sm font-medium transition-colors relative ${activeTab() === tab.id
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
}`}
>
{tab.label}
<Show when={activeTab() === tab.id}>
<div class="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600 dark:bg-blue-400 rounded-t-full" />
</Show>
</button>
)}
</For>
</div>
<div class="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">Metrics</div>
<div class="space-y-2">
<div class="space-y-1">
<div class="text-[10px] text-gray-500 dark:text-gray-400">CPU</div>
<Show when={cpuPercent() !== null} fallback={<div class="text-xs text-gray-400"></div>}>
<MetricBar
value={cpuPercent() ?? 0}
label={formatPercent(cpuPercent() ?? 0)}
type="cpu"
resourceId={metricKey()}
/>
</Show>
</div>
<div class="space-y-1">
<div class="text-[10px] text-gray-500 dark:text-gray-400">Memory</div>
<Show when={memoryPercent() !== null} fallback={<div class="text-xs text-gray-400"></div>}>
<MetricBar
value={memoryPercent() ?? 0}
label={formatPercent(memoryPercent() ?? 0)}
sublabel={metricSublabel(props.resource.memory)}
type="memory"
resourceId={metricKey()}
/>
</Show>
</div>
<div class="space-y-1">
<div class="text-[10px] text-gray-500 dark:text-gray-400">Disk</div>
<Show when={diskPercent() !== null} fallback={<div class="text-xs text-gray-400"></div>}>
<MetricBar
value={diskPercent() ?? 0}
label={formatPercent(diskPercent() ?? 0)}
sublabel={metricSublabel(props.resource.disk)}
type="disk"
resourceId={metricKey()}
/>
</Show>
</div>
</div>
</div>
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">Status</div>
<div class="space-y-1.5 text-[11px]">
<div class="flex items-center justify-between gap-2">
<span class="text-gray-500 dark:text-gray-400">State</span>
<span class="font-medium text-gray-700 dark:text-gray-200 capitalize">{props.resource.status || 'unknown'}</span>
</div>
<Show when={props.resource.uptime}>
<div class="flex items-center justify-between gap-2">
<span class="text-gray-500 dark:text-gray-400">Uptime</span>
<span class="font-medium text-gray-700 dark:text-gray-200">{formatUptime(props.resource.uptime ?? 0)}</span>
</div>
{/* Overview Tab */}
<div class={activeTab() === 'overview' ? '' : 'hidden'} style={{ "overflow-anchor": "none" }}>
<Show when={proxmoxNode() || agentHost()}>
<div class="flex flex-wrap gap-3 [&>*]:flex-1 [&>*]:basis-[calc(25%-0.75rem)] [&>*]:min-w-[200px] [&>*]:max-w-full [&>*]:overflow-hidden">
<Show when={proxmoxNode()}>
{(node) => (
<>
<SystemInfoCard variant="node" node={node()} />
<HardwareCard variant="node" node={node()} />
<RootDiskCard node={node()} />
</>
)}
</Show>
<Show when={props.resource.lastSeen}>
<div class="flex items-center justify-between gap-2">
<span class="text-gray-500 dark:text-gray-400">Last Seen</span>
<span
class="font-medium text-gray-700 dark:text-gray-200"
title={lastSeenAbsolute()}
>
{lastSeen() || '—'}
</span>
</div>
</Show>
<Show when={props.resource.platformId}>
<div class="flex items-center justify-between gap-2">
<span class="text-gray-500 dark:text-gray-400">Platform ID</span>
<span class="font-medium text-gray-700 dark:text-gray-200 truncate" title={props.resource.platformId}>
{props.resource.platformId}
</span>
</div>
</Show>
<Show when={props.resource.clusterId}>
<div class="flex items-center justify-between gap-2">
<span class="text-gray-500 dark:text-gray-400">Cluster</span>
<span class="font-medium text-gray-700 dark:text-gray-200 truncate" title={props.resource.clusterId}>
{props.resource.clusterId}
</span>
</div>
</Show>
<Show when={props.resource.parentId}>
<div class="flex items-center justify-between gap-2">
<span class="text-gray-500 dark:text-gray-400">Parent</span>
<span class="font-medium text-gray-700 dark:text-gray-200 truncate" title={props.resource.parentId}>
{props.resource.parentId}
</span>
</div>
<Show when={agentHost()}>
{(host) => (
<>
<SystemInfoCard variant="host" host={host()} />
<HardwareCard variant="host" host={host()} />
<NetworkInterfacesCard interfaces={host().networkInterfaces} />
<DisksCard disks={host().disks} />
<TemperaturesCard rows={temperatureRows()} />
</>
)}
</Show>
</div>
</div>
</Show>
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">Identity</div>
<div class="space-y-1.5 text-[11px]">
<Show when={props.resource.identity?.hostname}>
<div class="grid gap-3 md:grid-cols-2 lg:grid-cols-3 mt-3">
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">Status</div>
<div class="space-y-1.5 text-[11px]">
<div class="flex items-center justify-between gap-2">
<span class="text-gray-500 dark:text-gray-400">Hostname</span>
<span class="font-medium text-gray-700 dark:text-gray-200 truncate" title={props.resource.identity?.hostname}>
{props.resource.identity?.hostname}
</span>
<span class="text-gray-500 dark:text-gray-400">State</span>
<span class="font-medium text-gray-700 dark:text-gray-200 capitalize">{props.resource.status || 'unknown'}</span>
</div>
</Show>
<Show when={props.resource.identity?.machineId}>
<div class="flex items-center justify-between gap-2">
<span class="text-gray-500 dark:text-gray-400">Machine ID</span>
<span class="font-medium text-gray-700 dark:text-gray-200 truncate" title={props.resource.identity?.machineId}>
{props.resource.identity?.machineId}
</span>
</div>
</Show>
<Show when={props.resource.identity?.ips && props.resource.identity.ips.length > 0}>
<div class="flex flex-col gap-1">
<span class="text-gray-500 dark:text-gray-400">IP Addresses</span>
<div class="flex flex-wrap gap-1">
<For each={props.resource.identity?.ips ?? []}>
{(ip) => (
<span
class="inline-flex items-center rounded bg-blue-100 px-1.5 py-0.5 text-[10px] text-blue-700 dark:bg-blue-900/40 dark:text-blue-200"
title={ip}
>
{ip}
</span>
)}
</For>
<Show when={props.resource.uptime}>
<div class="flex items-center justify-between gap-2">
<span class="text-gray-500 dark:text-gray-400">Uptime</span>
<span class="font-medium text-gray-700 dark:text-gray-200">{formatUptime(props.resource.uptime ?? 0)}</span>
</div>
</div>
</Show>
<Show when={props.resource.tags && props.resource.tags.length > 0}>
<div class="flex items-center justify-between gap-2">
<span class="text-gray-500 dark:text-gray-400">Tags</span>
<TagBadges tags={props.resource.tags} maxVisible={6} />
</div>
</Show>
</Show>
<Show when={props.resource.lastSeen}>
<div class="flex items-center justify-between gap-2">
<span class="text-gray-500 dark:text-gray-400">Last Seen</span>
<span
class="font-medium text-gray-700 dark:text-gray-200"
title={lastSeenAbsolute()}
>
{lastSeen() || '—'}
</span>
</div>
</Show>
<Show when={props.resource.platformId}>
<div class="flex items-center justify-between gap-2">
<span class="text-gray-500 dark:text-gray-400">Platform ID</span>
<span class="font-medium text-gray-700 dark:text-gray-200 truncate" title={props.resource.platformId}>
{props.resource.platformId}
</span>
</div>
</Show>
<Show when={props.resource.clusterId}>
<div class="flex items-center justify-between gap-2">
<span class="text-gray-500 dark:text-gray-400">Cluster</span>
<span class="font-medium text-gray-700 dark:text-gray-200 truncate" title={props.resource.clusterId}>
{props.resource.clusterId}
</span>
</div>
</Show>
<Show when={props.resource.parentId}>
<div class="flex items-center justify-between gap-2">
<span class="text-gray-500 dark:text-gray-400">Parent</span>
<span class="font-medium text-gray-700 dark:text-gray-200 truncate" title={props.resource.parentId}>
{props.resource.parentId}
</span>
</div>
</Show>
</div>
</div>
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">Identity</div>
<div class="space-y-1.5 text-[11px]">
<Show when={props.resource.identity?.hostname}>
<div class="flex items-center justify-between gap-2">
<span class="text-gray-500 dark:text-gray-400">Hostname</span>
<span class="font-medium text-gray-700 dark:text-gray-200 truncate" title={props.resource.identity?.hostname}>
{props.resource.identity?.hostname}
</span>
</div>
</Show>
<Show when={props.resource.identity?.machineId}>
<div class="flex items-center justify-between gap-2">
<span class="text-gray-500 dark:text-gray-400">Machine ID</span>
<span class="font-medium text-gray-700 dark:text-gray-200 truncate" title={props.resource.identity?.machineId}>
{props.resource.identity?.machineId}
</span>
</div>
</Show>
<Show when={props.resource.identity?.ips && props.resource.identity.ips.length > 0}>
<div class="flex flex-col gap-1">
<span class="text-gray-500 dark:text-gray-400">IP Addresses</span>
<div class="flex flex-wrap gap-1">
<For each={props.resource.identity?.ips ?? []}>
{(ip) => (
<span
class="inline-flex items-center rounded bg-blue-100 px-1.5 py-0.5 text-[10px] text-blue-700 dark:bg-blue-900/40 dark:text-blue-200"
title={ip}
>
{ip}
</span>
)}
</For>
</div>
</div>
</Show>
<Show when={props.resource.tags && props.resource.tags.length > 0}>
<div class="flex items-center justify-between gap-2">
<span class="text-gray-500 dark:text-gray-400">Tags</span>
<TagBadges tags={props.resource.tags} maxVisible={6} />
</div>
</Show>
</div>
</div>
</div>
</div>
{/* Discovery Tab */}
<div class={activeTab() === 'discovery' ? '' : 'hidden'} style={{ "overflow-anchor": "none" }}>
<div class="rounded border border-dashed border-gray-300 bg-gray-50/70 p-4 text-sm text-gray-600 dark:border-gray-600 dark:bg-gray-900/30 dark:text-gray-300">
Discovery details are available in the legacy host drawer for now.
</div>
</div>
{/* Metrics Tab */}
<div class={activeTab() === 'metrics' ? '' : 'hidden'} style={{ "overflow-anchor": "none" }}>
<div class="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">Metrics</div>
<div class="space-y-2">
<div class="space-y-1">
<div class="text-[10px] text-gray-500 dark:text-gray-400">CPU</div>
<Show when={cpuPercent() !== null} fallback={<div class="text-xs text-gray-400"></div>}>
<MetricBar
value={cpuPercent() ?? 0}
label={formatPercent(cpuPercent() ?? 0)}
type="cpu"
resourceId={metricKey()}
/>
</Show>
</div>
<div class="space-y-1">
<div class="text-[10px] text-gray-500 dark:text-gray-400">Memory</div>
<Show when={memoryPercent() !== null} fallback={<div class="text-xs text-gray-400"></div>}>
<MetricBar
value={memoryPercent() ?? 0}
label={formatPercent(memoryPercent() ?? 0)}
sublabel={metricSublabel(props.resource.memory)}
type="memory"
resourceId={metricKey()}
/>
</Show>
</div>
<div class="space-y-1">
<div class="text-[10px] text-gray-500 dark:text-gray-400">Disk</div>
<Show when={diskPercent() !== null} fallback={<div class="text-xs text-gray-400"></div>}>
<MetricBar
value={diskPercent() ?? 0}
label={formatPercent(diskPercent() ?? 0)}
sublabel={metricSublabel(props.resource.disk)}
type="disk"
resourceId={metricKey()}
/>
</Show>
</div>
</div>
</div>
</div>
</div>
{/* Debug Tab */}
<Show when={debugEnabled()}>
<div class={activeTab() === 'debug' ? '' : 'hidden'} style={{ "overflow-anchor": "none" }}>
<div class="flex items-center justify-between gap-3">
<div class="text-xs text-gray-500 dark:text-gray-400">
Debug mode is enabled via localStorage (<code>pulse_debug_mode</code>).
</div>
<button
type="button"
onClick={handleCopyJson}
class="rounded-md border border-gray-200 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 shadow-sm transition-colors hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-900/60 dark:text-gray-200 dark:hover:bg-gray-800"
>
{copied() ? 'Copied' : 'Copy JSON'}
</button>
</div>
<div class="mt-3 space-y-4">
<div>
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">Unified Resource</div>
<pre class="max-h-[280px] overflow-auto rounded-lg bg-gray-900/90 p-3 text-[11px] text-gray-100">
{JSON.stringify(props.resource, null, 2)}
</pre>
</div>
<div>
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">Identity Matching</div>
<pre class="max-h-[220px] overflow-auto rounded-lg bg-gray-900/90 p-3 text-[11px] text-gray-100">
{JSON.stringify(
{
identity: props.resource.identity,
matchInfo: identityMatchInfo(),
},
null,
2,
)}
</pre>
</div>
<div>
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">Sources</div>
<div class="space-y-2">
<For each={sourceSections()}>
{(section) => {
const status = sourceStatus()[section.id];
const lastSeenText = formatSourceTime(status?.lastSeen);
return (
<details class="rounded-lg border border-gray-200 bg-white/70 p-3 dark:border-gray-700 dark:bg-gray-900/40">
<summary class="flex cursor-pointer list-none items-center justify-between text-sm font-medium text-gray-700 dark:text-gray-200">
<span>{section.label}</span>
<span class="text-[11px] text-gray-500 dark:text-gray-400">
{status?.status ?? 'unknown'}
{lastSeenText ? `${lastSeenText}` : ''}
</span>
</summary>
<Show when={status?.error}>
<div class="mt-2 text-[11px] text-amber-600 dark:text-amber-300">
{status?.error}
</div>
</Show>
<pre class="mt-3 max-h-[220px] overflow-auto rounded-lg bg-gray-900/90 p-3 text-[11px] text-gray-100">
{JSON.stringify(section.payload ?? {}, null, 2)}
</pre>
</details>
);
}}
</For>
</div>
</div>
</div>
</div>
</Show>
<Show when={hasMergedSources()}>
<div class="flex items-center justify-end">
<button
type="button"
onClick={() => setShowReportModal(true)}
class="text-xs font-medium text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
Report incorrect merge
</button>
</div>
</Show>
<ReportMergeModal
isOpen={showReportModal()}
resourceId={props.resource.id}
resourceName={displayName()}
sources={mergedSources()}
onClose={() => setShowReportModal(false)}
/>
</div>
);
};

View File

@@ -1,4 +1,4 @@
import { Component, For, Show, createMemo, createSignal } from 'solid-js';
import { Component, For, Show, createEffect, createMemo, createSignal } from 'solid-js';
import type { Resource } from '@/types/resource';
import { getDisplayName, getCpuPercent, getMemoryPercent, getDiskPercent } from '@/types/resource';
import { formatBytes, formatUptime } from '@/utils/format';
@@ -13,6 +13,9 @@ import { getPlatformBadge, getSourceBadge, getUnifiedSourceBadges } from './reso
interface UnifiedResourceTableProps {
resources: Resource[];
expandedResourceId: string | null;
highlightedResourceId?: string | null;
onExpandedResourceChange: (id: string | null) => void;
}
type SortKey = 'default' | 'name' | 'uptime' | 'cpu' | 'memory' | 'disk' | 'source';
@@ -33,7 +36,17 @@ export const UnifiedResourceTable: Component<UnifiedResourceTableProps> = (props
const { isMobile } = useBreakpoint();
const [sortKey, setSortKey] = createSignal<SortKey>('default');
const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc');
const [expandedResourceId, setExpandedResourceId] = createSignal<string | null>(null);
const setExpandedResourceId = (id: string | null) => props.onExpandedResourceChange(id);
const rowRefs = new Map<string, HTMLTableRowElement>();
createEffect(() => {
const selectedId = props.expandedResourceId;
if (!selectedId) return;
const row = rowRefs.get(selectedId);
if (row) {
row.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
});
const handleSort = (key: Exclude<SortKey, 'default'>) => {
if (sortKey() === key) {
@@ -123,7 +136,7 @@ export const UnifiedResourceTable: Component<UnifiedResourceTableProps> = (props
};
const toggleExpand = (resourceId: string) => {
setExpandedResourceId((prev) => (prev === resourceId ? null : resourceId));
setExpandedResourceId(props.expandedResourceId === resourceId ? null : resourceId);
};
const thClassBase = 'px-2 py-1 text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap';
@@ -164,7 +177,8 @@ export const UnifiedResourceTable: Component<UnifiedResourceTableProps> = (props
<tbody class="bg-white dark:bg-gray-800">
<For each={sortedResources()}>
{(resource) => {
const isExpanded = createMemo(() => expandedResourceId() === resource.id);
const isExpanded = createMemo(() => props.expandedResourceId === resource.id);
const isHighlighted = createMemo(() => props.highlightedResourceId === resource.id);
const displayName = createMemo(() => getDisplayName(resource));
const statusIndicator = createMemo(() => getHostStatusIndicator({ status: resource.status }));
const metricsKey = createMemo(() => buildMetricKey('host', resource.id));
@@ -191,6 +205,9 @@ export const UnifiedResourceTable: Component<UnifiedResourceTableProps> = (props
}
let className = baseHover;
if (isHighlighted()) {
className += ' bg-blue-50 dark:bg-blue-900/20 ring-1 ring-blue-300 dark:ring-blue-600';
}
if (!isResourceOnline(resource)) {
className += ' opacity-60';
}
@@ -208,6 +225,13 @@ export const UnifiedResourceTable: Component<UnifiedResourceTableProps> = (props
return (
<>
<tr
ref={(el) => {
if (el) {
rowRefs.set(resource.id, el);
} else {
rowRefs.delete(resource.id);
}
}}
class={rowClass()}
style={{ 'min-height': '36px' }}
onClick={() => toggleExpand(resource.id)}

View File

@@ -1,5 +1,5 @@
import { Component, For, Show, createSignal, createMemo, createEffect } from 'solid-js';
import { useNavigate } from '@solidjs/router';
import { Component, For, Show, createSignal, createMemo, createEffect, onCleanup } from 'solid-js';
import { useLocation, useNavigate } from '@solidjs/router';
import { useWebSocket } from '@/App';
import { getAlertStyles } from '@/utils/alerts';
import { formatBytes, formatPercent } from '@/utils/format';
@@ -24,9 +24,11 @@ import { useColumnVisibility, type ColumnDef } from '@/hooks/useColumnVisibility
import { STORAGE_KEYS } from '@/utils/localStorage';
type StorageSortKey = 'name' | 'node' | 'type' | 'status' | 'usage' | 'free' | 'total';
type StorageSourceFilter = 'all' | 'proxmox' | 'pbs' | 'ceph';
const Storage: Component = () => {
const navigate = useNavigate();
const location = useLocation();
const { state, connected, activeAlerts, initialDataReceived, reconnecting, reconnect } = useWebSocket();
const alertsActivation = useAlertsActivation();
const alertsEnabled = createMemo(() => alertsActivation.activationState() === 'active');
@@ -41,6 +43,9 @@ const Storage: Component = () => {
const [searchTerm, setSearchTerm] = createSignal('');
const [selectedNode, setSelectedNode] = createSignal<string | null>(null);
const [expandedStorage, setExpandedStorage] = createSignal<string | null>(null);
const [highlightedStorageId, setHighlightedStorageId] = createSignal<string | null>(null);
const [handledStorageId, setHandledStorageId] = createSignal<string | null>(null);
let highlightTimer: number | undefined;
const [sortKey, setSortKey] = usePersistentSignal<StorageSortKey>('storageSortKey', 'name', {
deserialize: (raw) =>
(['name', 'node', 'type', 'status', 'usage', 'free', 'total'] as const).includes(
@@ -65,6 +70,37 @@ const Storage: Component = () => {
},
);
const getStorageRowId = (storage: StorageType) =>
storage.id || `${storage.instance}-${storage.node}-${storage.name}`;
createEffect(() => {
const params = new URLSearchParams(location.search);
const resourceId = params.get('resource');
if (!resourceId || resourceId === handledStorageId()) return;
const match = (state.storage || []).find(
(storage) => getStorageRowId(storage) === resourceId || storage.name === resourceId,
);
if (!match) return;
const rowId = getStorageRowId(match);
setExpandedStorage(rowId);
setHighlightedStorageId(rowId);
setHandledStorageId(resourceId);
if (highlightTimer) window.clearTimeout(highlightTimer);
highlightTimer = window.setTimeout(() => setHighlightedStorageId(null), 2000);
});
onCleanup(() => {
if (highlightTimer) window.clearTimeout(highlightTimer);
});
const [sourceFilter, setSourceFilter] = usePersistentSignal<StorageSourceFilter>(
STORAGE_KEYS.STORAGE_SOURCE_FILTER,
'all',
{
deserialize: (raw) =>
raw === 'all' || raw === 'proxmox' || raw === 'pbs' || raw === 'ceph' ? raw : 'all',
},
);
// Column definitions for storage table
const STORAGE_COLUMNS: ColumnDef[] = [
{ id: 'type', label: 'Type', priority: 'secondary', toggleable: true },
@@ -100,6 +136,11 @@ const Storage: Component = () => {
return value === 'rbd' || value === 'cephfs' || value === 'ceph';
};
const isPBSStorage = (storage: StorageType) => storage.type === 'pbs';
const isCephStorage = (storage: StorageType) => isCephType(storage.type);
const isProxmoxStorage = (storage: StorageType) =>
!isPBSStorage(storage) && !isCephStorage(storage);
const getCephHealthLabel = (health?: string) => {
if (!health) return 'CEPH';
const normalized = health.toUpperCase();
@@ -410,6 +451,15 @@ const Storage: Component = () => {
}
}
// Apply source filter
if (sourceFilter() !== 'all') {
storage = storage.filter((s) => {
if (sourceFilter() === 'pbs') return isPBSStorage(s);
if (sourceFilter() === 'ceph') return isCephStorage(s);
return isProxmoxStorage(s);
});
}
// Apply status filter
if (statusFilter() !== 'all') {
storage = storage.filter((s) => {
@@ -579,6 +629,7 @@ const Storage: Component = () => {
setSortKey('name');
setSortDirection('asc');
setStatusFilter('all');
setSourceFilter('all');
};
// Handle keyboard shortcuts
@@ -601,7 +652,8 @@ const Storage: Component = () => {
searchTerm().trim() ||
selectedNode() ||
viewMode() !== 'node' ||
statusFilter() !== 'all'
statusFilter() !== 'all' ||
sourceFilter() !== 'all'
) {
resetFilters();
@@ -645,7 +697,8 @@ const Storage: Component = () => {
tabView() === 'pools' &&
connected() &&
initialDataReceived() &&
cephSummaryStats().clusters.length > 0
cephSummaryStats().clusters.length > 0 &&
(sourceFilter() === 'all' || sourceFilter() === 'ceph')
}
>
<Card padding="md" tone="glass">
@@ -753,6 +806,8 @@ const Storage: Component = () => {
setSortDirection={setSortDirection}
statusFilter={statusFilter}
setStatusFilter={setStatusFilter}
sourceFilter={sourceFilter}
setSourceFilter={setSourceFilter}
searchInputRef={(el) => (searchInputRef = el)}
columnVisibility={columnVisibility}
/>
@@ -1169,6 +1224,9 @@ const Storage: Component = () => {
const isExpanded = createMemo(
() => expandedStorage() === storageRowId(),
);
const isHighlighted = createMemo(
() => highlightedStorageId() === storageRowId(),
);
const hasAcknowledgedOnlyAlert = createMemo(
() => alertStyles().hasAcknowledgedOnlyAlert && parentNodeOnline(),
@@ -1187,6 +1245,8 @@ const Storage: Component = () => {
? 'bg-red-50 dark:bg-red-950/30'
: 'bg-yellow-50 dark:bg-yellow-950/20',
);
} else if (isHighlighted()) {
classes.push('bg-blue-50/60 dark:bg-blue-900/20 ring-1 ring-blue-300 dark:ring-blue-600');
} else if (hasAcknowledgedOnlyAlert()) {
classes.push('bg-gray-50/40 dark:bg-gray-800/40');
}

View File

@@ -19,6 +19,8 @@ interface StorageFilterProps {
searchInputRef?: (el: HTMLInputElement) => void;
statusFilter?: () => 'all' | 'available' | 'offline';
setStatusFilter?: (value: 'all' | 'available' | 'offline') => void;
sourceFilter?: () => 'all' | 'proxmox' | 'pbs' | 'ceph';
setSourceFilter?: (value: 'all' | 'proxmox' | 'pbs' | 'ceph') => void;
// Column visibility (optional)
columnVisibility?: {
availableToggles: () => ColumnDef[];
@@ -107,7 +109,8 @@ export const StorageFilter: Component<StorageFilterProps> = (props) => {
props.sortKey() !== 'name' ||
props.sortDirection() !== 'asc' ||
(props.groupBy && props.groupBy() !== 'node') ||
(props.statusFilter && props.statusFilter() !== 'all');
(props.statusFilter && props.statusFilter() !== 'all') ||
(props.sourceFilter && props.sourceFilter() !== 'all');
return (
<Card class="storage-filter mb-3" padding="sm">
@@ -325,6 +328,53 @@ export const StorageFilter: Component<StorageFilterProps> = (props) => {
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block"></div>
</Show>
{/* Source Filter */}
<Show when={props.sourceFilter && props.setSourceFilter}>
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
<button
type="button"
onClick={() => props.setSourceFilter!('all')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.sourceFilter!() === 'all'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
All Sources
</button>
<button
type="button"
onClick={() => props.setSourceFilter!('proxmox')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.sourceFilter!() === 'proxmox'
? 'bg-white dark:bg-gray-800 text-blue-600 dark:text-blue-300 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
Proxmox
</button>
<button
type="button"
onClick={() => props.setSourceFilter!('pbs')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.sourceFilter!() === 'pbs'
? 'bg-white dark:bg-gray-800 text-emerald-600 dark:text-emerald-300 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
PBS
</button>
<button
type="button"
onClick={() => props.setSourceFilter!('ceph')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.sourceFilter!() === 'ceph'
? 'bg-white dark:bg-gray-800 text-purple-600 dark:text-purple-300 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
Ceph
</button>
</div>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block"></div>
</Show>
{/* Status Filter */}
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
<button
@@ -415,9 +465,10 @@ export const StorageFilter: Component<StorageFilterProps> = (props) => {
props.setSearch('');
props.setSortKey('name');
props.setSortDirection('asc');
if (props.setGroupBy) props.setGroupBy('node');
if (props.setStatusFilter) props.setStatusFilter('all');
}}
if (props.setGroupBy) props.setGroupBy('node');
if (props.setStatusFilter) props.setStatusFilter('all');
if (props.setSourceFilter) props.setSourceFilter('all');
}}
title="Reset all filters"
class="flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium rounded-lg transition-colors
text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900/50 hover:bg-blue-200 dark:hover:bg-blue-900/70"

View File

@@ -0,0 +1,196 @@
import { Show, For, createMemo, createSignal, createEffect } from 'solid-js';
import { useNavigate } from '@solidjs/router';
interface CommandPaletteModalProps {
isOpen: boolean;
onClose: () => void;
}
type Command = {
id: string;
label: string;
description?: string;
shortcut?: string;
keywords?: string[];
action: () => void;
};
export function CommandPaletteModal(props: CommandPaletteModalProps) {
const navigate = useNavigate();
const [query, setQuery] = createSignal('');
let inputRef: HTMLInputElement | undefined;
const commands = createMemo<Command[]>(() => [
{
id: 'nav-infrastructure',
label: 'Go to Infrastructure',
description: '/infrastructure',
shortcut: 'g i',
keywords: ['infra', 'hosts', 'nodes'],
action: () => navigate('/infrastructure'),
},
{
id: 'nav-workloads',
label: 'Go to Workloads',
description: '/workloads',
shortcut: 'g w',
keywords: ['vm', 'lxc', 'docker'],
action: () => navigate('/workloads'),
},
{
id: 'nav-storage',
label: 'Go to Storage',
description: '/storage',
shortcut: 'g s',
keywords: ['ceph', 'pbs'],
action: () => navigate('/storage'),
},
{
id: 'nav-backups',
label: 'Go to Backups',
description: '/backups',
shortcut: 'g b',
keywords: ['replication'],
action: () => navigate('/backups'),
},
{
id: 'nav-alerts',
label: 'Go to Alerts',
description: '/alerts',
shortcut: 'g a',
keywords: ['alarms', 'notifications'],
action: () => navigate('/alerts'),
},
{
id: 'nav-settings',
label: 'Go to Settings',
description: '/settings',
shortcut: 'g t',
keywords: ['preferences', 'config'],
action: () => navigate('/settings'),
},
]);
const normalizedQuery = createMemo(() =>
query()
.toLowerCase()
.trim()
.replace(/\s+/g, '')
);
const filteredCommands = createMemo(() => {
const q = normalizedQuery();
if (!q) return commands();
return commands().filter((cmd) => {
const haystack = [
cmd.label,
cmd.description ?? '',
cmd.shortcut ?? '',
...(cmd.keywords ?? []),
]
.join(' ')
.toLowerCase()
.replace(/\s+/g, '');
return haystack.includes(q);
});
});
const handleSelect = (command: Command) => {
command.action();
props.onClose();
};
createEffect(() => {
if (props.isOpen) {
setQuery('');
queueMicrotask(() => inputRef?.focus());
} else {
setQuery('');
}
});
return (
<Show when={props.isOpen}>
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={props.onClose}
role="dialog"
aria-modal="true"
>
<div
class="w-full max-w-xl rounded-lg bg-white shadow-xl dark:bg-gray-800"
onClick={(e) => e.stopPropagation()}
>
<div class="border-b border-gray-200 px-5 py-4 dark:border-gray-700">
<div class="flex items-center gap-2 rounded-md border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 shadow-sm focus-within:border-blue-500 focus-within:ring-2 focus-within:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200 dark:focus-within:border-blue-400">
<svg class="h-4 w-4 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
ref={(el) => (inputRef = el)}
type="text"
value={query()}
onInput={(e) => setQuery(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault();
props.onClose();
return;
}
if (e.key === 'Enter') {
const first = filteredCommands()[0];
if (first) {
e.preventDefault();
handleSelect(first);
}
}
}}
placeholder="Type a command or search..."
class="w-full bg-transparent text-sm text-gray-800 placeholder-gray-400 focus:outline-none dark:text-gray-100 dark:placeholder-gray-500"
/>
<span class="text-[11px] text-gray-400 dark:text-gray-500">Cmd+K</span>
</div>
</div>
<div class="max-h-[320px] overflow-y-auto px-3 py-3">
<Show
when={filteredCommands().length > 0}
fallback={
<div class="px-3 py-8 text-center text-sm text-gray-500 dark:text-gray-400">
No matches found.
</div>
}
>
<For each={filteredCommands()}>
{(command) => (
<button
type="button"
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm text-gray-700 hover:bg-blue-50 dark:text-gray-200 dark:hover:bg-blue-900/30"
onClick={() => handleSelect(command)}
>
<div>
<div class="font-medium">{command.label}</div>
<Show when={command.description}>
<div class="text-xs text-gray-500 dark:text-gray-400">
{command.description}
</div>
</Show>
</div>
<Show when={command.shortcut}>
<span class="rounded bg-gray-100 px-2 py-1 text-[10px] font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-200">
{command.shortcut}
</span>
</Show>
</button>
)}
</For>
</Show>
</div>
</div>
</div>
</Show>
);
}
export default CommandPaletteModal;

View File

@@ -0,0 +1,468 @@
import { Component, For, Show, createEffect, createMemo, createResource, createSignal, onCleanup, onMount } from 'solid-js';
import type { JSX } from 'solid-js';
import { useLocation, useNavigate } from '@solidjs/router';
import SearchIcon from 'lucide-solid/icons/search';
import XIcon from 'lucide-solid/icons/x';
import ServerIcon from 'lucide-solid/icons/server';
import BoxesIcon from 'lucide-solid/icons/boxes';
import HardDriveIcon from 'lucide-solid/icons/hard-drive';
import type { ResourceStatus, ResourceType } from '@/types/resource';
import { StatusDot } from '@/components/shared/StatusDot';
import { OFFLINE_HEALTH_STATUSES, DEGRADED_HEALTH_STATUSES } from '@/utils/status';
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
import { apiFetchJSON } from '@/utils/apiClient';
type V2Resource = {
id: string;
type?: string;
name?: string;
status?: string;
parentId?: string;
sources?: string[];
};
type V2ListResponse = {
data?: V2Resource[];
resources?: V2Resource[];
meta?: {
total?: number;
};
};
type SearchResource = {
id: string;
type: ResourceType;
name: string;
status: ResourceStatus;
parentId?: string;
sources?: string[];
};
type SearchResponse = {
items: SearchResource[];
total: number;
};
const resolveResourceType = (value?: string): ResourceType => {
const normalized = (value || '').trim().toLowerCase();
switch (normalized) {
case 'node':
return 'node';
case 'host':
return 'host';
case 'docker-host':
case 'docker_host':
return 'docker-host';
case 'k8s-cluster':
case 'k8s_cluster':
case 'kubernetes-cluster':
return 'k8s-cluster';
case 'k8s-node':
case 'k8s_node':
return 'k8s-node';
case 'vm':
return 'vm';
case 'lxc':
return 'container';
case 'container':
return 'container';
case 'oci-container':
case 'oci_container':
return 'oci-container';
case 'docker-container':
case 'docker_container':
return 'docker-container';
case 'pod':
return 'pod';
case 'storage':
return 'storage';
case 'datastore':
return 'datastore';
case 'pool':
return 'pool';
case 'dataset':
return 'dataset';
case 'pbs':
return 'pbs';
case 'pmg':
return 'pmg';
default:
return 'host';
}
};
const resolveStatus = (value?: string): ResourceStatus => {
const normalized = (value || '').trim().toLowerCase();
if (normalized === 'online' || normalized === 'running') return 'online';
if (normalized === 'offline' || normalized === 'stopped') return 'offline';
if (normalized === 'warning' || normalized === 'degraded') return 'degraded';
if (normalized === 'paused') return 'paused';
return 'unknown';
};
const getResourceStatusIndicator = (status: ResourceStatus) => {
const normalized = status.toLowerCase();
if (OFFLINE_HEALTH_STATUSES.has(normalized)) {
return { variant: 'danger', label: 'Offline' } as const;
}
if (DEGRADED_HEALTH_STATUSES.has(normalized)) {
return { variant: 'warning', label: 'Degraded' } as const;
}
if (normalized === 'online' || normalized === 'running') {
return { variant: 'success', label: 'Online' } as const;
}
return { variant: 'muted', label: 'Unknown' } as const;
};
const typeLabels: Record<ResourceType, string> = {
node: 'Node',
host: 'Host',
'docker-host': 'Docker Host',
'k8s-cluster': 'Kubernetes Cluster',
'k8s-node': 'Kubernetes Node',
truenas: 'TrueNAS',
vm: 'VM',
container: 'LXC',
'oci-container': 'OCI Container',
'docker-container': 'Docker Container',
pod: 'Kubernetes Pod',
jail: 'Jail',
'docker-service': 'Docker Service',
'k8s-deployment': 'Kubernetes Deployment',
'k8s-service': 'Kubernetes Service',
storage: 'Storage',
datastore: 'Datastore',
pool: 'Pool',
dataset: 'Dataset',
pbs: 'Backup Server',
pmg: 'Mail Gateway',
};
const infrastructureTypes = new Set<ResourceType>([
'node',
'host',
'docker-host',
'k8s-cluster',
'k8s-node',
'truenas',
'pbs',
'pmg',
]);
const workloadTypes = new Set<ResourceType>([
'vm',
'container',
'oci-container',
'docker-container',
'pod',
'jail',
'docker-service',
'k8s-deployment',
'k8s-service',
]);
const storageTypes = new Set<ResourceType>(['storage', 'datastore', 'pool', 'dataset']);
const resolveGroup = (resource: SearchResource): 'infrastructure' | 'workloads' | 'storage' => {
if (workloadTypes.has(resource.type)) return 'workloads';
if (storageTypes.has(resource.type)) return 'storage';
if (infrastructureTypes.has(resource.type)) return 'infrastructure';
return 'infrastructure';
};
const iconForGroup = (group: 'infrastructure' | 'workloads' | 'storage') => {
if (group === 'workloads') return BoxesIcon;
if (group === 'storage') return HardDriveIcon;
return ServerIcon;
};
const fetchSearchResults = async (query: string): Promise<SearchResponse> => {
const params = new URLSearchParams();
params.set('q', query);
params.set('limit', '10');
const response = await apiFetchJSON<V2ListResponse | V2Resource[]>(`/api/v2/resources?${params.toString()}`, { cache: 'no-store' });
const payload = Array.isArray(response) ? response : response.data ?? response.resources ?? [];
const total = Array.isArray(response)
? response.length
: response.meta?.total ?? payload.length;
const items = payload.map((resource) => ({
id: resource.id,
type: resolveResourceType(resource.type),
name: resource.name || resource.id,
status: resolveStatus(resource.status),
parentId: resource.parentId,
sources: resource.sources,
}));
return { items, total };
};
export const GlobalSearch: Component = () => {
const navigate = useNavigate();
const location = useLocation();
const [query, setQuery] = createSignal('');
const [isOpen, setIsOpen] = createSignal(false);
const [activeIndex, setActiveIndex] = createSignal<number>(-1);
const debouncedQuery = useDebouncedValue(() => query().trim(), 300);
const searchSource = createMemo(() => {
const term = debouncedQuery();
if (term.length < 2) return null;
return term;
});
const [searchResults] = createResource(searchSource, fetchSearchResults, {
initialValue: { items: [], total: 0 },
});
const results = createMemo(() => searchResults()?.items ?? []);
const totalResults = createMemo(() => searchResults()?.total ?? results().length);
const hasMoreResults = createMemo(() => totalResults() > results().length);
const isLoading = createMemo(() => searchResults.loading && searchSource() !== null);
const hasQuery = createMemo(() => query().trim().length > 0);
const isTooShort = createMemo(() => {
const term = query().trim();
return term.length > 0 && term.length < 2;
});
const shouldShowDropdown = createMemo(() => isOpen() && (hasQuery() || isLoading()));
const groupedResults = createMemo(() => {
const groups = {
infrastructure: [] as SearchResource[],
workloads: [] as SearchResource[],
storage: [] as SearchResource[],
};
results().forEach((resource) => {
const group = resolveGroup(resource);
groups[group].push(resource);
});
return groups;
});
const flattenedResults = createMemo(() => [
...groupedResults().infrastructure,
...groupedResults().workloads,
...groupedResults().storage,
]);
createEffect(() => {
const list = flattenedResults();
if (list.length === 0) {
setActiveIndex(-1);
return;
}
setActiveIndex(0);
});
createEffect(() => {
location.pathname;
setIsOpen(false);
});
let containerRef: HTMLDivElement | undefined;
const handleDocumentClick = (event: MouseEvent) => {
const target = event.target as Node | null;
if (!target || !containerRef) return;
if (!containerRef.contains(target)) {
setIsOpen(false);
}
};
onMount(() => {
document.addEventListener('mousedown', handleDocumentClick);
});
onCleanup(() => {
document.removeEventListener('mousedown', handleDocumentClick);
});
const clearSearch = () => {
setQuery('');
setIsOpen(false);
};
const navigateToResource = (resource: SearchResource) => {
const group = resolveGroup(resource);
if (group === 'workloads') {
navigate(`/workloads?resource=${encodeURIComponent(resource.id)}`);
return;
}
if (group === 'storage') {
navigate(`/storage?resource=${encodeURIComponent(resource.id)}`);
return;
}
navigate(`/infrastructure?resource=${encodeURIComponent(resource.id)}`);
};
const handleSelect = (resource: SearchResource) => {
navigateToResource(resource);
clearSearch();
};
const handleViewAll = () => {
const term = query().trim();
if (!term) return;
navigate(`/infrastructure?search=${encodeURIComponent(term)}`);
clearSearch();
};
const handleKeyDown: JSX.EventHandler<HTMLInputElement, KeyboardEvent> = (event) => {
if (event.key === 'ArrowDown') {
event.preventDefault();
const list = flattenedResults();
if (list.length === 0) return;
setIsOpen(true);
setActiveIndex((prev) => (prev + 1) % list.length);
}
if (event.key === 'ArrowUp') {
event.preventDefault();
const list = flattenedResults();
if (list.length === 0) return;
setIsOpen(true);
setActiveIndex((prev) => (prev <= 0 ? list.length - 1 : prev - 1));
}
if (event.key === 'Enter') {
const list = flattenedResults();
const index = activeIndex();
if (index >= 0 && index < list.length) {
event.preventDefault();
handleSelect(list[index]);
return;
}
if (hasMoreResults()) {
event.preventDefault();
handleViewAll();
}
}
if (event.key === 'Escape') {
setIsOpen(false);
}
};
return (
<div class="relative w-full max-w-[360px]" ref={containerRef}>
<div class="relative">
<SearchIcon class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<input
type="search"
class="w-full rounded-md border border-gray-200 bg-white/90 px-9 py-2 text-sm text-gray-700 shadow-sm focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-400 dark:border-gray-700 dark:bg-gray-900/70 dark:text-gray-200"
placeholder="Search resources..."
data-global-search
value={query()}
onInput={(event) => {
setQuery(event.currentTarget.value);
setIsOpen(true);
}}
onFocus={() => setIsOpen(true)}
onKeyDown={handleKeyDown}
aria-label="Search resources"
/>
<Show when={isLoading()}>
<span class="absolute right-8 top-1/2 h-3.5 w-3.5 -translate-y-1/2 rounded-full border-2 border-blue-500 border-t-transparent animate-spin" />
</Show>
<Show when={query().length > 0}>
<button
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
onClick={clearSearch}
aria-label="Clear search"
>
<XIcon class="h-4 w-4" />
</button>
</Show>
</div>
<Show when={shouldShowDropdown()}>
<div class="absolute left-0 right-0 z-40 mt-2 rounded-md border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-900">
<Show when={isLoading()}>
<div class="px-3 py-3 text-xs text-gray-500 dark:text-gray-400">Searching...</div>
</Show>
<Show when={isTooShort()}>
<div class="px-3 py-3 text-xs text-gray-500 dark:text-gray-400">Type at least 2 characters to search.</div>
</Show>
<Show when={!isLoading() && searchSource() !== null && results().length === 0 && !isTooShort()}>
<div class="px-3 py-3 text-xs text-gray-500 dark:text-gray-400">No results</div>
</Show>
<Show when={results().length > 0}>
<div class="max-h-[320px] overflow-y-auto">
<For each={([
{ key: 'infrastructure', label: 'Infrastructure' },
{ key: 'workloads', label: 'Workloads' },
{ key: 'storage', label: 'Storage' },
] as const)}>
{(group) => {
const items = () => groupedResults()[group.key];
return (
<Show when={items().length > 0}>
<div class="px-3 py-2 text-[10px] font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">
{group.label}
</div>
<For each={items()}>
{(resource) => {
const list = flattenedResults();
const index = list.findIndex((item) => item.id === resource.id);
const isActive = () => index === activeIndex();
const statusIndicator = () => getResourceStatusIndicator(resource.status);
const displayName = () => resource.name || resource.id;
const typeLabel = () => typeLabels[resource.type] ?? resource.type;
const Icon = iconForGroup(resolveGroup(resource));
return (
<button
type="button"
class={`flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors ${isActive()
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200'
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-200 dark:hover:bg-gray-800/60'}`}
onMouseEnter={() => setActiveIndex(index)}
onMouseDown={(event) => event.preventDefault()}
onClick={() => handleSelect(resource)}
aria-selected={isActive()}
role="option"
>
<Icon class="h-4 w-4 text-gray-400" />
<StatusDot
variant={statusIndicator().variant}
ariaLabel={statusIndicator().label}
size="xs"
/>
<div class="min-w-0 flex-1">
<div class="truncate font-medium">{displayName()}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">{typeLabel()}</div>
</div>
</button>
);
}}
</For>
</Show>
);
}}
</For>
</div>
</Show>
<Show when={hasMoreResults()}>
<button
type="button"
class="w-full px-3 py-2 text-left text-xs font-medium text-blue-600 hover:bg-blue-50 dark:text-blue-300 dark:hover:bg-blue-900/30"
onMouseDown={(event) => event.preventDefault()}
onClick={handleViewAll}
>
View all results ({totalResults()})
</button>
</Show>
</div>
</Show>
</div>
);
};
export default GlobalSearch;

View File

@@ -0,0 +1,98 @@
import { Show, For } from 'solid-js';
interface ShortcutGroup {
title: string;
items: { keys: string; description: string }[];
}
interface KeyboardShortcutsModalProps {
isOpen: boolean;
onClose: () => void;
}
const SHORTCUT_GROUPS: ShortcutGroup[] = [
{
title: 'Navigation',
items: [
{ keys: 'g then i', description: 'Go to Infrastructure' },
{ keys: 'g then w', description: 'Go to Workloads' },
{ keys: 'g then s', description: 'Go to Storage' },
{ keys: 'g then b', description: 'Go to Backups' },
{ keys: 'g then a', description: 'Go to Alerts' },
{ keys: 'g then t', description: 'Go to Settings' },
],
},
{
title: 'Search & Help',
items: [
{ keys: '/', description: 'Focus search' },
{ keys: 'Cmd+K / Ctrl+K', description: 'Open command palette' },
{ keys: '?', description: 'Show keyboard shortcuts' },
{ keys: 'Esc', description: 'Close dialogs / cancel' },
],
},
];
export function KeyboardShortcutsModal(props: KeyboardShortcutsModalProps) {
return (
<Show when={props.isOpen}>
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={props.onClose}
role="dialog"
aria-modal="true"
>
<div
class="w-full max-w-xl rounded-lg bg-white shadow-xl dark:bg-gray-800"
onClick={(e) => e.stopPropagation()}
>
<div class="flex items-center justify-between border-b border-gray-200 px-5 py-4 dark:border-gray-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
Keyboard Shortcuts
</h2>
<button
type="button"
onClick={props.onClose}
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
aria-label="Close shortcuts"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="space-y-5 px-5 py-4">
<For each={SHORTCUT_GROUPS}>
{(group) => (
<div>
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{group.title}
</div>
<div class="mt-2 space-y-2">
<For each={group.items}>
{(item) => (
<div class="flex items-center justify-between text-sm text-gray-700 dark:text-gray-300">
<span>{item.description}</span>
<span class="rounded bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 dark:bg-gray-700 dark:text-gray-200">
{item.keys}
</span>
</div>
)}
</For>
</div>
</div>
)}
</For>
</div>
<div class="border-t border-gray-200 px-5 py-3 text-xs text-gray-500 dark:border-gray-700 dark:text-gray-400">
Press <span class="font-medium">?</span> again or <span class="font-medium">Esc</span> to close.
</div>
</div>
</div>
</Show>
);
}
export default KeyboardShortcutsModal;

View File

@@ -0,0 +1,309 @@
import { createEffect, createMemo, createSignal, For, Show, onCleanup } from 'solid-js';
import type { JSX } from 'solid-js';
import ServerIcon from 'lucide-solid/icons/server';
import BoxesIcon from 'lucide-solid/icons/boxes';
import BellIcon from 'lucide-solid/icons/bell';
import SettingsIcon from 'lucide-solid/icons/settings';
import MoreHorizontalIcon from 'lucide-solid/icons/more-horizontal';
import XIcon from 'lucide-solid/icons/x';
type PlatformTab = {
id: string;
label: string;
route: string;
settingsRoute: string;
tooltip: string;
enabled: boolean;
icon: JSX.Element;
badge?: string;
};
type UtilityTab = {
id: 'alerts' | 'ai' | 'settings';
label: string;
route: string;
tooltip: string;
badge: 'update' | 'pro' | null;
count: number | undefined;
breakdown: { warning: number; critical: number } | undefined;
icon: JSX.Element;
};
type MobileNavBarProps = {
activeTab: () => string;
platformTabs: () => PlatformTab[];
utilityTabs: () => UtilityTab[];
onPlatformClick: (platform: PlatformTab) => void;
onUtilityClick: (tab: UtilityTab) => void;
};
export function MobileNavBar(props: MobileNavBarProps) {
const [drawerOpen, setDrawerOpen] = createSignal(false);
const [touchStartX, setTouchStartX] = createSignal<number | null>(null);
const [touchStartY, setTouchStartY] = createSignal<number | null>(null);
const alertsTab = createMemo(() => props.utilityTabs().find((tab) => tab.id === 'alerts'));
const settingsTab = createMemo(() => props.utilityTabs().find((tab) => tab.id === 'settings'));
const infrastructureTab = createMemo(() =>
props.platformTabs().find((tab) => tab.id === 'infrastructure'),
);
const workloadsTab = createMemo(() =>
props.platformTabs().find((tab) => tab.id === 'workloads'),
);
const morePlatformTabs = createMemo(() =>
props.platformTabs().filter((tab) => !['infrastructure', 'workloads'].includes(tab.id)),
);
const moreUtilityTabs = createMemo(() =>
props.utilityTabs().filter((tab) => !['alerts', 'settings'].includes(tab.id)),
);
createEffect(() => {
if (!drawerOpen()) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setDrawerOpen(false);
}
};
document.addEventListener('keydown', handleKeyDown);
onCleanup(() => document.removeEventListener('keydown', handleKeyDown));
});
const handlePlatformClick = (platform: PlatformTab) => {
props.onPlatformClick(platform);
setDrawerOpen(false);
};
const handleUtilityClick = (tab: UtilityTab) => {
props.onUtilityClick(tab);
setDrawerOpen(false);
};
const handleTouchStart: JSX.EventHandlerUnion<HTMLDivElement, TouchEvent> = (event) => {
const touch = event.touches[0];
if (!touch) return;
setTouchStartX(touch.clientX);
setTouchStartY(touch.clientY);
};
const handleTouchEnd: JSX.EventHandlerUnion<HTMLDivElement, TouchEvent> = (event) => {
const touch = event.changedTouches[0];
if (!touch || touchStartX() === null || touchStartY() === null) {
setTouchStartX(null);
setTouchStartY(null);
return;
}
const deltaX = touch.clientX - touchStartX()!;
const deltaY = touch.clientY - touchStartY()!;
if (deltaX > 60 && Math.abs(deltaY) < 40) {
setDrawerOpen(false);
}
setTouchStartX(null);
setTouchStartY(null);
};
const renderAlertsBadge = () => {
const tab = alertsTab();
if (!tab || !tab.count || tab.count <= 0) return null;
const critical = tab.breakdown?.critical ?? 0;
const warning = tab.breakdown?.warning ?? 0;
return (
<span class="absolute -right-2 -top-1 flex items-center gap-1">
{critical > 0 && (
<span class="inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-red-600 px-1 text-[10px] font-bold text-white">
{critical}
</span>
)}
{warning > 0 && (
<span class="inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-amber-200 px-1 text-[10px] font-semibold text-amber-900">
{warning}
</span>
)}
</span>
);
};
return (
<>
{/* Bottom navigation bar */}
<nav class="fixed inset-x-0 bottom-0 z-40 border-t border-gray-200 bg-white/95 backdrop-blur dark:border-gray-700 dark:bg-gray-900/95 md:hidden">
<div class="flex items-center justify-around px-2 py-2">
<button
type="button"
onClick={() => infrastructureTab() && handlePlatformClick(infrastructureTab()!)}
class={`flex flex-1 flex-col items-center gap-1 rounded-lg px-2 py-1.5 text-[11px] font-medium transition-colors ${props.activeTab() === 'infrastructure'
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-500 dark:text-gray-400'
}`}
>
<ServerIcon class="h-5 w-5" />
<span>Infra</span>
</button>
<button
type="button"
onClick={() => workloadsTab() && handlePlatformClick(workloadsTab()!)}
class={`flex flex-1 flex-col items-center gap-1 rounded-lg px-2 py-1.5 text-[11px] font-medium transition-colors ${props.activeTab() === 'workloads'
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-500 dark:text-gray-400'
}`}
>
<BoxesIcon class="h-5 w-5" />
<span>Workloads</span>
</button>
<button
type="button"
onClick={() => alertsTab() && handleUtilityClick(alertsTab()!)}
class={`relative flex flex-1 flex-col items-center gap-1 rounded-lg px-2 py-1.5 text-[11px] font-medium transition-colors ${props.activeTab() === 'alerts'
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-500 dark:text-gray-400'
}`}
>
<span class="relative">
<BellIcon class="h-5 w-5" />
{renderAlertsBadge()}
</span>
<span>Alerts</span>
</button>
<Show when={settingsTab()}>
<button
type="button"
onClick={() => settingsTab() && handleUtilityClick(settingsTab()!)}
class={`flex flex-1 flex-col items-center gap-1 rounded-lg px-2 py-1.5 text-[11px] font-medium transition-colors ${props.activeTab() === 'settings'
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-500 dark:text-gray-400'
}`}
>
<SettingsIcon class="h-5 w-5" />
<span>Settings</span>
</button>
</Show>
<button
type="button"
onClick={() => setDrawerOpen((prev) => !prev)}
class={`flex flex-1 flex-col items-center gap-1 rounded-lg px-2 py-1.5 text-[11px] font-medium transition-colors ${drawerOpen() || !['infrastructure', 'workloads', 'alerts', 'settings'].includes(props.activeTab())
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-500 dark:text-gray-400'
}`}
>
<MoreHorizontalIcon class="h-5 w-5" />
<span>More</span>
</button>
</div>
</nav>
{/* Drawer overlay */}
<div
class={`fixed inset-0 z-50 md:hidden ${drawerOpen() ? 'pointer-events-auto' : 'pointer-events-none'}`}
aria-hidden={!drawerOpen()}
>
<div
class={`absolute inset-0 bg-black/50 transition-opacity duration-200 ${drawerOpen() ? 'opacity-100' : 'opacity-0'}`}
onClick={() => setDrawerOpen(false)}
/>
<div
class={`absolute inset-y-0 right-0 w-80 max-w-[90vw] bg-white shadow-xl transition-transform duration-200 dark:bg-gray-900 ${drawerOpen() ? 'translate-x-0' : 'translate-x-full'}`}
role="dialog"
aria-label="Mobile navigation menu"
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
<div class="flex items-center justify-between border-b border-gray-200 px-4 py-4 dark:border-gray-700">
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">Menu</div>
<button
type="button"
onClick={() => setDrawerOpen(false)}
class="rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
aria-label="Close menu"
>
<XIcon class="h-5 w-5" />
</button>
</div>
<div class="h-full overflow-y-auto px-4 pb-20 pt-4">
<Show when={morePlatformTabs().length > 0}>
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
More Destinations
</div>
<div class="mt-3 space-y-2">
<For each={morePlatformTabs()}>
{(platform) => (
<button
type="button"
onClick={() => handlePlatformClick(platform)}
title={platform.tooltip}
class={`flex w-full items-center justify-between rounded-lg border border-gray-200 px-3 py-2 text-sm font-medium transition-colors dark:border-gray-700 ${platform.enabled
? 'text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-800'
: 'text-gray-400 hover:bg-gray-100 dark:text-gray-500 dark:hover:bg-gray-800'
}`}
>
<span class="flex items-center gap-2">
{platform.icon}
<span>{platform.label}</span>
</span>
<span class="flex items-center gap-2">
<Show when={!platform.enabled}>
<span class="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-semibold text-amber-700 dark:bg-amber-900/40 dark:text-amber-200">
Setup
</span>
</Show>
<Show when={platform.badge}>
<span class="rounded-full bg-gray-200 px-2 py-0.5 text-[10px] font-semibold text-gray-600 dark:bg-gray-700 dark:text-gray-300">
{platform.badge}
</span>
</Show>
</span>
</button>
)}
</For>
</div>
</Show>
<Show when={moreUtilityTabs().length > 0}>
<div class="mt-6 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
System
</div>
<div class="mt-3 space-y-2">
<For each={moreUtilityTabs()}>
{(tab) => (
<button
type="button"
onClick={() => handleUtilityClick(tab)}
title={tab.tooltip}
class="flex w-full items-center justify-between rounded-lg border border-gray-200 px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-100 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-800"
>
<span class="flex items-center gap-2">
{tab.icon}
<span>{tab.label}</span>
</span>
<span class="flex items-center gap-2">
<Show when={tab.id === 'alerts' && tab.count && tab.count > 0}>
<span class="rounded-full bg-red-600 px-2 py-0.5 text-[10px] font-semibold text-white">
{tab.count}
</span>
</Show>
<Show when={tab.badge === 'update'}>
<span class="h-2 w-2 rounded-full bg-red-500"></span>
</Show>
<Show when={tab.badge === 'pro'}>
<span class="rounded-full bg-blue-100 px-2 py-0.5 text-[10px] font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">
Pro
</span>
</Show>
</span>
</button>
)}
</For>
</div>
</Show>
</div>
</div>
</div>
</>
);
}
export default MobileNavBar;

View File

@@ -0,0 +1,155 @@
import { createEffect, createSignal, Show } from 'solid-js';
import { createLocalStorageBooleanSignal, STORAGE_KEYS } from '@/utils/localStorage';
import ServerIcon from 'lucide-solid/icons/server';
import BoxesIcon from 'lucide-solid/icons/boxes';
import HardDriveIcon from 'lucide-solid/icons/hard-drive';
import ShieldCheckIcon from 'lucide-solid/icons/shield-check';
import ExternalLinkIcon from 'lucide-solid/icons/external-link';
import XIcon from 'lucide-solid/icons/x';
const DOCS_URL = 'https://github.com/rcourtman/Pulse/blob/main/docs/README.md';
export function WhatsNewModal() {
const [hasSeen, setHasSeen] = createLocalStorageBooleanSignal(
STORAGE_KEYS.WHATS_NEW_NAV_V2_SHOWN,
false,
);
const [isOpen, setIsOpen] = createSignal(false);
const [dontShowAgain, setDontShowAgain] = createSignal(true);
createEffect(() => {
if (!hasSeen()) {
setIsOpen(true);
}
});
const handleClose = () => {
if (dontShowAgain() || hasSeen()) {
setHasSeen(true);
}
setIsOpen(false);
};
return (
<Show when={isOpen()}>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div class="w-full max-w-2xl overflow-hidden rounded-2xl bg-white shadow-2xl dark:bg-gray-800">
<div class="flex items-start justify-between border-b border-gray-200 px-6 py-4 dark:border-gray-700">
<div>
<h2 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
Welcome to the New Navigation!
</h2>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
Everything is now organized by what you want to do, not where the data comes from.
</p>
</div>
<button
onClick={handleClose}
class="rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300"
aria-label="Close"
>
<XIcon class="h-5 w-5" />
</button>
</div>
<div class="space-y-6 px-6 py-5">
<div class="grid gap-4 sm:grid-cols-2">
<div class="rounded-xl border border-blue-200 bg-blue-50/70 p-4 dark:border-blue-800/60 dark:bg-blue-900/20">
<div class="flex items-center gap-2 text-sm font-semibold text-blue-900 dark:text-blue-100">
<ServerIcon class="h-4 w-4" />
Infrastructure
</div>
<p class="mt-2 text-xs text-blue-900/80 dark:text-blue-100/80">
Proxmox nodes, Hosts, and Docker hosts live together in one unified view.
</p>
</div>
<div class="rounded-xl border border-purple-200 bg-purple-50/70 p-4 dark:border-purple-800/60 dark:bg-purple-900/20">
<div class="flex items-center gap-2 text-sm font-semibold text-purple-900 dark:text-purple-100">
<BoxesIcon class="h-4 w-4" />
Workloads
</div>
<p class="mt-2 text-xs text-purple-900/80 dark:text-purple-100/80">
All VMs, containers, and Docker workloads now share a single list.
</p>
</div>
<div class="rounded-xl border border-emerald-200 bg-emerald-50/70 p-4 dark:border-emerald-800/60 dark:bg-emerald-900/20">
<div class="flex items-center gap-2 text-sm font-semibold text-emerald-900 dark:text-emerald-100">
<HardDriveIcon class="h-4 w-4" />
Storage
</div>
<p class="mt-2 text-xs text-emerald-900/80 dark:text-emerald-100/80">
Storage is now a top-level destination across all systems.
</p>
</div>
<div class="rounded-xl border border-amber-200 bg-amber-50/70 p-4 dark:border-amber-800/60 dark:bg-amber-900/20">
<div class="flex items-center gap-2 text-sm font-semibold text-amber-900 dark:text-amber-100">
<ShieldCheckIcon class="h-4 w-4" />
Backups
</div>
<p class="mt-2 text-xs text-amber-900/80 dark:text-amber-100/80">
Backup status and replication are now first-class pages.
</p>
</div>
</div>
<div class="rounded-xl border border-gray-200 bg-gray-50 p-4 text-sm text-gray-700 dark:border-gray-700 dark:bg-gray-900/40 dark:text-gray-300">
<div class="font-medium text-gray-900 dark:text-gray-100">
Quick summary
</div>
<ul class="mt-2 space-y-2">
<li class="flex items-start gap-2">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-blue-500"></span>
<span>Infrastructure combines Proxmox nodes, Hosts, and Docker hosts.</span>
</li>
<li class="flex items-start gap-2">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-purple-500"></span>
<span>Workloads now shows every VM, container, and Docker container.</span>
</li>
<li class="flex items-start gap-2">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-amber-500"></span>
<span>Storage and Backups live at the top level for faster access.</span>
</li>
</ul>
</div>
<div class="flex items-center justify-between gap-4">
<label class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<input
type="checkbox"
checked={dontShowAgain()}
onChange={(event) => setDontShowAgain(event.currentTarget.checked)}
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 focus:ring-2"
/>
Don&#39;t show again
</label>
<a
href={DOCS_URL}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
Documentation
<ExternalLinkIcon class="h-4 w-4" />
</a>
</div>
</div>
<div class="flex items-center justify-end border-t border-gray-200 bg-gray-50 px-6 py-4 dark:border-gray-700 dark:bg-gray-900/50">
<button
onClick={handleClose}
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-blue-700"
>
Let&#39;s go
</button>
</div>
</div>
</div>
</Show>
);
}
export default WhatsNewModal;

View File

@@ -0,0 +1,164 @@
import { createSignal, onCleanup, type Accessor } from 'solid-js';
import { useNavigate } from '@solidjs/router';
type KeyboardShortcutsOptions = {
enabled?: Accessor<boolean>;
isShortcutsOpen?: Accessor<boolean>;
isCommandPaletteOpen?: Accessor<boolean>;
onOpenShortcuts?: () => void;
onCloseShortcuts?: () => void;
onToggleShortcuts?: () => void;
onOpenCommandPalette?: () => void;
onCloseCommandPalette?: () => void;
onToggleCommandPalette?: () => void;
onFocusSearch?: () => boolean | void;
};
const isEditableTarget = (target: EventTarget | null): boolean => {
if (!target || !(target instanceof HTMLElement)) return false;
const tag = target.tagName.toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select') return true;
if (target.isContentEditable) return true;
if (target.getAttribute('role') === 'textbox') return true;
return false;
};
export function useKeyboardShortcuts(options: KeyboardShortcutsOptions = {}) {
const navigate = useNavigate();
const [awaitingSecondKey, setAwaitingSecondKey] = createSignal(false);
let awaitingTimeout: number | undefined;
const clearAwaiting = () => {
if (awaitingTimeout !== undefined) {
window.clearTimeout(awaitingTimeout);
awaitingTimeout = undefined;
}
setAwaitingSecondKey(false);
};
const startAwaiting = () => {
clearAwaiting();
setAwaitingSecondKey(true);
awaitingTimeout = window.setTimeout(() => {
setAwaitingSecondKey(false);
awaitingTimeout = undefined;
}, 1000);
};
const focusSearch = () => {
const handled = options.onFocusSearch?.();
if (handled) return;
const el = document.querySelector<HTMLInputElement>('[data-global-search]');
if (el) {
el.focus();
el.select?.();
}
};
const openShortcuts = () => {
if (options.onToggleShortcuts) {
options.onToggleShortcuts();
return;
}
options.onOpenShortcuts?.();
};
const openCommandPalette = () => {
if (options.onToggleCommandPalette) {
options.onToggleCommandPalette();
return;
}
options.onOpenCommandPalette?.();
};
const routes: Record<string, string> = {
i: '/infrastructure',
w: '/workloads',
s: '/storage',
b: '/backups',
a: '/alerts',
t: '/settings',
};
const handleKeyDown = (e: KeyboardEvent) => {
if (options.enabled && !options.enabled()) {
return;
}
const shortcutsOpen = options.isShortcutsOpen?.() ?? false;
const paletteOpen = options.isCommandPaletteOpen?.() ?? false;
if (e.key === 'Escape') {
if (awaitingSecondKey()) {
clearAwaiting();
}
if (shortcutsOpen) {
options.onCloseShortcuts?.();
}
if (paletteOpen) {
options.onCloseCommandPalette?.();
}
return;
}
if (shortcutsOpen || paletteOpen) {
return;
}
if (isEditableTarget(e.target)) {
return;
}
const key = e.key.toLowerCase();
if (key === 'g' && !awaitingSecondKey() && !e.metaKey && !e.ctrlKey && !e.altKey) {
if (!e.repeat) {
startAwaiting();
}
return;
}
if (awaitingSecondKey()) {
clearAwaiting();
const route = routes[key];
if (route) {
e.preventDefault();
navigate(route);
}
return;
}
if (key === '/' && !e.metaKey && !e.ctrlKey && !e.altKey) {
e.preventDefault();
focusSearch();
return;
}
if ((e.metaKey || e.ctrlKey) && key === 'k') {
e.preventDefault();
openCommandPalette();
return;
}
if (e.key === '?') {
e.preventDefault();
openShortcuts();
}
};
if (typeof document !== 'undefined') {
document.addEventListener('keydown', handleKeyDown);
onCleanup(() => {
document.removeEventListener('keydown', handleKeyDown);
if (awaitingTimeout !== undefined) {
window.clearTimeout(awaitingTimeout);
}
});
}
return {
awaitingSecondKey,
};
}
export default useKeyboardShortcuts;

View File

@@ -1,5 +1,6 @@
import { createResource } from 'solid-js';
import { createEffect, createResource, onCleanup } from 'solid-js';
import { apiFetch } from '@/utils/apiClient';
import { getGlobalWebSocketStore } from '@/stores/websocket-global';
import type { Resource, PlatformType, SourceType, ResourceStatus, ResourceType } from '@/types/resource';
const UNIFIED_RESOURCES_URL = '/api/v2/resources?type=host';
@@ -191,6 +192,43 @@ export function useUnifiedResources() {
const [resources, { refetch, mutate }] = createResource<Resource[]>(fetchUnifiedResources, {
initialValue: [],
});
const wsStore = getGlobalWebSocketStore();
let refreshHandle: number | undefined;
const scheduleRefetch = () => {
if (refreshHandle !== undefined) {
clearTimeout(refreshHandle);
}
refreshHandle = setTimeout(() => {
refreshHandle = undefined;
if (!resources.loading) {
void refetch();
}
}, 800);
};
createEffect(() => {
if (!wsStore.connected() || !wsStore.initialDataReceived()) {
return;
}
// Track resource-adjacent updates from the WebSocket store.
// Accessing these arrays makes this effect react to updates.
void wsStore.state.resources;
void wsStore.state.nodes;
void wsStore.state.hosts;
void wsStore.state.dockerHosts;
void wsStore.state.kubernetesClusters;
void wsStore.state.pbs;
void wsStore.state.pmg;
scheduleRefetch();
});
onCleanup(() => {
if (refreshHandle !== undefined) {
clearTimeout(refreshHandle);
}
});
return {
resources,

View File

@@ -2,7 +2,7 @@ import { createMemo, createResource, type Accessor } from 'solid-js';
import { apiFetchJSON } from '@/utils/apiClient';
import type { WorkloadGuest, WorkloadType } from '@/types/workloads';
const V2_WORKLOADS_URL = '/api/v2/resources?type=vm,lxc,container';
const V2_WORKLOADS_URL = '/api/v2/resources?type=vm,lxc,docker_container';
type V2MetricValue = {
value?: number;
@@ -98,7 +98,9 @@ const resolveWorkloadType = (value?: string | null): WorkloadType | null => {
const normalized = (value || '').trim().toLowerCase();
if (normalized === 'vm' || normalized === 'qemu') return 'vm';
if (normalized === 'lxc') return 'lxc';
if (normalized === 'container' || normalized === 'docker-container') return 'docker';
if (normalized === 'container' || normalized === 'docker-container' || normalized === 'docker_container') {
return 'docker';
}
if (normalized === 'pod' || normalized === 'k8s' || normalized === 'kubernetes') return 'k8s';
return null;
};

View File

@@ -1,4 +1,5 @@
import { For, Show, createMemo, createSignal } from 'solid-js';
import { For, Show, createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
import { useLocation } from '@solidjs/router';
import { SectionHeader } from '@/components/shared/SectionHeader';
import { EmptyState } from '@/components/shared/EmptyState';
import { Card } from '@/components/shared/Card';
@@ -10,9 +11,37 @@ import type { Resource } from '@/types/resource';
export function Infrastructure() {
const { resources, loading, error, refetch } = useUnifiedResources();
const location = useLocation();
const hasResources = createMemo(() => resources().length > 0);
const [selectedSources, setSelectedSources] = createSignal<Set<string>>(new Set());
const [selectedStatuses, setSelectedStatuses] = createSignal<Set<string>>(new Set());
const [expandedResourceId, setExpandedResourceId] = createSignal<string | null>(null);
const [highlightedResourceId, setHighlightedResourceId] = createSignal<string | null>(null);
const [handledResourceId, setHandledResourceId] = createSignal<string | null>(null);
let highlightTimer: number | undefined;
createEffect(() => {
const params = new URLSearchParams(location.search);
const resourceId = params.get('resource');
if (!resourceId || resourceId === handledResourceId()) return;
const matching = resources().some((resource) => resource.id === resourceId);
if (!matching) return;
setExpandedResourceId(resourceId);
setHighlightedResourceId(resourceId);
setHandledResourceId(resourceId);
if (highlightTimer) {
window.clearTimeout(highlightTimer);
}
highlightTimer = window.setTimeout(() => {
setHighlightedResourceId(null);
}, 2000);
});
onCleanup(() => {
if (highlightTimer) {
window.clearTimeout(highlightTimer);
}
});
const sourceOptions = [
{ key: 'proxmox', label: 'PVE' },
@@ -301,7 +330,12 @@ export function Infrastructure() {
</Card>
}
>
<UnifiedResourceTable resources={filteredResources()} />
<UnifiedResourceTable
resources={filteredResources()}
expandedResourceId={expandedResourceId()}
highlightedResourceId={highlightedResourceId()}
onExpandedResourceChange={setExpandedResourceId}
/>
</Show>
</div>
</Show>

View File

@@ -121,6 +121,7 @@ export const STORAGE_KEYS = {
// Storage settings
STORAGE_SHOW_FILTERS: 'storageShowFilters',
STORAGE_VIEW_MODE: 'storageViewMode',
STORAGE_SOURCE_FILTER: 'storageSourceFilter',
// Backup settings
BACKUPS_SHOW_FILTERS: 'backupsShowFilters',
@@ -164,6 +165,8 @@ export const STORAGE_KEYS = {
// Feature discovery
DISMISSED_FEATURE_TIPS: 'pulse-dismissed-feature-tips',
WHATS_NEW_NAV_V2_SHOWN: 'pulse_whats_new_v2_shown',
DEBUG_MODE: 'pulse_debug_mode',
// GitHub star prompt
GITHUB_STAR_DISMISSED: 'pulse-github-star-dismissed',

View File

@@ -8,7 +8,9 @@ export const resolveWorkloadType = (
const rawType = (guest.type || '').toLowerCase();
if (rawType === 'qemu' || rawType === 'vm') return 'vm';
if (rawType === 'lxc' || rawType === 'oci' || rawType === 'container') return 'lxc';
if (rawType === 'docker' || rawType === 'docker-container') return 'docker';
if (rawType === 'docker' || rawType === 'docker-container' || rawType === 'docker_container') {
return 'docker';
}
if (rawType === 'k8s' || rawType === 'pod' || rawType === 'kubernetes') return 'k8s';
return 'lxc';
};

View File

@@ -2,6 +2,9 @@ package api
import (
"encoding/json"
"errors"
"io"
"log"
"net/http"
"sort"
"strconv"
@@ -126,6 +129,10 @@ func (h *ResourceV2Handlers) HandleResourceRoutes(w http.ResponseWriter, r *http
h.HandleUnlink(w, r)
return
}
if strings.HasSuffix(r.URL.Path, "/report-merge") {
h.HandleReportMerge(w, r)
return
}
h.HandleGetResource(w, r)
}
@@ -322,6 +329,116 @@ func (h *ResourceV2Handlers) HandleUnlink(w http.ResponseWriter, r *http.Request
})
}
// HandleReportMerge handles POST /api/v2/resources/{id}/report-merge.
func (h *ResourceV2Handlers) HandleReportMerge(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
orgID := GetOrgID(r.Context())
store, err := h.getStore(orgID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
path := strings.TrimPrefix(r.URL.Path, "/api/v2/resources/")
path = strings.TrimSuffix(path, "/report-merge")
path = strings.TrimSuffix(path, "/")
if path == "" {
http.Error(w, "Resource ID required", http.StatusBadRequest)
return
}
var payload struct {
Sources []string `json:"sources"`
Notes string `json:"notes"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil && !errors.Is(err, io.EOF) {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
registry, err := h.buildRegistry(orgID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
resource, ok := registry.Get(path)
if !ok {
http.Error(w, "Resource not found", http.StatusNotFound)
return
}
if len(resource.Sources) < 2 {
http.Error(w, "Resource is not merged", http.StatusBadRequest)
return
}
sourceTargets := registry.SourceTargets(path)
if len(sourceTargets) == 0 {
http.Error(w, "No source targets found", http.StatusBadRequest)
return
}
filteredSources := make(map[string]struct{})
for _, source := range payload.Sources {
filteredSources[strings.ToLower(strings.TrimSpace(source))] = struct{}{}
}
reason := strings.TrimSpace(payload.Notes)
if reason == "" {
reason = "reported_incorrect_merge"
}
exclusionsAdded := 0
seen := make(map[string]struct{})
for _, target := range sourceTargets {
if len(filteredSources) > 0 {
if _, ok := filteredSources[strings.ToLower(string(target.Source))]; !ok {
continue
}
}
if target.CandidateID == "" || target.CandidateID == path {
continue
}
key := target.CandidateID
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
exclusion := unified.ResourceExclusion{
ResourceA: path,
ResourceB: target.CandidateID,
Reason: reason,
CreatedBy: getUserID(r),
CreatedAt: time.Now().UTC(),
}
if err := store.AddExclusion(exclusion); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
exclusionsAdded += 1
}
if exclusionsAdded == 0 {
http.Error(w, "No exclusions created", http.StatusBadRequest)
return
}
log.Printf("v2 report-merge: resource=%s exclusions=%d user=%s sources=%v", path, exclusionsAdded, getUserID(r), payload.Sources)
h.invalidateCache(orgID)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"message": "Merge reported",
"exclusions": exclusionsAdded,
})
}
// buildRegistry constructs a registry for the current tenant.
func (h *ResourceV2Handlers) buildRegistry(orgID string) (*unified.ResourceRegistry, error) {
store, err := h.getStore(orgID)
@@ -577,7 +694,7 @@ func parseResourceTypesV2(raw string) map[unified.ResourceType]struct{} {
result[unified.ResourceTypeVM] = struct{}{}
case "lxc":
result[unified.ResourceTypeLXC] = struct{}{}
case "container":
case "container", "docker_container", "docker-container":
result[unified.ResourceTypeContainer] = struct{}{}
case "storage":
result[unified.ResourceTypeStorage] = struct{}{}

View File

@@ -323,6 +323,9 @@ var allRouteAllowlist = []string{
"/api/resources",
"/api/resources/stats",
"/api/resources/",
"/api/v2/resources",
"/api/v2/resources/stats",
"/api/v2/resources/",
"/api/guests/metadata",
"/api/guests/metadata/",
"/api/docker/metadata",

View File

@@ -119,6 +119,31 @@ func (rr *ResourceRegistry) Get(id string) (*Resource, bool) {
return r, ok
}
// SourceTargets returns the source-specific IDs that map to the provided resource ID.
func (rr *ResourceRegistry) SourceTargets(resourceID string) []SourceTarget {
rr.mu.RLock()
defer rr.mu.RUnlock()
resource := rr.resources[resourceID]
if resource == nil {
return nil
}
out := make([]SourceTarget, 0)
for source, mapping := range rr.bySource {
for sourceID, mappedID := range mapping {
if mappedID != resourceID {
continue
}
out = append(out, SourceTarget{
Source: source,
SourceID: sourceID,
CandidateID: rr.sourceSpecificID(resource.Type, source, sourceID),
})
}
}
return out
}
// GetChildren returns child resources for a parent.
func (rr *ResourceRegistry) GetChildren(parentID string) []Resource {
rr.mu.RLock()

View File

@@ -93,6 +93,13 @@ type MatchResult struct {
RequiresReview bool `json:"requiresReview"`
}
// SourceTarget describes a source-specific mapping for a unified resource.
type SourceTarget struct {
Source DataSource `json:"source"`
SourceID string `json:"sourceId"`
CandidateID string `json:"candidateId"`
}
// ResourceMetrics contains unified metrics derived from available sources.
type ResourceMetrics struct {
CPU *MetricValue `json:"cpu,omitempty"`

149
pkg/audit/async_logger.go Normal file
View File

@@ -0,0 +1,149 @@
package audit
import (
"sync"
"sync/atomic"
"time"
"github.com/rs/zerolog/log"
)
// AsyncLoggerConfig configures the async audit logger.
type AsyncLoggerConfig struct {
BufferSize int
}
// AsyncLogger wraps a Logger and writes events asynchronously.
type AsyncLogger struct {
backend Logger
queue chan Event
stop chan struct{}
wg sync.WaitGroup
closed atomic.Bool
}
// NewAsyncLogger wraps the provided logger with an async worker.
func NewAsyncLogger(backend Logger, cfg AsyncLoggerConfig) *AsyncLogger {
if backend == nil {
backend = NewConsoleLogger()
}
if cfg.BufferSize <= 0 {
cfg.BufferSize = 4096
}
l := &AsyncLogger{
backend: backend,
queue: make(chan Event, cfg.BufferSize),
stop: make(chan struct{}),
}
l.wg.Add(1)
go l.run()
return l
}
// Log enqueues the event for async processing. If the queue is full, it falls back to sync logging.
func (l *AsyncLogger) Log(event Event) error {
if l == nil {
return nil
}
if l.closed.Load() {
return l.backend.Log(event)
}
select {
case l.queue <- event:
return nil
default:
// Queue full; fall back to synchronous logging to avoid dropping events.
return l.backend.Log(event)
}
}
// Query delegates to the backend logger.
func (l *AsyncLogger) Query(filter QueryFilter) ([]Event, error) {
return l.backend.Query(filter)
}
// Count delegates to the backend logger.
func (l *AsyncLogger) Count(filter QueryFilter) (int, error) {
return l.backend.Count(filter)
}
// GetWebhookURLs delegates to the backend logger.
func (l *AsyncLogger) GetWebhookURLs() []string {
return l.backend.GetWebhookURLs()
}
// UpdateWebhookURLs delegates to the backend logger.
func (l *AsyncLogger) UpdateWebhookURLs(urls []string) error {
return l.backend.UpdateWebhookURLs(urls)
}
// Close drains queued events, stops the worker, and closes the backend logger.
func (l *AsyncLogger) Close() error {
if l == nil {
return nil
}
if l.closed.Swap(true) {
return nil
}
close(l.stop)
l.wg.Wait()
return l.backend.Close()
}
func (l *AsyncLogger) run() {
defer l.wg.Done()
for {
select {
case event := <-l.queue:
l.logEvent(event)
case <-l.stop:
l.drain()
return
}
}
}
func (l *AsyncLogger) drain() {
for {
select {
case event := <-l.queue:
l.logEvent(event)
default:
return
}
}
}
func (l *AsyncLogger) logEvent(event Event) {
start := time.Now()
if err := l.backend.Log(event); err != nil {
log.Error().Err(err).Str("event", event.EventType).Msg("Failed to log audit event")
return
}
if time.Since(start) > 250*time.Millisecond {
log.Warn().
Str("event", event.EventType).
Dur("duration", time.Since(start)).
Msg("Audit log write slow")
}
}
// EnableAsyncLogging wraps the current global logger with an AsyncLogger.
// It is safe to call multiple times.
func EnableAsyncLogging(cfg AsyncLoggerConfig) {
loggerMu.Lock()
defer loggerMu.Unlock()
if globalLogger == nil {
globalLogger = NewConsoleLogger()
}
if _, ok := globalLogger.(*AsyncLogger); ok {
return
}
globalLogger = NewAsyncLogger(globalLogger, cfg)
}

View File

@@ -103,6 +103,20 @@ func GetLogger() Logger {
return globalLogger
}
// Close closes the global audit logger if it implements Close.
func Close() error {
loggerMu.RLock()
l := globalLogger
loggerMu.RUnlock()
if l == nil {
return nil
}
if closer, ok := l.(interface{ Close() error }); ok {
return closer.Close()
}
return nil
}
// Log is a convenience function that logs an event using the global logger.
func Log(eventType, user, ip, path string, success bool, details string) {
event := Event{

View File

@@ -117,6 +117,12 @@ func Run(ctx context.Context, version string) error {
api.SetTenantAuditManager(tenantAuditManager)
log.Info().Msg("Tenant audit manager initialized")
// Enable async audit logging to avoid request latency on audit writes.
if !strings.EqualFold(os.Getenv("PULSE_AUDIT_ASYNC"), "false") {
audit.EnableAsyncLogging(audit.AsyncLoggerConfig{BufferSize: 4096})
log.Info().Msg("Async audit logging enabled")
}
log.Info().Msg("Starting Pulse monitoring server")
// Validate agent binaries are available for download
@@ -384,6 +390,9 @@ shutdown:
// Close tenant audit loggers
tenantAuditManager.Close()
if err := audit.Close(); err != nil {
log.Error().Err(err).Msg("Failed to close audit logger")
}
log.Info().Msg("Server stopped")
return nil