mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 00:17:39 +01:00
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:
12
README.md
12
README.md
@@ -22,6 +22,18 @@ Designed for homelabs, sysadmins, and MSPs who need a "single pane of glass" wit
|
||||
|
||||

|
||||
|
||||
## 🧭 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
|
||||
|
||||
43
docs/MIGRATION_UNIFIED_NAV.md
Normal file
43
docs/MIGRATION_UNIFIED_NAV.md
Normal 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.
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
196
frontend-modern/src/components/shared/CommandPaletteModal.tsx
Normal file
196
frontend-modern/src/components/shared/CommandPaletteModal.tsx
Normal 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;
|
||||
468
frontend-modern/src/components/shared/GlobalSearch.tsx
Normal file
468
frontend-modern/src/components/shared/GlobalSearch.tsx
Normal 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;
|
||||
@@ -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;
|
||||
309
frontend-modern/src/components/shared/MobileNavBar.tsx
Normal file
309
frontend-modern/src/components/shared/MobileNavBar.tsx
Normal 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;
|
||||
155
frontend-modern/src/components/shared/WhatsNewModal.tsx
Normal file
155
frontend-modern/src/components/shared/WhatsNewModal.tsx
Normal 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'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's go
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
||||
export default WhatsNewModal;
|
||||
164
frontend-modern/src/hooks/useKeyboardShortcuts.ts
Normal file
164
frontend-modern/src/hooks/useKeyboardShortcuts.ts
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
};
|
||||
|
||||
@@ -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{}{}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
149
pkg/audit/async_logger.go
Normal 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)
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user