mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-24 04:07:16 +00:00
Fix frontend typings and update notifications layout
This commit is contained in:
@@ -99,7 +99,7 @@ function App() {
|
||||
? savedDarkMode === 'true'
|
||||
: window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const [darkMode, setDarkMode] = createSignal(initialDarkMode);
|
||||
const [hasLoadedServerTheme, setHasLoadedServerTheme] = createSignal(false);
|
||||
const [, setHasLoadedServerTheme] = createSignal(false);
|
||||
|
||||
// Apply dark mode immediately on initialization
|
||||
if (initialDarkMode) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export default function Test() {
|
||||
return <div style={{ padding: '20px', fontSize: '24px' }}>
|
||||
return <div style={{ padding: '20px', 'font-size': '24px' }}>
|
||||
<h1>TEST - APP IS WORKING!</h1>
|
||||
<p>If you see this, the basic app infrastructure works.</p>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,15 +3,15 @@ import type { Alert } from '@/types/api';
|
||||
import { Card } from '@/components/shared/Card';
|
||||
import { SectionHeader } from '@/components/shared/SectionHeader';
|
||||
|
||||
interface Resource {
|
||||
export interface Resource {
|
||||
id: string;
|
||||
name: string;
|
||||
node?: string;
|
||||
instance?: string;
|
||||
type?: string;
|
||||
resourceType?: string;
|
||||
thresholds?: Record<string, number>;
|
||||
defaults?: Record<string, number>;
|
||||
thresholds?: Record<string, number | undefined>;
|
||||
defaults?: Record<string, number | undefined>;
|
||||
disabled?: boolean;
|
||||
disableConnectivity?: boolean;
|
||||
hasOverride?: boolean;
|
||||
@@ -28,15 +28,15 @@ interface ResourceTableProps {
|
||||
groupedResources?: Record<string, Resource[]>;
|
||||
columns: string[];
|
||||
activeAlerts?: Record<string, Alert>;
|
||||
onEdit: (resourceId: string, thresholds: Record<string, number>, defaults: Record<string, number>) => void;
|
||||
onEdit: (resourceId: string, thresholds: Record<string, number | undefined>, defaults: Record<string, number | undefined>) => void;
|
||||
onSaveEdit: (resourceId: string) => void;
|
||||
onCancelEdit: () => void;
|
||||
onRemoveOverride: (resourceId: string) => void;
|
||||
onToggleDisabled?: (resourceId: string) => void;
|
||||
onToggleNodeConnectivity?: (nodeId: string) => void;
|
||||
editingId: () => string | null;
|
||||
editingThresholds: () => Record<string, number>;
|
||||
setEditingThresholds: (value: Record<string, number>) => void;
|
||||
editingThresholds: () => Record<string, number | undefined>;
|
||||
setEditingThresholds: (value: Record<string, number | undefined>) => void;
|
||||
formatMetricValue: (metric: string, value: number | undefined) => string;
|
||||
hasActiveAlert: (resourceId: string, metric: string) => boolean;
|
||||
}
|
||||
@@ -117,15 +117,24 @@ export function ResourceTable(props: ResourceTableProps) {
|
||||
<For each={resources}>
|
||||
{(resource) => {
|
||||
const isEditing = () => props.editingId() === resource.id;
|
||||
const thresholds = () => isEditing() ? props.editingThresholds() : resource.thresholds;
|
||||
const thresholds = (): Record<string, number | undefined> => {
|
||||
if (isEditing()) {
|
||||
return props.editingThresholds();
|
||||
}
|
||||
return resource.thresholds ?? {};
|
||||
};
|
||||
const displayValue = (metric: string): number => {
|
||||
const thresh = thresholds();
|
||||
const defaults = resource.defaults || {};
|
||||
if (isEditing()) {
|
||||
const val = thresh?.[metric] || defaults[metric];
|
||||
return typeof val === 'string' ? (parseFloat(val) || 0) : (val || 0);
|
||||
const val = thresh[metric] ?? defaults[metric];
|
||||
return typeof val === 'number' ? val : Number(val) || 0;
|
||||
}
|
||||
return resource.thresholds?.[metric] || defaults[metric] || 0;
|
||||
const liveValue = resource.thresholds?.[metric];
|
||||
if (typeof liveValue === 'number') {
|
||||
return liveValue;
|
||||
}
|
||||
return typeof defaults[metric] === 'number' ? (defaults[metric] as number) : 0;
|
||||
};
|
||||
const isOverridden = (metric: string) => {
|
||||
return resource.thresholds?.[metric] !== undefined && resource.thresholds?.[metric] !== null;
|
||||
@@ -308,7 +317,11 @@ export function ResourceTable(props: ResourceTableProps) {
|
||||
</>
|
||||
}>
|
||||
<button type="button"
|
||||
onClick={() => props.onEdit(resource.id, resource.thresholds || {}, resource.defaults || {})}
|
||||
onClick={() => props.onEdit(
|
||||
resource.id,
|
||||
resource.thresholds ? { ...resource.thresholds } : {},
|
||||
resource.defaults ? { ...resource.defaults } : {}
|
||||
)}
|
||||
class="p-1 text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
title="Edit thresholds"
|
||||
>
|
||||
@@ -343,7 +356,12 @@ export function ResourceTable(props: ResourceTableProps) {
|
||||
<For each={props.resources}>
|
||||
{(resource) => {
|
||||
const isEditing = () => props.editingId() === resource.id;
|
||||
const thresholds = () => isEditing() ? props.editingThresholds() : resource.thresholds;
|
||||
const thresholds = (): Record<string, number | undefined> => {
|
||||
if (isEditing()) {
|
||||
return props.editingThresholds();
|
||||
}
|
||||
return resource.thresholds ?? {};
|
||||
};
|
||||
const displayValue = (metric: string): number => {
|
||||
const thresh = thresholds();
|
||||
const defaults = resource.defaults || {};
|
||||
@@ -440,11 +458,23 @@ export function ResourceTable(props: ResourceTableProps) {
|
||||
type="number"
|
||||
min="-1"
|
||||
max={metric.includes('disk') || metric.includes('memory') || metric.includes('cpu') || metric === 'usage' ? 100 : 10000}
|
||||
value={thresholds()[metric] || ''}
|
||||
onInput={(e) => props.setEditingThresholds({
|
||||
...props.editingThresholds(),
|
||||
[metric]: parseInt(e.currentTarget.value) || undefined
|
||||
})}
|
||||
value={(() => {
|
||||
const currentThresholds = thresholds();
|
||||
const rawValue = currentThresholds[metric];
|
||||
return rawValue ?? '';
|
||||
})()}
|
||||
onInput={(e) => {
|
||||
const parsed = e.currentTarget.value.trim();
|
||||
let nextValue: number | undefined;
|
||||
if (parsed !== '') {
|
||||
const numeric = Number(parsed);
|
||||
nextValue = Number.isFinite(numeric) ? numeric : undefined;
|
||||
}
|
||||
props.setEditingThresholds({
|
||||
...props.editingThresholds(),
|
||||
[metric]: nextValue
|
||||
});
|
||||
}}
|
||||
class="w-14 px-1 py-0.5 text-sm text-center border border-gray-300 dark:border-gray-600 rounded
|
||||
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
@@ -535,7 +565,11 @@ export function ResourceTable(props: ResourceTableProps) {
|
||||
</>
|
||||
}>
|
||||
<button type="button"
|
||||
onClick={() => props.onEdit(resource.id, resource.thresholds || {}, resource.defaults || {})}
|
||||
onClick={() => props.onEdit(
|
||||
resource.id,
|
||||
resource.thresholds ? { ...resource.thresholds } : {},
|
||||
resource.defaults ? { ...resource.defaults } : {}
|
||||
)}
|
||||
class="p-1 text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
title="Edit thresholds"
|
||||
>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createSignal, createMemo, Show, onMount, onCleanup } from 'solid-js';
|
||||
import type { VM, Container, Node, Alert, Storage, PBSInstance } from '@/types/api';
|
||||
import { ResourceTable } from './ResourceTable';
|
||||
import type { RawOverrideConfig } from '@/types/alerts';
|
||||
import { ResourceTable, Resource } from './ResourceTable';
|
||||
import { Card } from '@/components/shared/Card';
|
||||
import { SectionHeader } from '@/components/shared/SectionHeader';
|
||||
|
||||
@@ -41,8 +42,8 @@ interface SimpleThresholds {
|
||||
interface ThresholdsTableProps {
|
||||
overrides: () => Override[];
|
||||
setOverrides: (overrides: Override[]) => void;
|
||||
rawOverridesConfig: () => Record<string, unknown>;
|
||||
setRawOverridesConfig: (config: Record<string, unknown>) => void;
|
||||
rawOverridesConfig: () => Record<string, RawOverrideConfig>;
|
||||
setRawOverridesConfig: (config: Record<string, RawOverrideConfig>) => void;
|
||||
allGuests: () => (VM | Container)[];
|
||||
nodes: Node[];
|
||||
storage: Storage[];
|
||||
@@ -64,7 +65,7 @@ interface ThresholdsTableProps {
|
||||
export function ThresholdsTable(props: ThresholdsTableProps) {
|
||||
const [searchTerm, setSearchTerm] = createSignal('');
|
||||
const [editingId, setEditingId] = createSignal<string | null>(null);
|
||||
const [editingThresholds, setEditingThresholds] = createSignal<Record<string, number>>({});
|
||||
const [editingThresholds, setEditingThresholds] = createSignal<Record<string, number | undefined>>({});
|
||||
|
||||
let searchInputRef: HTMLInputElement | undefined;
|
||||
|
||||
@@ -135,10 +136,10 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
|
||||
};
|
||||
|
||||
// Process nodes with their overrides
|
||||
const nodesWithOverrides = createMemo((prev) => {
|
||||
const nodesWithOverrides = createMemo<Resource[]>((prev = []) => {
|
||||
// If we're currently editing, return the previous value to avoid re-renders
|
||||
if (editingId()) {
|
||||
return prev || [];
|
||||
return prev;
|
||||
}
|
||||
|
||||
const search = searchTerm().toLowerCase();
|
||||
@@ -173,13 +174,13 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
|
||||
return nodes.filter(n => n.name.toLowerCase().includes(search));
|
||||
}
|
||||
return nodes;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Process guests with their overrides and group by node
|
||||
const guestsGroupedByNode = createMemo((prev) => {
|
||||
const guestsGroupedByNode = createMemo<Record<string, Resource[]>>((prev = {}) => {
|
||||
// If we're currently editing, return the previous value to avoid re-renders
|
||||
if (editingId()) {
|
||||
return prev || {};
|
||||
return prev;
|
||||
}
|
||||
|
||||
const search = searchTerm().toLowerCase();
|
||||
@@ -225,7 +226,7 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
|
||||
: guests;
|
||||
|
||||
// Group by node
|
||||
const grouped: Record<string, typeof filteredGuests> = {};
|
||||
const grouped: Record<string, Resource[]> = {};
|
||||
filteredGuests.forEach(guest => {
|
||||
const node = guest.node || 'Unknown';
|
||||
if (!grouped[node]) {
|
||||
@@ -243,13 +244,13 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
|
||||
});
|
||||
|
||||
return grouped;
|
||||
});
|
||||
}, {});
|
||||
|
||||
// Process PBS servers with their overrides
|
||||
const pbsServersWithOverrides = createMemo((prev) => {
|
||||
const pbsServersWithOverrides = createMemo<Resource[]>((prev = []) => {
|
||||
// If we're currently editing, return the previous value to avoid re-renders
|
||||
if (editingId()) {
|
||||
return prev || [];
|
||||
return prev;
|
||||
}
|
||||
|
||||
const search = searchTerm().toLowerCase();
|
||||
@@ -258,7 +259,9 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
|
||||
// Get PBS instances from props
|
||||
const pbsInstances = props.pbsInstances || [];
|
||||
|
||||
const pbsServers = pbsInstances.filter((pbs) => (pbs.cpu || 0) > 0 || (pbs.memory?.usage || 0) > 0).map((pbs) => {
|
||||
const pbsServers = pbsInstances
|
||||
.filter((pbs) => (pbs.cpu || 0) > 0 || (pbs.memory || 0) > 0)
|
||||
.map((pbs) => {
|
||||
// PBS IDs already have "pbs-" prefix from backend, don't double it
|
||||
const pbsId = pbs.id;
|
||||
const override = overridesMap.get(pbsId);
|
||||
@@ -302,13 +305,13 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
|
||||
);
|
||||
}
|
||||
return pbsServers;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Process storage with their overrides
|
||||
const storageWithOverrides = createMemo((prev) => {
|
||||
const storageWithOverrides = createMemo<Resource[]>((prev = []) => {
|
||||
// If we're currently editing, return the previous value to avoid re-renders
|
||||
if (editingId()) {
|
||||
return prev || [];
|
||||
return prev;
|
||||
}
|
||||
|
||||
const search = searchTerm().toLowerCase();
|
||||
@@ -346,10 +349,14 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
|
||||
);
|
||||
}
|
||||
return storageDevices;
|
||||
});
|
||||
}, []);
|
||||
|
||||
|
||||
const startEditing = (resourceId: string, currentThresholds: Record<string, number>, defaults: Record<string, number>) => {
|
||||
const startEditing = (
|
||||
resourceId: string,
|
||||
currentThresholds: Record<string, number | undefined>,
|
||||
defaults: Record<string, number | undefined>
|
||||
) => {
|
||||
setEditingId(resourceId);
|
||||
// Merge defaults with overrides for editing
|
||||
const mergedThresholds = { ...defaults, ...currentThresholds };
|
||||
@@ -364,14 +371,14 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
|
||||
if (!resource) return;
|
||||
|
||||
const editedThresholds = editingThresholds();
|
||||
const defaultThresholds = resource.defaults;
|
||||
const defaultThresholds = (resource.defaults ?? {}) as Record<string, number | undefined>;
|
||||
|
||||
// Only include values that differ from defaults
|
||||
const overrideThresholds: Record<string, number> = {};
|
||||
Object.keys(editedThresholds).forEach(key => {
|
||||
const editedValue = editedThresholds[key];
|
||||
const defaultValue = defaultThresholds[key as keyof typeof defaultThresholds];
|
||||
if (editedValue !== defaultValue && editedValue !== undefined && editedValue !== '') {
|
||||
if (editedValue !== undefined && editedValue !== defaultValue) {
|
||||
overrideThresholds[key] = editedValue;
|
||||
}
|
||||
});
|
||||
@@ -417,13 +424,13 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
|
||||
}
|
||||
|
||||
// Update raw config
|
||||
const newRawConfig = { ...props.rawOverridesConfig() };
|
||||
const hysteresisThresholds: Record<string, any> = {};
|
||||
const newRawConfig: Record<string, RawOverrideConfig> = { ...props.rawOverridesConfig() };
|
||||
const hysteresisThresholds: RawOverrideConfig = {};
|
||||
Object.entries(overrideThresholds).forEach(([metric, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
hysteresisThresholds[metric] = {
|
||||
trigger: value,
|
||||
clear: Math.max(0, (value as number) - 5)
|
||||
clear: Math.max(0, value - 5)
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -505,15 +512,15 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
|
||||
}
|
||||
|
||||
// Update raw config
|
||||
const newRawConfig = { ...props.rawOverridesConfig() };
|
||||
const hysteresisThresholds: Record<string, any> = {};
|
||||
const newRawConfig: Record<string, RawOverrideConfig> = { ...props.rawOverridesConfig() };
|
||||
const hysteresisThresholds: RawOverrideConfig = {};
|
||||
|
||||
// Only add threshold overrides that differ from defaults
|
||||
Object.entries(override.thresholds).forEach(([metric, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
if (typeof value === 'number') {
|
||||
hysteresisThresholds[metric] = {
|
||||
trigger: value,
|
||||
clear: Math.max(0, (value as number) - 5)
|
||||
clear: Math.max(0, value - 5)
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -9,31 +9,10 @@ import { BackupsFilter } from './BackupsFilter';
|
||||
import { Card } from '@/components/shared/Card';
|
||||
import { EmptyState } from '@/components/shared/EmptyState';
|
||||
import { SectionHeader } from '@/components/shared/SectionHeader';
|
||||
import type { BackupType, GuestType, UnifiedBackup } from '@/types/backups';
|
||||
|
||||
type BackupType = 'snapshot' | 'local' | 'remote';
|
||||
type GuestType = 'VM' | 'LXC' | 'Host' | 'Template' | 'ISO';
|
||||
type FilterableGuestType = 'VM' | 'LXC' | 'Host';
|
||||
|
||||
interface UnifiedBackup {
|
||||
backupType: BackupType;
|
||||
vmid: number;
|
||||
name: string;
|
||||
type: GuestType;
|
||||
node: string;
|
||||
backupTime: number;
|
||||
backupName: string;
|
||||
description: string;
|
||||
status: string;
|
||||
size: number | null;
|
||||
storage: string | null;
|
||||
datastore: string | null;
|
||||
namespace: string | null;
|
||||
verified: boolean | null;
|
||||
protected: boolean;
|
||||
encrypted?: boolean;
|
||||
owner?: string;
|
||||
}
|
||||
|
||||
// Types for PBS backups - temporarily disabled to avoid unused warnings
|
||||
// type PBSBackup = any;
|
||||
// type PBSSnapshot = any;
|
||||
@@ -1008,7 +987,6 @@ const UnifiedBackups: Component = () => {
|
||||
// Only lock if we're setting a filter, unlock if clearing
|
||||
setIsSearchLocked(namespaceFilter !== '');
|
||||
}}
|
||||
filteredBackups={(searchTerm() || backupTypeFilter() !== 'all') ? filteredData() : undefined}
|
||||
searchTerm={searchTerm()}
|
||||
/>
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ import { GuestMetadataAPI } from '@/api/guestMetadata';
|
||||
import type { GuestMetadata } from '@/api/guestMetadata';
|
||||
import { Card } from '@/components/shared/Card';
|
||||
import { EmptyState } from '@/components/shared/EmptyState';
|
||||
import { SectionHeader } from '@/components/shared/SectionHeader';
|
||||
|
||||
interface DashboardProps {
|
||||
vms: VM[];
|
||||
|
||||
@@ -31,11 +31,6 @@ interface GuestRowProps {
|
||||
export function GuestRow(props: GuestRowProps) {
|
||||
const [customUrl, setCustomUrl] = createSignal<string | undefined>(props.customUrl);
|
||||
|
||||
// Create guest ID for metadata
|
||||
const guestId = createMemo(() => {
|
||||
return props.guest.id || `${props.guest.node}-${props.guest.vmid}`;
|
||||
});
|
||||
|
||||
// Update custom URL when prop changes
|
||||
createEffect(() => {
|
||||
setCustomUrl(props.customUrl);
|
||||
@@ -146,7 +141,7 @@ export function GuestRow(props: GuestRowProps) {
|
||||
|
||||
{/* Tag badges */}
|
||||
<TagBadges
|
||||
tags={props.guest.tags}
|
||||
tags={Array.isArray(props.guest.tags) ? props.guest.tags : []}
|
||||
maxVisible={3}
|
||||
onTagClick={props.onTagClick}
|
||||
activeSearch={props.activeSearch}
|
||||
@@ -240,4 +235,4 @@ export function GuestRow(props: GuestRowProps) {
|
||||
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Component, Show, createMemo } from 'solid-js';
|
||||
import type { PBSInstance } from '@/types/api';
|
||||
import { formatUptime, formatBytes } from '@/utils/format';
|
||||
import { Card } from '@/components/shared/Card';
|
||||
import { SectionHeader } from '@/components/shared/SectionHeader';
|
||||
|
||||
interface PBSCardProps {
|
||||
instance: PBSInstance;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getTagColorWithSpecial } from '@/utils/tagColors';
|
||||
import { useDarkMode } from '@/App';
|
||||
|
||||
interface TagBadgesProps {
|
||||
tags: string[];
|
||||
tags?: string[];
|
||||
maxVisible?: number;
|
||||
isDarkMode?: boolean;
|
||||
onTagClick?: (tag: string) => void;
|
||||
@@ -133,4 +133,4 @@ export const TagBadges: Component<TagBadgesProps> = (props) => {
|
||||
</Portal>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -29,6 +29,16 @@ interface DiscoveredServer {
|
||||
release?: string;
|
||||
}
|
||||
|
||||
type RawDiscoveredServer = {
|
||||
ip?: string;
|
||||
port?: number;
|
||||
type?: string;
|
||||
version?: string;
|
||||
hostname?: string;
|
||||
name?: string;
|
||||
release?: string;
|
||||
};
|
||||
|
||||
interface ClusterEndpoint {
|
||||
Host?: string;
|
||||
IP?: string;
|
||||
@@ -227,54 +237,99 @@ const Settings: Component = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const updateDiscoveredNodesFromServers = (servers: RawDiscoveredServer[] | undefined | null, options: { merge?: boolean } = {}) => {
|
||||
const { merge = false } = options;
|
||||
|
||||
if (!servers || servers.length === 0) {
|
||||
if (!merge) {
|
||||
setDiscoveredNodes([]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare sets of configured hosts and cluster member IPs to filter duplicates
|
||||
const configuredHosts = new Set<string>();
|
||||
const clusterMemberIPs = new Set<string>();
|
||||
|
||||
nodes().forEach((n) => {
|
||||
const cleanedHost = n.host.replace(/^https?:\/\//, '').replace(/:\d+$/, '');
|
||||
configuredHosts.add(cleanedHost.toLowerCase());
|
||||
|
||||
if (n.type === 'pve' && 'isCluster' in n && n.isCluster && 'clusterEndpoints' in n && n.clusterEndpoints) {
|
||||
n.clusterEndpoints.forEach((endpoint: ClusterEndpoint) => {
|
||||
if (endpoint.IP) {
|
||||
clusterMemberIPs.add(endpoint.IP.toLowerCase());
|
||||
}
|
||||
if (endpoint.Host) {
|
||||
clusterMemberIPs.add(endpoint.Host.toLowerCase());
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const normalized = servers
|
||||
.map((server): DiscoveredServer | null => {
|
||||
const ip = (server.ip || '').trim();
|
||||
const type = (server.type || '').toLowerCase();
|
||||
const port = typeof server.port === 'number' ? server.port : type === 'pbs' ? 8007 : 8006;
|
||||
|
||||
if (!ip || (type !== 'pve' && type !== 'pbs')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hostname = (server.hostname || server.name || '').trim();
|
||||
const version = (server.version || '').trim();
|
||||
const release = (server.release || '').trim();
|
||||
|
||||
return {
|
||||
ip,
|
||||
port,
|
||||
type: type as 'pve' | 'pbs',
|
||||
version: version || 'Unknown',
|
||||
hostname: hostname || undefined,
|
||||
release: release || undefined,
|
||||
};
|
||||
})
|
||||
.filter((server): server is DiscoveredServer => server !== null);
|
||||
|
||||
const filtered = normalized.filter((server) => {
|
||||
const serverIP = server.ip.toLowerCase();
|
||||
const serverHostname = server.hostname?.toLowerCase();
|
||||
|
||||
if (configuredHosts.has(serverIP) || (serverHostname && configuredHosts.has(serverHostname))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (clusterMemberIPs.has(serverIP) || (serverHostname && clusterMemberIPs.has(serverHostname))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (merge) {
|
||||
setDiscoveredNodes((prev) => {
|
||||
const existingMap = new Map(prev.map((item) => [`${item.ip}:${item.port}`, item]));
|
||||
filtered.forEach((server) => {
|
||||
existingMap.set(`${server.ip}:${server.port}`, server);
|
||||
});
|
||||
return Array.from(existingMap.values());
|
||||
});
|
||||
} else {
|
||||
setDiscoveredNodes(filtered);
|
||||
}
|
||||
};
|
||||
|
||||
const loadDiscoveredNodes = async () => {
|
||||
try {
|
||||
const { apiFetch } = await import('@/utils/apiClient');
|
||||
const response = await apiFetch('/api/discover');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.servers && Array.isArray(data.servers)) {
|
||||
// Get all configured hosts and cluster member IPs
|
||||
const configuredHosts = new Set<string>();
|
||||
const clusterMemberIPs = new Set<string>();
|
||||
|
||||
nodes().forEach(n => {
|
||||
// Add the main host
|
||||
const host = n.host.replace(/^https?:\/\//, '').replace(/:\d+$/, '');
|
||||
configuredHosts.add(host.toLowerCase());
|
||||
|
||||
// If it's a cluster, add all member IPs
|
||||
if (n.type === 'pve' && 'isCluster' in n && n.isCluster && 'clusterEndpoints' in n && n.clusterEndpoints) {
|
||||
n.clusterEndpoints.forEach((endpoint: ClusterEndpoint) => {
|
||||
if (endpoint.IP) {
|
||||
clusterMemberIPs.add(endpoint.IP.toLowerCase());
|
||||
}
|
||||
if (endpoint.Host) {
|
||||
clusterMemberIPs.add(endpoint.Host.toLowerCase());
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Filter out nodes that are already configured or part of a cluster
|
||||
const filtered = data.servers.filter((server: DiscoveredServer) => {
|
||||
const serverIP = server.ip?.toLowerCase();
|
||||
const serverHostname = server.hostname?.toLowerCase();
|
||||
|
||||
// Check if this server is already configured directly
|
||||
if ((serverIP && configuredHosts.has(serverIP)) || (serverHostname && configuredHosts.has(serverHostname))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if this server is part of a configured cluster
|
||||
if ((serverIP && clusterMemberIPs.has(serverIP)) || (serverHostname && clusterMemberIPs.has(serverHostname))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
setDiscoveredNodes(filtered);
|
||||
if (Array.isArray(data.servers)) {
|
||||
updateDiscoveredNodesFromServers(data.servers as RawDiscoveredServer[]);
|
||||
} else {
|
||||
updateDiscoveredNodesFromServers([]);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -299,27 +354,16 @@ const Settings: Component = () => {
|
||||
});
|
||||
|
||||
const unsubscribeDiscovery = eventBus.on('discovery_updated', (data) => {
|
||||
// If this is an immediate update (from node deletion), merge with existing
|
||||
if (data && data.immediate && data.servers) {
|
||||
setDiscoveredNodes(prev => {
|
||||
// Create a map of existing servers by IP:port
|
||||
const existingMap = new Map(prev.map(s => [`${s.ip}:${s.port}`, s]));
|
||||
|
||||
// Add/update the new servers
|
||||
data.servers.forEach((server) => {
|
||||
const discoveredServer: DiscoveredServer = {
|
||||
...server,
|
||||
type: server.type as 'pbs' | 'pve'
|
||||
};
|
||||
existingMap.set(`${server.ip}:${server.port}`, discoveredServer);
|
||||
});
|
||||
|
||||
// Convert back to array
|
||||
return Array.from(existingMap.values());
|
||||
});
|
||||
} else {
|
||||
// Full discovery update - reload from API
|
||||
loadDiscoveredNodes();
|
||||
if (!data) {
|
||||
updateDiscoveredNodesFromServers([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(data.servers)) {
|
||||
updateDiscoveredNodesFromServers(data.servers as RawDiscoveredServer[], { merge: !!data.immediate });
|
||||
} else if (!data.immediate) {
|
||||
// Ensure we clear stale results when the update explicitly reports no servers
|
||||
updateDiscoveredNodesFromServers([]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2088,7 +2132,7 @@ const Settings: Component = () => {
|
||||
{(() => {
|
||||
const sanitizeForGitHub = (data: Record<string, unknown>) => {
|
||||
// Deep clone the data
|
||||
const sanitized = JSON.parse(JSON.stringify(data));
|
||||
const sanitized = JSON.parse(JSON.stringify(data)) as Record<string, unknown>;
|
||||
|
||||
// Sanitize IP addresses (keep first octet for network type identification)
|
||||
const sanitizeIP = (ip: string) => {
|
||||
@@ -2111,78 +2155,112 @@ const Settings: Component = () => {
|
||||
|
||||
// Sanitize nodes
|
||||
if (sanitized.nodes) {
|
||||
sanitized.nodes = (sanitized.nodes as Array<Record<string, unknown>>).map((node, index: number) => ({
|
||||
...node,
|
||||
id: `${node.type}-${index}`,
|
||||
name: sanitizeHostname(node.name),
|
||||
host: node.host ? node.host.replace(/https?:\/\/[^:\/]+/, 'https://REDACTED') : node.host,
|
||||
tokenName: node.tokenName ? 'token-REDACTED' : node.tokenName,
|
||||
clusterName: node.clusterName ? 'cluster-REDACTED' : node.clusterName,
|
||||
clusterEndpoints: node.clusterEndpoints ? (node.clusterEndpoints as Array<Record<string, unknown>>).map((ep, epIndex: number) => ({
|
||||
...ep,
|
||||
NodeName: `node-${epIndex + 1}`,
|
||||
Host: `node-${epIndex + 1}`,
|
||||
IP: sanitizeIP(ep.IP)
|
||||
})) : node.clusterEndpoints
|
||||
}));
|
||||
sanitized.nodes = (sanitized.nodes as Array<Record<string, unknown>>).map((node, index: number) => {
|
||||
const nodeType = typeof node.type === 'string' ? (node.type as string) : 'node';
|
||||
const nodeName = typeof node.name === 'string' ? (node.name as string) : '';
|
||||
const nodeHost = typeof node.host === 'string' ? (node.host as string) : '';
|
||||
const tokenName = typeof node.tokenName === 'string' ? (node.tokenName as string) : undefined;
|
||||
const clusterName = typeof node.clusterName === 'string' ? (node.clusterName as string) : undefined;
|
||||
const clusterEndpoints = Array.isArray(node.clusterEndpoints)
|
||||
? (node.clusterEndpoints as Array<Record<string, unknown>>).map((ep, epIndex: number) => ({
|
||||
...ep,
|
||||
NodeName: `node-${epIndex + 1}`,
|
||||
Host: `node-${epIndex + 1}`,
|
||||
IP: sanitizeIP(typeof ep.IP === 'string' ? ep.IP : '')
|
||||
}))
|
||||
: node.clusterEndpoints;
|
||||
|
||||
return {
|
||||
...node,
|
||||
id: `${nodeType}-${index}`,
|
||||
name: sanitizeHostname(nodeName),
|
||||
host: nodeHost ? nodeHost.replace(/https?:\/\/[^:\/]+/, 'https://REDACTED') : nodeHost,
|
||||
tokenName: tokenName ? 'token-REDACTED' : tokenName,
|
||||
clusterName: clusterName ? 'cluster-REDACTED' : clusterName,
|
||||
clusterEndpoints
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Sanitize storage
|
||||
if (sanitized.storage) {
|
||||
sanitized.storage = (sanitized.storage as Array<Record<string, unknown>>).map((s, index: number) => ({
|
||||
...s,
|
||||
id: `storage-${index}`,
|
||||
node: sanitizeHostname(s.node),
|
||||
name: `storage-${index}`
|
||||
}));
|
||||
sanitized.storage = (sanitized.storage as Array<Record<string, unknown>>).map((s, index: number) => {
|
||||
const storageNode = typeof s.node === 'string' ? s.node : '';
|
||||
return {
|
||||
...s,
|
||||
id: `storage-${index}`,
|
||||
node: sanitizeHostname(storageNode),
|
||||
name: `storage-${index}`
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Sanitize backups
|
||||
if (sanitized.backups) {
|
||||
const backups = sanitized.backups as Record<string, unknown> | undefined;
|
||||
if (backups) {
|
||||
// Sanitize PVE backup tasks
|
||||
if (sanitized.backups.pveBackupTasks) {
|
||||
sanitized.backups.pveBackupTasks = (sanitized.backups.pveBackupTasks as Array<Record<string, unknown>>).map((b, index: number) => ({
|
||||
...b,
|
||||
node: sanitizeHostname(b.node),
|
||||
storage: `storage-${index}`,
|
||||
vmid: b.vmid ? `vm-${b.vmid}` : b.vmid
|
||||
}));
|
||||
if (Array.isArray(backups.pveBackupTasks)) {
|
||||
backups.pveBackupTasks = (backups.pveBackupTasks as Array<Record<string, unknown>>).map((b, index: number) => {
|
||||
const backupNode = typeof b.node === 'string' ? b.node : '';
|
||||
const backupVmid = typeof b.vmid === 'number' ? b.vmid : undefined;
|
||||
return {
|
||||
...b,
|
||||
node: sanitizeHostname(backupNode),
|
||||
storage: `storage-${index}`,
|
||||
vmid: backupVmid !== undefined ? `vm-${backupVmid}` : backupVmid
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Sanitize PVE storage backups
|
||||
if (sanitized.backups.pveStorageBackups) {
|
||||
sanitized.backups.pveStorageBackups = (sanitized.backups.pveStorageBackups as Array<Record<string, unknown>>).map((b, index: number) => ({
|
||||
...b,
|
||||
node: sanitizeHostname(b.node),
|
||||
storage: `storage-${index}`,
|
||||
vmid: b.vmid ? `vm-${b.vmid}` : b.vmid,
|
||||
volid: b.volid ? `vol-REDACTED` : b.volid
|
||||
}));
|
||||
if (Array.isArray(backups.pveStorageBackups)) {
|
||||
backups.pveStorageBackups = (backups.pveStorageBackups as Array<Record<string, unknown>>).map((b, index: number) => {
|
||||
const backupNode = typeof b.node === 'string' ? b.node : '';
|
||||
const backupVmid = typeof b.vmid === 'number' ? b.vmid : undefined;
|
||||
const volid = typeof b.volid === 'string' ? b.volid : undefined;
|
||||
return {
|
||||
...b,
|
||||
node: sanitizeHostname(backupNode),
|
||||
storage: `storage-${index}`,
|
||||
vmid: backupVmid !== undefined ? `vm-${backupVmid}` : backupVmid,
|
||||
volid: volid ? 'vol-REDACTED' : volid
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Sanitize PBS backups
|
||||
if (sanitized.backups.pbsBackups) {
|
||||
sanitized.backups.pbsBackups = (sanitized.backups.pbsBackups as Array<Record<string, unknown>>).map((b, index: number) => ({
|
||||
...b,
|
||||
datastore: `datastore-${index}`,
|
||||
backupId: b.backupId ? `backup-${index}` : b.backupId,
|
||||
vmName: b.vmName ? `vm-REDACTED` : b.vmName
|
||||
}));
|
||||
if (Array.isArray(backups.pbsBackups)) {
|
||||
backups.pbsBackups = (backups.pbsBackups as Array<Record<string, unknown>>).map((b, index: number) => {
|
||||
const backupId = typeof b.backupId === 'string' ? b.backupId : undefined;
|
||||
const vmName = typeof b.vmName === 'string' ? b.vmName : undefined;
|
||||
return {
|
||||
...b,
|
||||
datastore: `datastore-${index}`,
|
||||
backupId: backupId ? `backup-${index}` : backupId,
|
||||
vmName: vmName ? 'vm-REDACTED' : vmName
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Sanitize active alerts
|
||||
if (sanitized.activeAlerts) {
|
||||
sanitized.activeAlerts = (sanitized.activeAlerts as Array<Record<string, unknown>>).map((alert) => ({
|
||||
...alert,
|
||||
node: sanitizeHostname(alert.node),
|
||||
details: alert.details ? alert.details.replace(/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/g, 'xxx.xxx.xxx.xxx') : alert.details
|
||||
}));
|
||||
const activeAlerts = sanitized.activeAlerts as Array<Record<string, unknown>> | undefined;
|
||||
if (activeAlerts) {
|
||||
sanitized.activeAlerts = activeAlerts.map((alert) => {
|
||||
const alertNode = typeof alert.node === 'string' ? alert.node : '';
|
||||
const details = typeof alert.details === 'string' ? alert.details : undefined;
|
||||
return {
|
||||
...alert,
|
||||
node: sanitizeHostname(alertNode),
|
||||
details: details ? details.replace(/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/g, 'xxx.xxx.xxx.xxx') : details
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Sanitize websocket URL
|
||||
if (sanitized.websocket?.url) {
|
||||
sanitized.websocket.url = sanitized.websocket.url.replace(/\/\/[^\/]+/, '//REDACTED');
|
||||
const websocketInfo = sanitized.websocket as Record<string, unknown> | undefined;
|
||||
if (websocketInfo && typeof websocketInfo.url === 'string') {
|
||||
websocketInfo.url = websocketInfo.url.replace(/\/\/[^\/]+/, '//REDACTED');
|
||||
}
|
||||
|
||||
// Add sanitization notice
|
||||
@@ -2195,7 +2273,7 @@ const Settings: Component = () => {
|
||||
let diagnostics: Record<string, unknown> = {
|
||||
timestamp: new Date().toISOString(),
|
||||
version: '2.1.0',
|
||||
pulseVersion: state.version || 'unknown',
|
||||
pulseVersion: state.stats?.version || 'unknown',
|
||||
environment: {
|
||||
userAgent: navigator.userAgent,
|
||||
platform: navigator.platform,
|
||||
@@ -2240,7 +2318,7 @@ const Settings: Component = () => {
|
||||
cpu: n.cpu,
|
||||
memory: n.memory,
|
||||
uptime: n.uptime,
|
||||
version: n.version
|
||||
version: n.pveVersion ?? n.kernelVersion
|
||||
})) || [],
|
||||
storage: state.storage?.map(s => ({
|
||||
id: s.id,
|
||||
@@ -2265,14 +2343,14 @@ const Settings: Component = () => {
|
||||
// Physical disks - critical for troubleshooting
|
||||
physicalDisks: state.physicalDisks?.map(d => ({
|
||||
node: d.node,
|
||||
device: d.device,
|
||||
device: d.device || d.devPath,
|
||||
model: d.model,
|
||||
size: d.size,
|
||||
type: d.type,
|
||||
health: d.health,
|
||||
wearout: d.wearout,
|
||||
rpm: d.rpm,
|
||||
smart: d.smart
|
||||
smart: d.smart ?? null
|
||||
})) || [],
|
||||
backups: {
|
||||
pveBackupTasks: state.pveBackups?.backupTasks?.slice(0, 10) || [],
|
||||
@@ -2287,10 +2365,6 @@ const Settings: Component = () => {
|
||||
apiCallDuration: state.performance?.apiCallDuration || {}
|
||||
},
|
||||
activeAlerts: state.activeAlerts?.slice(0, 20) || [],
|
||||
// Alert configuration - helps debug threshold save issues
|
||||
alertConfig: state.alertConfig || null,
|
||||
// Recent errors if any
|
||||
recentErrors: state.errors?.slice(0, 20) || [],
|
||||
settings: {
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Component, For, Show, createMemo, createSignal } from 'solid-js';
|
||||
import { Component, For, Show, createMemo } from 'solid-js';
|
||||
import { Card } from '@/components/shared/Card';
|
||||
import { formatBytes } from '@/utils/format';
|
||||
import { useWebSocket } from '@/App';
|
||||
import type { PhysicalDisk } from '@/types/api';
|
||||
|
||||
interface DiskListProps {
|
||||
@@ -11,8 +10,6 @@ interface DiskListProps {
|
||||
}
|
||||
|
||||
export const DiskList: Component<DiskListProps> = (props) => {
|
||||
const { state } = useWebSocket();
|
||||
|
||||
// Filter disks based on selected node and search term
|
||||
const filteredDisks = createMemo(() => {
|
||||
let disks = props.disks || [];
|
||||
@@ -195,4 +192,4 @@ export const DiskList: Component<DiskListProps> = (props) => {
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Component, For, Show, createSignal, createMemo, createEffect } from 'so
|
||||
import { useWebSocket } from '@/App';
|
||||
import { getAlertStyles } from '@/utils/alerts';
|
||||
import { formatBytes } from '@/utils/format';
|
||||
import { createTooltipSystem } from '@/components/shared/Tooltip';
|
||||
import type { Storage as StorageType } from '@/types/api';
|
||||
import { ComponentErrorBoundary } from '@/components/ErrorBoundary';
|
||||
import { UnifiedNodeSelector } from '@/components/shared/UnifiedNodeSelector';
|
||||
@@ -10,7 +9,6 @@ import { StorageFilter } from './StorageFilter';
|
||||
import { DiskList } from './DiskList';
|
||||
import { Card } from '@/components/shared/Card';
|
||||
import { EmptyState } from '@/components/shared/EmptyState';
|
||||
import { SectionHeader } from '@/components/shared/SectionHeader';
|
||||
|
||||
|
||||
const Storage: Component = () => {
|
||||
@@ -23,9 +21,6 @@ const Storage: Component = () => {
|
||||
// const [sortKey, setSortKey] = createSignal('name');
|
||||
// const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc');
|
||||
|
||||
// Create tooltip system
|
||||
const TooltipComponent = createTooltipSystem();
|
||||
|
||||
// Create a mapping from node name to host URL
|
||||
const nodeHostMap = createMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
@@ -407,14 +402,16 @@ const Storage: Component = () => {
|
||||
const rowClass = `${isDisabled ? 'opacity-60' : ''} ${alertBg} hover:shadow-sm transition-all duration-200`;
|
||||
|
||||
// Create row style with inset box-shadow for alert border
|
||||
const rowStyle = createMemo(() => {
|
||||
const styles: Record<string, string> = {};
|
||||
if (alertStyles.hasAlert) {
|
||||
const color = alertStyles.severity === 'critical' ? '#ef4444' : '#eab308';
|
||||
styles['box-shadow'] = `inset 4px 0 0 0 ${color}`;
|
||||
}
|
||||
return styles;
|
||||
});
|
||||
const rowStyle = createMemo(() => {
|
||||
const styles: Record<string, string> = {};
|
||||
if (alertStyles.hasAlert) {
|
||||
const color = alertStyles.severity === 'critical' ? '#ef4444' : '#eab308';
|
||||
styles['box-shadow'] = `inset 4px 0 0 0 ${color}`;
|
||||
}
|
||||
return styles;
|
||||
});
|
||||
|
||||
const zfsPool = storage.zfsPool;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -428,17 +425,17 @@ const Storage: Component = () => {
|
||||
{storage.name}
|
||||
</span>
|
||||
{/* ZFS Health Badge */}
|
||||
<Show when={storage.zfsPool && storage.zfsPool.state !== 'ONLINE'}>
|
||||
<Show when={zfsPool && zfsPool.state !== 'ONLINE'}>
|
||||
<span class={`px-1.5 py-0.5 rounded text-[10px] font-medium ${
|
||||
storage.zfsPool.state === 'DEGRADED'
|
||||
zfsPool?.state === 'DEGRADED'
|
||||
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300'
|
||||
: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300'
|
||||
}`}>
|
||||
{storage.zfsPool.state}
|
||||
{zfsPool?.state}
|
||||
</span>
|
||||
</Show>
|
||||
{/* ZFS Error Badge */}
|
||||
<Show when={storage.zfsPool && (storage.zfsPool.readErrors > 0 || storage.zfsPool.writeErrors > 0 || storage.zfsPool.checksumErrors > 0)}>
|
||||
<Show when={zfsPool && (zfsPool.readErrors > 0 || zfsPool.writeErrors > 0 || zfsPool.checksumErrors > 0)}>
|
||||
<span class="px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300">
|
||||
ERRORS
|
||||
</span>
|
||||
|
||||
@@ -8,7 +8,7 @@ type SectionHeaderProps = {
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
titleClass?: string;
|
||||
descriptionClass?: string;
|
||||
} & JSX.HTMLAttributes<HTMLDivElement>;
|
||||
} & Omit<JSX.HTMLAttributes<HTMLDivElement>, 'title'>;
|
||||
|
||||
export function SectionHeader(props: SectionHeaderProps) {
|
||||
const merged = mergeProps({ align: 'left' as const, size: 'md' as const, titleClass: '', descriptionClass: '' }, props);
|
||||
|
||||
@@ -10,7 +10,7 @@ export function Toggle(props: ToggleProps) {
|
||||
const merged = mergeProps({ containerClass: '' }, props);
|
||||
const [local, rest] = splitProps(merged, ['label', 'description', 'containerClass', 'class', 'disabled']);
|
||||
|
||||
const isDisabled = () => Boolean(local.disabled ?? rest.disabled);
|
||||
const isDisabled = () => Boolean(local.disabled);
|
||||
const isChecked = () => {
|
||||
const value = rest.checked as unknown;
|
||||
if (typeof value === 'function') {
|
||||
@@ -26,7 +26,7 @@ export function Toggle(props: ToggleProps) {
|
||||
return (
|
||||
<label class={`flex items-center gap-3 ${local.containerClass ?? ''} ${local.class ?? ''}`.trim()}>
|
||||
<span class={`relative inline-flex h-6 w-11 flex-shrink-0 items-center ${isDisabled() ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'}`}>
|
||||
<input type="checkbox" class="sr-only" {...rest} />
|
||||
<input type="checkbox" class="sr-only" disabled={local.disabled} {...rest} />
|
||||
<span
|
||||
class={`absolute inset-0 rounded-full transition ${
|
||||
isChecked()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component, createSignal, createEffect, createMemo, onMount, onCleanup } from 'solid-js';
|
||||
import { useWebSocket } from '@/App';
|
||||
import { NodeSummaryTable } from './NodeSummaryTable';
|
||||
import type { Node, VM, Container, Storage, PBSBackup } from '@/types/api';
|
||||
import type { Node, VM, Container, Storage } from '@/types/api';
|
||||
|
||||
interface UnifiedNodeSelectorProps {
|
||||
currentTab: 'dashboard' | 'storage' | 'backups';
|
||||
@@ -11,7 +11,6 @@ interface UnifiedNodeSelectorProps {
|
||||
filteredVms?: VM[];
|
||||
filteredContainers?: Container[];
|
||||
filteredStorage?: Storage[];
|
||||
filteredBackups?: PBSBackup[];
|
||||
searchTerm?: string;
|
||||
}
|
||||
|
||||
@@ -109,4 +108,4 @@ export const UnifiedNodeSelector: Component<UnifiedNodeSelectorProps> = (props)
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { EmailProviderSelect } from '@/components/Alerts/EmailProviderSelect';
|
||||
import { WebhookConfig } from '@/components/Alerts/WebhookConfig';
|
||||
import { CustomRulesTab } from '@/components/Alerts/CustomRulesTab';
|
||||
import { ThresholdsTable } from '@/components/Alerts/ThresholdsTable';
|
||||
import type { RawOverrideConfig } from '@/types/alerts';
|
||||
import { Card } from '@/components/shared/Card';
|
||||
import { SectionHeader } from '@/components/shared/SectionHeader';
|
||||
import { SettingsPanel } from '@/components/shared/SettingsPanel';
|
||||
@@ -13,7 +14,7 @@ import { showSuccess, showError } from '@/utils/toast';
|
||||
import { AlertsAPI } from '@/api/alerts';
|
||||
import { NotificationsAPI, Webhook } from '@/api/notifications';
|
||||
import type { EmailConfig } from '@/api/notifications';
|
||||
import type { HysteresisThreshold, AlertThresholds } from '@/types/alerts';
|
||||
import type { HysteresisThreshold } from '@/types/alerts';
|
||||
import type { Alert, State, VM, Container } from '@/types/api';
|
||||
|
||||
type AlertTab = 'overview' | 'thresholds' | 'destinations' | 'schedule' | 'history' | 'custom-rules';
|
||||
@@ -23,31 +24,6 @@ interface DestinationsRef {
|
||||
emailConfig?: () => EmailConfig;
|
||||
}
|
||||
|
||||
// ScheduleConfig interface - used for loading schedule configuration
|
||||
interface ScheduleConfig {
|
||||
quietHours?: {
|
||||
enabled: boolean;
|
||||
start: string;
|
||||
end: string;
|
||||
days: Record<string, boolean>;
|
||||
timezone?: string;
|
||||
};
|
||||
cooldown?: number;
|
||||
groupingWindow?: number;
|
||||
grouping?: {
|
||||
enabled: boolean;
|
||||
window: number;
|
||||
byNode?: boolean;
|
||||
byGuest?: boolean;
|
||||
};
|
||||
maxAlertsHour?: number;
|
||||
escalation?: {
|
||||
enabled: boolean;
|
||||
levels?: Array<{ after: number; notify: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Override interface for both guests and nodes
|
||||
interface Override {
|
||||
id: string; // Full ID (e.g. "Main-node1-105" for guest, "node-node1" for node, "pbs-name" for PBS)
|
||||
@@ -110,7 +86,7 @@ export function Alerts() {
|
||||
let destinationsRef: DestinationsRef = {};
|
||||
|
||||
const [overrides, setOverrides] = createSignal<Override[]>([]);
|
||||
const [rawOverridesConfig, setRawOverridesConfig] = createSignal<Record<string, unknown>>({}); // Store raw config
|
||||
const [rawOverridesConfig, setRawOverridesConfig] = createSignal<Record<string, RawOverrideConfig>>({}); // Store raw config
|
||||
|
||||
// Email configuration state moved to parent to persist across tab changes
|
||||
const [emailConfig, setEmailConfig] = createSignal<UIEmailConfig>({
|
||||
@@ -476,7 +452,7 @@ export function Alerts() {
|
||||
};
|
||||
|
||||
// Helper to extract trigger values for all thresholds
|
||||
const extractTriggerValues = (thresholds: AlertThresholds): Record<string, number> => {
|
||||
const extractTriggerValues = (thresholds: RawOverrideConfig): Record<string, number> => {
|
||||
const result: Record<string, number> = {};
|
||||
Object.entries(thresholds).forEach(([key, value]) => {
|
||||
// Skip non-threshold fields
|
||||
@@ -1042,14 +1018,14 @@ interface ThresholdsTabProps {
|
||||
timeThreshold: () => number;
|
||||
timeThresholds: () => { guest: number; node: number; storage: number; pbs: number };
|
||||
overrides: () => Override[];
|
||||
rawOverridesConfig: () => Record<string, unknown>;
|
||||
rawOverridesConfig: () => Record<string, RawOverrideConfig>;
|
||||
setGuestDefaults: (value: Record<string, number> | ((prev: Record<string, number>) => Record<string, number>)) => void;
|
||||
setNodeDefaults: (value: Record<string, number> | ((prev: Record<string, number>) => Record<string, number>)) => void;
|
||||
setStorageDefault: (value: number) => void;
|
||||
setTimeThreshold: (value: number) => void;
|
||||
setTimeThresholds: (value: { guest: number; node: number; storage: number; pbs: number }) => void;
|
||||
setOverrides: (value: Override[]) => void;
|
||||
setRawOverridesConfig: (value: Record<string, unknown>) => void;
|
||||
setRawOverridesConfig: (value: Record<string, RawOverrideConfig>) => void;
|
||||
activeAlerts: Record<string, Alert>;
|
||||
setHasUnsavedChanges: (value: boolean) => void;
|
||||
}
|
||||
@@ -1149,7 +1125,7 @@ function DestinationsTab(props: DestinationsTabProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="grid w-full max-w-full gap-6 md:gap-8 lg:grid-cols-[minmax(0,360px)_minmax(0,1fr)]">
|
||||
<div class="flex w-full max-w-full flex-col gap-6 md:gap-8">
|
||||
<SettingsPanel
|
||||
title="Email notifications"
|
||||
description="Configure SMTP delivery for alert emails."
|
||||
@@ -1689,7 +1665,7 @@ function ScheduleTab(props: ScheduleTabProps) {
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const lastLevel = escalation().levels[escalation().levels.length - 1];
|
||||
const newAfter = lastLevel ? lastLevel.after + 30 : 15;
|
||||
const newAfter = typeof lastLevel?.after === 'number' ? lastLevel.after + 30 : 15;
|
||||
setEscalation({
|
||||
...escalation(),
|
||||
levels: [...escalation().levels, { after: newAfter, notify: 'all' }]
|
||||
|
||||
@@ -169,7 +169,7 @@ export function createWebSocketStore(url: string) {
|
||||
// Transform tags from comma-separated strings to arrays
|
||||
const transformedVMs = message.data.vms.map((vm: VM) => {
|
||||
const originalTags = vm.tags;
|
||||
let transformedTags;
|
||||
let transformedTags: string[];
|
||||
|
||||
if (originalTags && typeof originalTags === 'string' && originalTags.trim()) {
|
||||
// String with content - split into array
|
||||
@@ -195,7 +195,7 @@ export function createWebSocketStore(url: string) {
|
||||
// Transform tags from comma-separated strings to arrays
|
||||
const transformedContainers = message.data.containers.map((container: Container) => {
|
||||
const originalTags = container.tags;
|
||||
let transformedTags;
|
||||
let transformedTags: string[];
|
||||
|
||||
if (originalTags && typeof originalTags === 'string' && originalTags.trim()) {
|
||||
// String with content - split into array
|
||||
@@ -454,4 +454,4 @@ export function createWebSocketStore(url: string) {
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,11 @@ export interface AlertThresholds {
|
||||
[key: string]: HysteresisThreshold | number | undefined;
|
||||
}
|
||||
|
||||
export type RawOverrideConfig = AlertThresholds & {
|
||||
disabled?: boolean;
|
||||
disableConnectivity?: boolean;
|
||||
};
|
||||
|
||||
export interface CustomAlertRule {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -53,7 +58,7 @@ export interface AlertConfig {
|
||||
nodeDefaults: AlertThresholds;
|
||||
storageDefault: HysteresisThreshold;
|
||||
customRules?: CustomAlertRule[];
|
||||
overrides: Record<string, AlertThresholds>; // key: resource ID
|
||||
overrides: Record<string, RawOverrideConfig>; // key: resource ID
|
||||
minimumDelta?: number;
|
||||
suppressionWindow?: number;
|
||||
hysteresisMargin?: number;
|
||||
@@ -127,4 +132,4 @@ export interface AlertConfig {
|
||||
// 0: Global defaults
|
||||
// 1-99: Reserved for system rules
|
||||
// 100+: Custom user rules
|
||||
// 1000+: Guest-specific overrides
|
||||
// 1000+: Guest-specific overrides
|
||||
|
||||
@@ -59,7 +59,7 @@ export interface VM {
|
||||
uptime: number;
|
||||
template: boolean;
|
||||
lastBackup: string;
|
||||
tags: string[];
|
||||
tags: string[] | string | null;
|
||||
lock: string;
|
||||
lastSeen: string;
|
||||
}
|
||||
@@ -83,7 +83,7 @@ export interface Container {
|
||||
uptime: number;
|
||||
template: boolean;
|
||||
lastBackup: string;
|
||||
tags: string[];
|
||||
tags: string[] | string | null;
|
||||
lock: string;
|
||||
lastSeen: string;
|
||||
}
|
||||
@@ -254,6 +254,7 @@ export interface PhysicalDisk {
|
||||
node: string;
|
||||
instance: string;
|
||||
devPath: string;
|
||||
device?: string;
|
||||
model: string;
|
||||
serial: string;
|
||||
type: 'nvme' | 'sata' | 'sas' | string;
|
||||
@@ -264,6 +265,7 @@ export interface PhysicalDisk {
|
||||
rpm: number;
|
||||
used: string;
|
||||
lastChecked: string;
|
||||
smart?: unknown;
|
||||
}
|
||||
|
||||
export interface CPUInfo {
|
||||
@@ -410,8 +412,20 @@ export type WSMessage =
|
||||
}>;
|
||||
errors?: string[];
|
||||
timestamp?: number;
|
||||
immediate?: boolean;
|
||||
scanning?: boolean;
|
||||
cached?: boolean;
|
||||
}}
|
||||
| { type: 'discovery_started'; data?: {
|
||||
subnet?: string;
|
||||
timestamp?: number;
|
||||
scanning?: boolean;
|
||||
}}
|
||||
| { type: 'discovery_complete'; data?: {
|
||||
timestamp?: number;
|
||||
scanning?: boolean;
|
||||
}};
|
||||
|
||||
// Utility types
|
||||
export type Status = 'running' | 'stopped' | 'paused' | 'unknown';
|
||||
export type GuestType = 'qemu' | 'lxc';
|
||||
export type GuestType = 'qemu' | 'lxc';
|
||||
|
||||
@@ -1,95 +1,22 @@
|
||||
// Unified backup types for the backup view
|
||||
export type BackupType = 'snapshot' | 'local' | 'remote';
|
||||
export type GuestType = 'VM' | 'LXC' | 'Host' | 'Template' | 'ISO';
|
||||
|
||||
export interface UnifiedBackup {
|
||||
// Common fields
|
||||
backupType: 'backup' | 'snapshot' | 'pbs';
|
||||
backupType: BackupType;
|
||||
vmid: number;
|
||||
name: string;
|
||||
type: 'VM' | 'LXC' | 'CT';
|
||||
type: GuestType;
|
||||
node: string;
|
||||
backupTime: number; // Unix timestamp in seconds
|
||||
backupTime: number;
|
||||
backupName: string;
|
||||
description: string;
|
||||
status: string;
|
||||
size: number | null;
|
||||
storage: string | null;
|
||||
|
||||
// PBS specific
|
||||
datastore: string | null;
|
||||
namespace: string | null;
|
||||
verified: boolean | null;
|
||||
|
||||
// Common flags
|
||||
protected: boolean;
|
||||
encrypted?: boolean;
|
||||
|
||||
// UI specific
|
||||
instance?: string;
|
||||
isPBS?: boolean;
|
||||
}
|
||||
|
||||
// PBS-specific backup file info
|
||||
export interface PBSBackupFile {
|
||||
filename: string;
|
||||
size: number;
|
||||
crypt?: string;
|
||||
}
|
||||
|
||||
// Extended PBS backup with file details
|
||||
export interface PBSBackupWithFiles {
|
||||
id: string;
|
||||
instance: string;
|
||||
datastore: string;
|
||||
namespace?: string;
|
||||
backupType: string;
|
||||
vmid: number;
|
||||
backupTime: string;
|
||||
size: number;
|
||||
protected: boolean;
|
||||
verified: boolean;
|
||||
comment?: string;
|
||||
files: PBSBackupFile[];
|
||||
}
|
||||
|
||||
// PBS datastore with snapshots
|
||||
export interface PBSDatastoreSnapshot {
|
||||
id: string;
|
||||
backupTime: string;
|
||||
size: number;
|
||||
owner?: string;
|
||||
verified?: boolean;
|
||||
protected?: boolean;
|
||||
files?: PBSBackupFile[];
|
||||
}
|
||||
|
||||
export interface PBSDatastore {
|
||||
name: string;
|
||||
total: number;
|
||||
used: number;
|
||||
free: number;
|
||||
snapshots: PBSDatastoreSnapshot[];
|
||||
}
|
||||
|
||||
export interface PBSInstanceData {
|
||||
id: string;
|
||||
name: string;
|
||||
host: string;
|
||||
backups: PBSBackupWithFiles[];
|
||||
datastores: PBSDatastore[];
|
||||
}
|
||||
|
||||
// Filter options for the backup view
|
||||
export interface BackupFilters {
|
||||
instance: string;
|
||||
type: 'all' | 'VM' | 'LXC';
|
||||
node: string;
|
||||
storage: string;
|
||||
protected: 'all' | 'protected' | 'unprotected';
|
||||
verified: 'all' | 'verified' | 'unverified';
|
||||
}
|
||||
|
||||
// Sorting options
|
||||
export interface BackupSort {
|
||||
key: keyof UnifiedBackup;
|
||||
order: 'asc' | 'desc';
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { VM, Container, PBSBackup, StorageBackup, BackupTask } from '@/types/api';
|
||||
import type { UnifiedBackup } from '@/types/backups';
|
||||
|
||||
export type ComparisonOperator = '>' | '<' | '>=' | '<=' | '=' | '==';
|
||||
export type LogicalOperator = 'AND' | 'OR';
|
||||
@@ -186,7 +187,7 @@ export function parseSearchQuery(query: string): ParsedQuery {
|
||||
};
|
||||
}
|
||||
|
||||
type FilterableItem = VM | Container | PBSBackup | StorageBackup | BackupTask;
|
||||
type FilterableItem = VM | Container | PBSBackup | StorageBackup | BackupTask | UnifiedBackup;
|
||||
|
||||
function evaluateMetricCondition(guest: FilterableItem, condition: MetricCondition): boolean {
|
||||
let value: number;
|
||||
@@ -208,9 +209,13 @@ function evaluateMetricCondition(guest: FilterableItem, condition: MetricConditi
|
||||
break;
|
||||
default:
|
||||
// For backup-specific numeric fields like 'size'
|
||||
const fieldValue = (guest as Record<string, unknown>)[condition.field];
|
||||
if (fieldValue !== undefined) {
|
||||
value = Number(fieldValue) || 0;
|
||||
if (typeof guest === 'object' && guest !== null && condition.field in guest) {
|
||||
const fieldValue = (guest as unknown as Record<string, unknown>)[condition.field];
|
||||
if (fieldValue !== undefined) {
|
||||
value = Number(fieldValue) || 0;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
@@ -245,25 +250,30 @@ function evaluateTextCondition(guest: FilterableItem, condition: TextCondition):
|
||||
return 'vmid' in guest && guest.vmid ? guest.vmid.toString().includes(searchValue) : false;
|
||||
case 'tags':
|
||||
// Check if guest has any tags that match the search value
|
||||
if (!('tags' in guest) || !guest.tags || !Array.isArray(guest.tags) || guest.tags.length === 0) return false;
|
||||
if (!('tags' in guest) || !guest.tags) return false;
|
||||
const tagsArray = Array.isArray(guest.tags)
|
||||
? guest.tags.filter((tag): tag is string => typeof tag === 'string')
|
||||
: typeof guest.tags === 'string'
|
||||
? guest.tags.split(',').map(tag => tag.trim()).filter(tag => tag.length > 0)
|
||||
: [];
|
||||
if (tagsArray.length === 0) return false;
|
||||
// Support comma-separated tag searches (OR logic)
|
||||
const searchTags = searchValue.split(',').map(t => t.trim()).filter(t => t.length > 0);
|
||||
return searchTags.some(searchTag =>
|
||||
guest.tags!.some((tag: string) => {
|
||||
if (typeof tag !== 'string') return false;
|
||||
return tag.toLowerCase().includes(searchTag.toLowerCase());
|
||||
})
|
||||
return searchTags.some(searchTag =>
|
||||
tagsArray.some(tag => tag.toLowerCase().includes(searchTag.toLowerCase()))
|
||||
);
|
||||
default:
|
||||
// For backup-specific fields
|
||||
const fieldValue = (guest as Record<string, unknown>)[condition.field];
|
||||
if (fieldValue) {
|
||||
if (typeof fieldValue === 'string') {
|
||||
return fieldValue.toLowerCase().includes(searchValue);
|
||||
} else if (typeof fieldValue === 'number') {
|
||||
return fieldValue.toString().includes(searchValue);
|
||||
} else if (typeof fieldValue === 'boolean') {
|
||||
return fieldValue.toString() === searchValue;
|
||||
if (typeof guest === 'object' && guest !== null && condition.field in guest) {
|
||||
const fieldValue = (guest as unknown as Record<string, unknown>)[condition.field];
|
||||
if (fieldValue) {
|
||||
if (typeof fieldValue === 'string') {
|
||||
return fieldValue.toLowerCase().includes(searchValue);
|
||||
} else if (typeof fieldValue === 'number') {
|
||||
return fieldValue.toString().includes(searchValue);
|
||||
} else if (typeof fieldValue === 'boolean') {
|
||||
return fieldValue.toString() === searchValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
@@ -335,16 +345,17 @@ export function evaluateFilterStack(guest: FilterableItem, stack: FilterStack):
|
||||
} else if (filter.type === 'raw' && filter.rawText) {
|
||||
const term = filter.rawText.toLowerCase();
|
||||
// Check name, vmid, node, status, and tags for raw text matches
|
||||
const basicMatch = ('name' in guest && guest.name && guest.name.toLowerCase().includes(term)) ||
|
||||
('vmid' in guest && guest.vmid && guest.vmid.toString().includes(term)) ||
|
||||
('node' in guest && guest.node && guest.node.toLowerCase().includes(term)) ||
|
||||
('status' in guest && guest.status && guest.status.toLowerCase().includes(term));
|
||||
|
||||
const nameMatch = 'name' in guest && typeof guest.name === 'string' && guest.name.toLowerCase().includes(term);
|
||||
const vmidMatch = 'vmid' in guest && !!guest.vmid && guest.vmid.toString().includes(term);
|
||||
const nodeMatch = 'node' in guest && typeof guest.node === 'string' && guest.node.toLowerCase().includes(term);
|
||||
const statusMatch = 'status' in guest && typeof guest.status === 'string' && guest.status.toLowerCase().includes(term);
|
||||
|
||||
// Also check if any tags contain the search term
|
||||
const tagMatch = 'tags' in guest && guest.tags && Array.isArray(guest.tags) &&
|
||||
guest.tags.some((tag: string) => tag.toLowerCase().includes(term));
|
||||
|
||||
return basicMatch || tagMatch;
|
||||
const tagMatch = 'tags' in guest && Array.isArray(guest.tags)
|
||||
? guest.tags.filter((tag): tag is string => typeof tag === 'string').some(tag => tag.toLowerCase().includes(term))
|
||||
: false;
|
||||
|
||||
return nameMatch || vmidMatch || nodeMatch || statusMatch || tagMatch;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
@@ -368,4 +379,4 @@ export function evaluateFilterStack(guest: FilterableItem, stack: FilterStack):
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user