Redesign Ceph, Replication, and Mail Gateway pages for UI consistency
- Ceph: Complete rework with summary cards (storage, clusters, services, pools), search with type-to-search, proper loading/disconnected/empty states, cluster overview table, pools table with filtering, health badges with tooltips - Replication: Add summary cards (total/healthy/issues/next sync), status filter buttons (All/Healthy/Warning/Error), search with type-to-search, countdown timers for next sync with overdue/imminent highlighting, row highlighting for problem jobs, error tooltips, loading state - Mail Gateway: Add aggregate summary cards across all instances (mail/spam/virus/ quarantine/queue), visual threat rate progress bars, status badges with health indicators, queue depth tooltips with breakdown, streamlined stats grid with color-coded threat categories, improved cluster nodes table All three pages now follow the same design patterns as Dashboard, Hosts, Docker, and Storage using Card, EmptyState, and consistent styling.
This commit is contained in:
parent
c48c5b8861
commit
0ddc30c1e1
11 changed files with 1826 additions and 856 deletions
|
|
@ -210,7 +210,7 @@ export class AIAPI {
|
|||
let lastEventTime = Date.now();
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
for (;;) {
|
||||
if (Date.now() - lastEventTime > STREAM_TIMEOUT_MS) {
|
||||
console.warn('[AI] Alert investigation stream timeout');
|
||||
break;
|
||||
|
|
@ -305,7 +305,7 @@ export class AIAPI {
|
|||
logger.debug('[AI SSE] Starting to read stream');
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
for (;;) {
|
||||
// Check for stream timeout
|
||||
if (Date.now() - lastEventTime > STREAM_TIMEOUT_MS) {
|
||||
logger.warn('[AI SSE] Stream timeout', { seconds: STREAM_TIMEOUT_MS / 1000 });
|
||||
|
|
@ -397,7 +397,7 @@ export class AIAPI {
|
|||
if (data.type === 'complete') receivedComplete = true;
|
||||
if (data.type === 'done') receivedDone = true;
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
logger.warn('[AI SSE] Could not parse remaining buffer', { preview: buffer.substring(0, 100) });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1156,8 +1156,10 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
|||
</Match>
|
||||
<Match when={evt.type === 'content' && evt.content}>
|
||||
{/* Content chunk - rendered as markdown */}
|
||||
{/* DOMPurify sanitizes LLM output in renderMarkdown before HTML rendering. */}
|
||||
<div
|
||||
class="text-sm prose prose-sm dark:prose-invert max-w-none prose-pre:bg-gray-800 prose-pre:text-gray-100 prose-code:text-purple-600 dark:prose-code:text-purple-400 prose-code:before:content-none prose-code:after:content-none"
|
||||
// eslint-disable-next-line solid/no-innerhtml -- HTML is sanitized via DOMPurify in renderMarkdown
|
||||
innerHTML={renderMarkdown(evt.content!)}
|
||||
/>
|
||||
</Match>
|
||||
|
|
@ -1191,6 +1193,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
|||
<Show when={message.content && (!message.streamEvents || message.streamEvents.length === 0)}>
|
||||
<div
|
||||
class="text-sm prose prose-sm dark:prose-invert max-w-none prose-pre:bg-gray-800 prose-pre:text-gray-100 prose-code:text-purple-600 dark:prose-code:text-purple-400 prose-code:before:content-none prose-code:after:content-none"
|
||||
// eslint-disable-next-line solid/no-innerhtml -- HTML is sanitized via DOMPurify in renderMarkdown
|
||||
innerHTML={renderMarkdown(message.content)}
|
||||
/>
|
||||
</Show>
|
||||
|
|
|
|||
|
|
@ -1122,9 +1122,9 @@ export function Dashboard(props: DashboardProps) {
|
|||
onClick={() => isSortable && handleSort(sortKeyForCol!)}
|
||||
title={col.icon ? col.label : undefined}
|
||||
>
|
||||
<div class={`flex items-center gap-0.5 ${isFirst() ? 'justify-start' : 'justify-center'}`} style={{ "min-height": "14px" }}>
|
||||
<div class={`flex items-center gap-0.5 ${isFirst() ? 'justify-start' : 'justify-center'}`} style={{ "min-height": "14px" }}>
|
||||
{col.icon ? (
|
||||
<span class="flex items-center" innerHTML={col.icon} />
|
||||
<span class="flex items-center">{col.icon}</span>
|
||||
) : (
|
||||
col.label
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { createMemo, createSignal, createEffect, Show, For } from 'solid-js';
|
||||
import type { JSX } from 'solid-js';
|
||||
import { Portal } from 'solid-js/web';
|
||||
import type { VM, Container, GuestNetworkInterface } from '@/types/api';
|
||||
import { formatBytes, formatUptime, formatSpeed, getBackupInfo, type BackupStatus, formatPercent } from '@/utils/format';
|
||||
|
|
@ -411,7 +412,7 @@ function BackupStatusCell(props: { lastBackup: string | number | null | undefine
|
|||
export interface GuestColumnDef {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: string; // SVG icon markup for compact column headers
|
||||
icon?: JSX.Element; // SVG icon for compact column headers
|
||||
priority: ColumnPriority;
|
||||
toggleable?: boolean;
|
||||
width?: string; // Fixed width for consistent column sizing
|
||||
|
|
@ -433,20 +434,20 @@ export const GUEST_COLUMNS: GuestColumnDef[] = [
|
|||
{ id: 'disk', label: 'Disk', priority: 'essential', width: '140px', sortKey: 'disk' },
|
||||
|
||||
// Secondary - visible on md+ (768px), user toggleable - use icons
|
||||
{ id: 'ip', label: 'IP', icon: '<svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/></svg>', priority: 'secondary', width: '45px', toggleable: true },
|
||||
{ id: 'uptime', label: 'Uptime', icon: '<svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>', priority: 'secondary', width: '60px', toggleable: true, sortKey: 'uptime' },
|
||||
{ id: 'node', label: 'Node', icon: '<svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"/></svg>', priority: 'secondary', width: '55px', toggleable: true, sortKey: 'node' },
|
||||
{ id: 'ip', label: 'IP', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/></svg>, priority: 'secondary', width: '45px', toggleable: true },
|
||||
{ id: 'uptime', label: 'Uptime', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>, priority: 'secondary', width: '60px', toggleable: true, sortKey: 'uptime' },
|
||||
{ id: 'node', label: 'Node', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"/></svg>, priority: 'secondary', width: '55px', toggleable: true, sortKey: 'node' },
|
||||
|
||||
// Supplementary - visible on lg+ (1024px), user toggleable
|
||||
{ id: 'backup', label: 'Backup', icon: '<svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/></svg>', priority: 'supplementary', width: '50px', toggleable: true },
|
||||
{ id: 'tags', label: 'Tags', icon: '<svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"/></svg>', priority: 'supplementary', width: '60px', toggleable: true },
|
||||
{ id: 'backup', label: 'Backup', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/></svg>, priority: 'supplementary', width: '50px', toggleable: true },
|
||||
{ id: 'tags', label: 'Tags', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"/></svg>, priority: 'supplementary', width: '60px', toggleable: true },
|
||||
|
||||
// Detailed - visible on xl+ (1280px), user toggleable
|
||||
{ id: 'os', label: 'OS', priority: 'detailed', width: '45px', toggleable: true },
|
||||
{ id: 'diskRead', label: 'D Read', icon: '<svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>', priority: 'detailed', width: '55px', toggleable: true, sortKey: 'diskRead' },
|
||||
{ id: 'diskWrite', label: 'D Write', icon: '<svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/></svg>', priority: 'detailed', width: '55px', toggleable: true, sortKey: 'diskWrite' },
|
||||
{ id: 'netIn', label: 'Net In', icon: '<svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16l-4-4m0 0l4-4m-4 4h18"/></svg>', priority: 'detailed', width: '55px', toggleable: true, sortKey: 'networkIn' },
|
||||
{ id: 'netOut', label: 'Net Out', icon: '<svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3"/></svg>', priority: 'detailed', width: '55px', toggleable: true, sortKey: 'networkOut' },
|
||||
{ id: 'diskRead', label: 'D Read', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>, priority: 'detailed', width: '55px', toggleable: true, sortKey: 'diskRead' },
|
||||
{ id: 'diskWrite', label: 'D Write', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/></svg>, priority: 'detailed', width: '55px', toggleable: true, sortKey: 'diskWrite' },
|
||||
{ id: 'netIn', label: 'Net In', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16l-4-4m0 0l4-4m-4 4h18"/></svg>, priority: 'detailed', width: '55px', toggleable: true, sortKey: 'networkIn' },
|
||||
{ id: 'netOut', label: 'Net Out', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3"/></svg>, priority: 'detailed', width: '55px', toggleable: true, sortKey: 'networkOut' },
|
||||
];
|
||||
|
||||
interface GuestRowProps {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { Component } from 'solid-js';
|
||||
import type { Component, JSX } from 'solid-js';
|
||||
import { For, Show, createMemo, createSignal, createEffect, onMount, onCleanup } from 'solid-js';
|
||||
import { Portal } from 'solid-js/web';
|
||||
import { useNavigate } from '@solidjs/router';
|
||||
|
|
@ -26,7 +26,7 @@ import { logger } from '@/utils/logger';
|
|||
export interface HostColumnDef {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
icon?: JSX.Element;
|
||||
priority: ColumnPriority;
|
||||
toggleable?: boolean;
|
||||
width?: string;
|
||||
|
|
@ -43,13 +43,13 @@ export const HOST_COLUMNS: HostColumnDef[] = [
|
|||
{ id: 'disk', label: 'Disk', priority: 'essential', width: '140px', sortKey: 'disk' },
|
||||
|
||||
// Secondary - visible on md+, toggleable
|
||||
{ id: 'temp', label: 'Temp', icon: '<svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>', priority: 'secondary', width: '50px', toggleable: true },
|
||||
{ id: 'uptime', label: 'Uptime', icon: '<svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>', priority: 'secondary', width: '65px', toggleable: true, sortKey: 'uptime' },
|
||||
{ id: 'temp', label: 'Temp', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>, priority: 'secondary', width: '50px', toggleable: true },
|
||||
{ id: 'uptime', label: 'Uptime', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>, priority: 'secondary', width: '65px', toggleable: true, sortKey: 'uptime' },
|
||||
{ id: 'agent', label: 'Agent', priority: 'secondary', width: '60px', toggleable: true },
|
||||
|
||||
// Supplementary - visible on lg+, toggleable
|
||||
// Note: CPU count and load average removed - they're shown in the EnhancedCPUBar tooltip
|
||||
{ id: 'ip', label: 'IP', icon: '<svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/></svg>', priority: 'supplementary', width: '50px', toggleable: true },
|
||||
{ id: 'ip', label: 'IP', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/></svg>, priority: 'supplementary', width: '50px', toggleable: true },
|
||||
|
||||
// Detailed - visible on xl+, toggleable
|
||||
{ id: 'arch', label: 'Arch', priority: 'detailed', width: '55px', toggleable: true },
|
||||
|
|
@ -468,11 +468,7 @@ function HostRAIDStatusCell(props: HostRAIDStatusCellProps) {
|
|||
}
|
||||
|
||||
type SortKey = 'name' | 'platform' | 'cpu' | 'memory' | 'disk' | 'uptime';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
interface HostsOverviewProps { }
|
||||
|
||||
export const HostsOverview: Component<HostsOverviewProps> = () => {
|
||||
export const HostsOverview: Component = () => {
|
||||
const navigate = useNavigate();
|
||||
const wsContext = useWebSocket();
|
||||
const [search, setSearch] = createSignal('');
|
||||
|
|
@ -821,13 +817,13 @@ export const HostsOverview: Component<HostsOverviewProps> = () => {
|
|||
{/* Secondary columns */}
|
||||
<Show when={isColVisible('temp')}>
|
||||
<th class={thClass} title="Temperature">
|
||||
<span class="inline-flex items-center justify-center" innerHTML={HOST_COLUMNS.find(c => c.id === 'temp')?.icon} />
|
||||
<span class="inline-flex items-center justify-center">{HOST_COLUMNS.find(c => c.id === 'temp')?.icon}</span>
|
||||
</th>
|
||||
</Show>
|
||||
<Show when={isColVisible('uptime')}>
|
||||
<th class={thClass} onClick={() => handleSort('uptime')} title="Uptime">
|
||||
<span class="inline-flex items-center justify-center gap-1">
|
||||
<span innerHTML={HOST_COLUMNS.find(c => c.id === 'uptime')?.icon} />
|
||||
<span>{HOST_COLUMNS.find(c => c.id === 'uptime')?.icon}</span>
|
||||
{renderSortIndicator('uptime')}
|
||||
</span>
|
||||
</th>
|
||||
|
|
@ -839,7 +835,7 @@ export const HostsOverview: Component<HostsOverviewProps> = () => {
|
|||
{/* Supplementary columns */}
|
||||
<Show when={isColVisible('ip')}>
|
||||
<th class={thClass} title="IP Address">
|
||||
<span class="inline-flex items-center justify-center" innerHTML={HOST_COLUMNS.find(c => c.id === 'ip')?.icon} />
|
||||
<span class="inline-flex items-center justify-center">{HOST_COLUMNS.find(c => c.id === 'ip')?.icon}</span>
|
||||
</th>
|
||||
</Show>
|
||||
|
||||
|
|
|
|||
|
|
@ -441,14 +441,6 @@ export const KubernetesClusters: Component<KubernetesClustersProps> = (props) =>
|
|||
|
||||
return (
|
||||
<div class="space-y-4">
|
||||
{/* Header */}
|
||||
<div class="flex flex-col gap-1">
|
||||
<h1 class="text-xl font-semibold text-gray-900 dark:text-gray-100">Kubernetes</h1>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Cluster health from the unified agent Kubernetes module.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filter Bar */}
|
||||
<Card padding="sm">
|
||||
<div class="flex flex-col gap-3">
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,6 @@
|
|||
import type { Component } from 'solid-js';
|
||||
import { Show, For, createMemo } from 'solid-js';
|
||||
import { Show, For, createMemo, createSignal, createEffect, onCleanup } from 'solid-js';
|
||||
import { Portal } from 'solid-js/web';
|
||||
import { ProxmoxSectionNav } from '@/components/Proxmox/ProxmoxSectionNav';
|
||||
import { useWebSocket } from '@/App';
|
||||
import { Card } from '@/components/shared/Card';
|
||||
|
|
@ -7,33 +8,139 @@ import { EmptyState } from '@/components/shared/EmptyState';
|
|||
import { formatAbsoluteTime, formatRelativeTime } from '@/utils/format';
|
||||
import { StatusDot } from '@/components/shared/StatusDot';
|
||||
import { getReplicationJobStatusIndicator } from '@/utils/status';
|
||||
import type { ReplicationJob } from '@/types/api';
|
||||
|
||||
type StatusFilter = 'all' | 'healthy' | 'warning' | 'error';
|
||||
|
||||
function formatDuration(durationSeconds?: number, durationHuman?: string): string {
|
||||
if (durationHuman && durationHuman.trim()) return durationHuman;
|
||||
if (!durationSeconds || durationSeconds <= 0) return '';
|
||||
|
||||
const hours = Math.floor(durationSeconds / 3600)
|
||||
.toString()
|
||||
.padStart(2, '0');
|
||||
const minutes = Math.floor((durationSeconds % 3600) / 60)
|
||||
.toString()
|
||||
.padStart(2, '0');
|
||||
const seconds = Math.floor(durationSeconds % 60)
|
||||
.toString()
|
||||
.padStart(2, '0');
|
||||
const hours = Math.floor(durationSeconds / 3600).toString().padStart(2, '0');
|
||||
const minutes = Math.floor((durationSeconds % 3600) / 60).toString().padStart(2, '0');
|
||||
const seconds = Math.floor(durationSeconds % 60).toString().padStart(2, '0');
|
||||
|
||||
return `${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
|
||||
|
||||
function formatRate(limit?: number): string {
|
||||
if (!limit || limit <= 0) return '—';
|
||||
return `${limit.toFixed(0)} MB/s`;
|
||||
function coerceTimestamp(value: unknown): number | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeTimestampMs(value: number): number {
|
||||
return value > 1e12 ? value : value * 1000;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Countdown timer for next sync
|
||||
function getTimeUntil(timestamp?: number): { text: string; isOverdue: boolean; isImminent: boolean } {
|
||||
if (!timestamp) return { text: '—', isOverdue: false, isImminent: false };
|
||||
|
||||
const now = Date.now();
|
||||
// Handle both Unix timestamp (seconds) and JS timestamp (milliseconds)
|
||||
const target = timestamp > 1e12 ? timestamp : timestamp * 1000;
|
||||
const diff = target - now;
|
||||
|
||||
if (diff < 0) {
|
||||
// Overdue
|
||||
const overdueMinutes = Math.abs(Math.floor(diff / 60000));
|
||||
if (overdueMinutes < 60) {
|
||||
return { text: `${overdueMinutes}m overdue`, isOverdue: true, isImminent: false };
|
||||
}
|
||||
const overdueHours = Math.floor(overdueMinutes / 60);
|
||||
return { text: `${overdueHours}h overdue`, isOverdue: true, isImminent: false };
|
||||
}
|
||||
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 60) {
|
||||
return { text: `in ${minutes}m`, isOverdue: false, isImminent: minutes < 5 };
|
||||
}
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
return { text: `in ${hours}h ${remainingMinutes}m`, isOverdue: false, isImminent: false };
|
||||
}
|
||||
|
||||
// Error tooltip component
|
||||
const ErrorTooltip: Component<{ error: string }> = (props) => {
|
||||
const [showTooltip, setShowTooltip] = createSignal(false);
|
||||
const [tooltipPos, setTooltipPos] = createSignal({ x: 0, y: 0 });
|
||||
|
||||
const handleMouseEnter = (e: MouseEvent) => {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
setTooltipPos({ x: rect.left + rect.width / 2, y: rect.top });
|
||||
setShowTooltip(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
class="mt-1 text-xs text-red-500 dark:text-red-400 line-clamp-1 cursor-help"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={() => setShowTooltip(false)}
|
||||
>
|
||||
{props.error}
|
||||
</div>
|
||||
|
||||
<Show when={showTooltip()}>
|
||||
<Portal mount={document.body}>
|
||||
<div
|
||||
class="fixed z-[9999] pointer-events-none"
|
||||
style={{
|
||||
left: `${tooltipPos().x}px`,
|
||||
top: `${tooltipPos().y - 8}px`,
|
||||
transform: 'translate(-50%, -100%)',
|
||||
}}
|
||||
>
|
||||
<div class="bg-gray-900 dark:bg-gray-800 text-white text-xs rounded-md shadow-lg px-3 py-2 max-w-[400px] border border-gray-700">
|
||||
<div class="font-medium text-red-400 mb-1">Error Details</div>
|
||||
<div class="text-gray-300 whitespace-pre-wrap">{props.error}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Replication: Component = () => {
|
||||
const { state, connected, reconnecting, reconnect } = useWebSocket();
|
||||
const { state, connected, reconnecting, reconnect, initialDataReceived } = useWebSocket();
|
||||
|
||||
const [searchTerm, setSearchTerm] = createSignal('');
|
||||
const [statusFilter, setStatusFilter] = createSignal<StatusFilter>('all');
|
||||
let searchInputRef: HTMLInputElement | undefined;
|
||||
|
||||
// Keyboard handler for type-to-search
|
||||
createEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const isInputField =
|
||||
target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.tagName === 'SELECT' ||
|
||||
target.contentEditable === 'true';
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
if (searchTerm().trim() || statusFilter() !== 'all') {
|
||||
setSearchTerm('');
|
||||
setStatusFilter('all');
|
||||
searchInputRef?.blur();
|
||||
}
|
||||
} else if (!isInputField && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
if (searchInputRef) {
|
||||
searchInputRef.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
onCleanup(() => document.removeEventListener('keydown', handleKeyDown));
|
||||
});
|
||||
|
||||
const replicationJobs = createMemo(() => {
|
||||
const jobs = state.replicationJobs ?? [];
|
||||
|
|
@ -46,11 +153,93 @@ const Replication: Component = () => {
|
|||
});
|
||||
});
|
||||
|
||||
// Get job status category for filtering
|
||||
const getJobStatusCategory = (job: ReplicationJob): StatusFilter => {
|
||||
const indicator = getReplicationJobStatusIndicator(job);
|
||||
if (indicator.variant === 'success') return 'healthy';
|
||||
if (indicator.variant === 'warning') return 'warning';
|
||||
if (indicator.variant === 'danger') return 'error';
|
||||
return 'healthy';
|
||||
};
|
||||
|
||||
// Filtered jobs based on search and status
|
||||
const filteredJobs = createMemo(() => {
|
||||
let jobs = replicationJobs();
|
||||
|
||||
// Apply status filter
|
||||
if (statusFilter() !== 'all') {
|
||||
jobs = jobs.filter(job => getJobStatusCategory(job) === statusFilter());
|
||||
}
|
||||
|
||||
// Apply search
|
||||
const term = searchTerm().toLowerCase().trim();
|
||||
if (term) {
|
||||
jobs = jobs.filter(job =>
|
||||
(job.guestName || '').toLowerCase().includes(term) ||
|
||||
(job.jobId || '').toLowerCase().includes(term) ||
|
||||
(job.sourceNode || '').toLowerCase().includes(term) ||
|
||||
(job.targetNode || '').toLowerCase().includes(term) ||
|
||||
(job.instance || '').toLowerCase().includes(term) ||
|
||||
String(job.guestId || '').includes(term)
|
||||
);
|
||||
}
|
||||
|
||||
return jobs;
|
||||
});
|
||||
|
||||
// Summary stats
|
||||
const stats = createMemo(() => {
|
||||
const jobs = replicationJobs();
|
||||
let healthy = 0;
|
||||
let warning = 0;
|
||||
let error = 0;
|
||||
let nextSync: { job: ReplicationJob | null; time: number | undefined } = { job: null, time: undefined };
|
||||
|
||||
for (const job of jobs) {
|
||||
const category = getJobStatusCategory(job);
|
||||
if (category === 'healthy') healthy++;
|
||||
else if (category === 'warning') warning++;
|
||||
else error++;
|
||||
|
||||
// Track the soonest next sync
|
||||
const nextSyncRaw = coerceTimestamp(job.nextSyncTime ?? job.nextSyncUnix);
|
||||
if (typeof nextSyncRaw === 'number') {
|
||||
const jobSyncMs = normalizeTimestampMs(nextSyncRaw);
|
||||
const currentSyncMs =
|
||||
typeof nextSync.time === 'number' ? normalizeTimestampMs(nextSync.time) : Infinity;
|
||||
if (jobSyncMs < currentSyncMs) nextSync = { job, time: nextSyncRaw };
|
||||
}
|
||||
}
|
||||
|
||||
return { total: jobs.length, healthy, warning, error, nextSync };
|
||||
});
|
||||
|
||||
const isLoading = createMemo(() => connected() && !initialDataReceived());
|
||||
|
||||
const thClass = "px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400";
|
||||
|
||||
return (
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-4">
|
||||
<ProxmoxSectionNav current="replication" />
|
||||
|
||||
<Show when={connected()} fallback={
|
||||
{/* Loading State */}
|
||||
<Show when={isLoading()}>
|
||||
<Card padding="lg">
|
||||
<EmptyState
|
||||
icon={
|
||||
<svg class="h-12 w-12 animate-spin text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
}
|
||||
title="Loading replication data..."
|
||||
description="Connecting to the monitoring service."
|
||||
/>
|
||||
</Card>
|
||||
</Show>
|
||||
|
||||
{/* Disconnected State */}
|
||||
<Show when={!connected() && !isLoading()}>
|
||||
<Card padding="lg" tone="danger">
|
||||
<EmptyState
|
||||
icon={
|
||||
|
|
@ -59,11 +248,7 @@ const Replication: Component = () => {
|
|||
</svg>
|
||||
}
|
||||
title="Connection lost"
|
||||
description={
|
||||
reconnecting()
|
||||
? 'Attempting to reconnect…'
|
||||
: 'Unable to connect to the backend server'
|
||||
}
|
||||
description={reconnecting() ? 'Attempting to reconnect…' : 'Unable to connect to the backend server'}
|
||||
tone="danger"
|
||||
actions={
|
||||
!reconnecting() ? (
|
||||
|
|
@ -77,126 +262,343 @@ const Replication: Component = () => {
|
|||
}
|
||||
/>
|
||||
</Card>
|
||||
}>
|
||||
<Show
|
||||
when={replicationJobs().length > 0}
|
||||
fallback={
|
||||
<Card padding="lg">
|
||||
<EmptyState
|
||||
icon={
|
||||
<svg class="h-12 w-12 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 4h16M4 10h16M4 16h16" />
|
||||
</Show>
|
||||
|
||||
<Show when={connected() && initialDataReceived()}>
|
||||
{/* No Jobs Empty State */}
|
||||
<Show when={replicationJobs().length === 0}>
|
||||
<Card padding="lg">
|
||||
<EmptyState
|
||||
icon={
|
||||
<svg class="h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
|
||||
</svg>
|
||||
}
|
||||
title="No replication jobs detected"
|
||||
description="Replication jobs will appear here once configured in Proxmox. Replication keeps your VMs synchronized across nodes for high availability."
|
||||
/>
|
||||
</Card>
|
||||
</Show>
|
||||
|
||||
{/* Has Jobs - Show Content */}
|
||||
<Show when={replicationJobs().length > 0}>
|
||||
{/* Summary Cards */}
|
||||
<div class="grid gap-3 grid-cols-2 lg:grid-cols-4">
|
||||
{/* Total Jobs */}
|
||||
<Card padding="sm" tone="glass">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Total Jobs</div>
|
||||
<div class="text-2xl font-bold text-gray-900 dark:text-gray-100 mt-1">
|
||||
{stats().total}
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
|
||||
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
|
||||
</svg>
|
||||
}
|
||||
title="No replication jobs detected"
|
||||
description="Replication jobs will appear here once configured in Proxmox."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
}
|
||||
>
|
||||
<Card padding="none" tone="glass">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-[1000px] w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-900/40">
|
||||
<tr class="text-left text-xs font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-300">
|
||||
<th class="px-4 py-3">Guest</th>
|
||||
<th class="px-4 py-3">Job</th>
|
||||
<th class="px-4 py-3">Source → Target</th>
|
||||
<th class="px-4 py-3">Last Sync</th>
|
||||
<th class="px-4 py-3">Next Sync</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3">Failures</th>
|
||||
<th class="px-4 py-3">Rate</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-800 text-sm text-gray-700 dark:text-gray-200">
|
||||
<For each={replicationJobs()}>
|
||||
{(job) => {
|
||||
const indicator = getReplicationJobStatusIndicator(job);
|
||||
|
||||
{/* Healthy */}
|
||||
<Card padding="sm" tone="glass">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Healthy</div>
|
||||
<div class="text-2xl font-bold text-green-600 dark:text-green-400 mt-1">
|
||||
{stats().healthy}
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-2 bg-green-100 dark:bg-green-900/30 rounded-lg">
|
||||
<svg class="w-5 h-5 text-green-600 dark:text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Warning/Error Combined */}
|
||||
<Card padding="sm" tone={stats().error > 0 ? 'danger' : stats().warning > 0 ? 'warning' : 'glass'}>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Issues</div>
|
||||
<div class={`text-2xl font-bold mt-1 ${stats().error > 0
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: stats().warning > 0
|
||||
? 'text-yellow-600 dark:text-yellow-400'
|
||||
: 'text-gray-400'
|
||||
}`}>
|
||||
{stats().error + stats().warning}
|
||||
</div>
|
||||
</div>
|
||||
<div class={`p-2 rounded-lg ${stats().error > 0
|
||||
? 'bg-red-100 dark:bg-red-900/30'
|
||||
: stats().warning > 0
|
||||
? 'bg-yellow-100 dark:bg-yellow-900/30'
|
||||
: 'bg-gray-100 dark:bg-gray-800'
|
||||
}`}>
|
||||
<Show when={stats().error > 0 || stats().warning > 0} fallback={
|
||||
<svg class="w-5 h-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
}>
|
||||
<svg class={`w-5 h-5 ${stats().error > 0 ? 'text-red-600 dark:text-red-400' : 'text-yellow-600 dark:text-yellow-400'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||
</svg>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Next Sync */}
|
||||
<Card padding="sm" tone="glass">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Next Sync</div>
|
||||
<Show when={stats().nextSync.job} fallback={
|
||||
<div class="text-sm text-gray-400 mt-1">—</div>
|
||||
}>
|
||||
{(() => {
|
||||
const timeInfo = getTimeUntil(stats().nextSync.time);
|
||||
return (
|
||||
<tr class="hover:bg-gray-50/80 dark:hover:bg-gray-900/40 transition-colors">
|
||||
<td class="px-4 py-3">
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100 truncate max-w-[200px]">
|
||||
{job.guestName || `VM ${job.guestId ?? ''}`}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{job.instance} · ID {job.guestId ?? '—'} · {job.guestNode || job.sourceNode || 'Unknown node'}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="font-medium">{job.jobId || '—'}</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{job.type ? `${job.type.toUpperCase()} · ` : ''}{job.schedule || '*/15'}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="text-sm">
|
||||
{job.sourceNode || '—'}
|
||||
<span class="mx-1 text-gray-400">→</span>
|
||||
{job.targetNode || '—'}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{job.sourceStorage || 'local'} → {job.targetStorage || 'remote'}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<Show when={job.lastSyncTime}>
|
||||
<div class="font-medium">{formatAbsoluteTime(job.lastSyncTime!)}</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatRelativeTime(job.lastSyncTime!)}
|
||||
<Show when={job.lastSyncDurationSeconds || job.lastSyncDurationHuman}>
|
||||
<span class="mx-1">·</span>
|
||||
{formatDuration(job.lastSyncDurationSeconds, job.lastSyncDurationHuman)}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!job.lastSyncTime}>
|
||||
<span class="text-gray-400">Never</span>
|
||||
</Show>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<Show when={job.nextSyncTime}>
|
||||
<div class="font-medium">{formatAbsoluteTime(job.nextSyncTime!)}</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatRelativeTime(job.nextSyncTime!)}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!job.nextSyncTime}>
|
||||
<span class="text-gray-400">—</span>
|
||||
</Show>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<StatusDot
|
||||
variant={indicator.variant}
|
||||
title={indicator.label}
|
||||
ariaLabel={indicator.label}
|
||||
size="sm"
|
||||
/>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{indicator.label}
|
||||
</span>
|
||||
</div>
|
||||
<Show when={job.error}>
|
||||
<div class="mt-1 text-xs text-red-500 dark:text-red-400 line-clamp-2">
|
||||
{job.error}
|
||||
</div>
|
||||
</Show>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="text-sm font-medium">{job.failCount ?? 0}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="text-sm">{formatRate(job.rateLimitMbps)}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<>
|
||||
<div class={`text-lg font-bold mt-1 ${timeInfo.isOverdue
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: timeInfo.isImminent
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-900 dark:text-gray-100'
|
||||
}`}>
|
||||
{timeInfo.text}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
{stats().nextSync.job?.guestName || `VM ${stats().nextSync.job?.guestId}`}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
})()}
|
||||
</Show>
|
||||
</div>
|
||||
<div class="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
|
||||
<svg class="w-5 h-5 text-purple-600 dark:text-purple-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filter Bar */}
|
||||
<Card padding="sm" tone="glass">
|
||||
<div class="flex flex-col sm:flex-row gap-3 sm:items-center sm:justify-between">
|
||||
{/* Search */}
|
||||
<div class="relative flex-1 max-w-md">
|
||||
<input
|
||||
ref={(el) => (searchInputRef = el)}
|
||||
type="text"
|
||||
placeholder="Search by guest, job, or node..."
|
||||
value={searchTerm()}
|
||||
onInput={(e) => setSearchTerm(e.currentTarget.value)}
|
||||
class="w-full pl-9 pr-8 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg
|
||||
bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500
|
||||
focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 outline-none transition-all"
|
||||
/>
|
||||
<svg class="absolute left-3 top-2 h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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>
|
||||
<Show when={searchTerm()}>
|
||||
<button
|
||||
onClick={() => setSearchTerm('')}
|
||||
class="absolute right-2.5 top-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Status Filter Buttons */}
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 mr-1">Status:</span>
|
||||
<button
|
||||
onClick={() => setStatusFilter('all')}
|
||||
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${statusFilter() === 'all'
|
||||
? 'bg-gray-800 dark:bg-gray-200 text-white dark:text-gray-900'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('healthy')}
|
||||
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${statusFilter() === 'healthy'
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
Healthy
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('warning')}
|
||||
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${statusFilter() === 'warning'
|
||||
? 'bg-yellow-500 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
Warning
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('error')}
|
||||
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${statusFilter() === 'error'
|
||||
? 'bg-red-600 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
Error
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Jobs Table */}
|
||||
<Card padding="none" tone="glass" class="overflow-hidden">
|
||||
<Show
|
||||
when={filteredJobs().length > 0}
|
||||
fallback={
|
||||
<div class="p-8 text-center">
|
||||
<svg class="w-10 h-10 text-gray-300 dark:text-gray-600 mx-auto mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||
</svg>
|
||||
<div class="text-gray-500 dark:text-gray-400 text-sm">
|
||||
No jobs match your search
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setSearchTerm(''); setStatusFilter('all'); }}
|
||||
class="mt-2 text-xs text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full min-w-[900px]">
|
||||
<thead class="bg-gray-50 dark:bg-gray-800/60 border-b border-gray-200 dark:border-gray-700">
|
||||
<tr>
|
||||
<th class={`${thClass} pl-4`}>Guest</th>
|
||||
<th class={thClass}>Job</th>
|
||||
<th class={thClass}>Source → Target</th>
|
||||
<th class={thClass}>Last Sync</th>
|
||||
<th class={thClass}>Next Sync</th>
|
||||
<th class={thClass}>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-gray-700/50">
|
||||
<For each={filteredJobs()}>
|
||||
{(job) => {
|
||||
const indicator = getReplicationJobStatusIndicator(job);
|
||||
const nextSyncInfo = getTimeUntil(job.nextSyncTime);
|
||||
const statusCategory = getJobStatusCategory(job);
|
||||
|
||||
return (
|
||||
<tr class={`transition-colors ${statusCategory === 'error'
|
||||
? 'bg-red-50/50 dark:bg-red-900/10 hover:bg-red-50 dark:hover:bg-red-900/20'
|
||||
: statusCategory === 'warning'
|
||||
? 'bg-yellow-50/30 dark:bg-yellow-900/10 hover:bg-yellow-50/50 dark:hover:bg-yellow-900/20'
|
||||
: 'hover:bg-gray-50/80 dark:hover:bg-gray-800/50'
|
||||
}`}>
|
||||
<td class="px-3 py-3 pl-4">
|
||||
<div class="font-medium text-sm text-gray-900 dark:text-gray-100">
|
||||
{job.guestName || `VM ${job.guestId ?? ''}`}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 font-mono">
|
||||
{job.instance}
|
||||
</span>
|
||||
<span>· ID {job.guestId ?? '—'}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-3">
|
||||
<div class="font-mono text-sm text-gray-700 dark:text-gray-300">{job.jobId || '—'}</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{job.schedule || '*/15'}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-3">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-300">{job.sourceNode || '—'}</span>
|
||||
<svg class="w-4 h-4 text-gray-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
||||
</svg>
|
||||
<span class="text-gray-700 dark:text-gray-300">{job.targetNode || '—'}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-3">
|
||||
<Show when={job.lastSyncTime} fallback={
|
||||
<span class="text-gray-400 text-sm">Never</span>
|
||||
}>
|
||||
<div class="text-sm text-gray-900 dark:text-gray-100">
|
||||
{formatRelativeTime(job.lastSyncTime!)}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatAbsoluteTime(job.lastSyncTime!)}
|
||||
<Show when={job.lastSyncDurationSeconds || job.lastSyncDurationHuman}>
|
||||
<span class="ml-1">({formatDuration(job.lastSyncDurationSeconds, job.lastSyncDurationHuman)})</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</td>
|
||||
<td class="px-3 py-3">
|
||||
<div class={`text-sm font-medium ${nextSyncInfo.isOverdue
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: nextSyncInfo.isImminent
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-700 dark:text-gray-300'
|
||||
}`}>
|
||||
{nextSyncInfo.text}
|
||||
</div>
|
||||
<Show when={job.nextSyncTime}>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatAbsoluteTime(job.nextSyncTime!)}
|
||||
</div>
|
||||
</Show>
|
||||
</td>
|
||||
<td class="px-3 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<StatusDot
|
||||
variant={indicator.variant}
|
||||
title={indicator.label}
|
||||
ariaLabel={indicator.label}
|
||||
size="sm"
|
||||
/>
|
||||
<span class={`text-sm font-medium ${statusCategory === 'error'
|
||||
? 'text-red-700 dark:text-red-400'
|
||||
: statusCategory === 'warning'
|
||||
? 'text-yellow-700 dark:text-yellow-400'
|
||||
: 'text-gray-700 dark:text-gray-300'
|
||||
}`}>
|
||||
{indicator.label}
|
||||
</span>
|
||||
</div>
|
||||
<Show when={job.error}>
|
||||
<ErrorTooltip error={job.error!} />
|
||||
</Show>
|
||||
<Show when={(job.failCount ?? 0) > 0}>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{job.failCount} failure{(job.failCount ?? 0) > 1 ? 's' : ''}
|
||||
</div>
|
||||
</Show>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
</Card>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { createMemo, Accessor } from 'solid-js';
|
||||
import type { JSX } from 'solid-js';
|
||||
import { usePersistentSignal } from './usePersistentSignal';
|
||||
import { useBreakpoint, type ColumnPriority, PRIORITY_BREAKPOINTS, type Breakpoint } from './useBreakpoint';
|
||||
|
||||
export interface ColumnDef {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: string; // SVG icon markup for compact column headers
|
||||
icon?: JSX.Element; // Optional icon for compact column headers
|
||||
priority: ColumnPriority;
|
||||
toggleable?: boolean;
|
||||
width?: string; // Fixed width for consistent column sizing
|
||||
|
|
|
|||
|
|
@ -1,287 +1,289 @@
|
|||
import { Component, For, Show, createMemo } from 'solid-js';
|
||||
import { Component, For, Show, createMemo, createSignal, createEffect, onCleanup } from 'solid-js';
|
||||
import { Portal } from 'solid-js/web';
|
||||
import { useWebSocket } from '@/App';
|
||||
import { ProxmoxSectionNav } from '@/components/Proxmox/ProxmoxSectionNav';
|
||||
import type { CephCluster, CephPool, CephServiceStatus } from '@/types/api';
|
||||
import { Card } from '@/components/shared/Card';
|
||||
import { EmptyState } from '@/components/shared/EmptyState';
|
||||
import type { CephPool, CephServiceStatus } from '@/types/api';
|
||||
import { formatBytes } from '@/utils/format';
|
||||
|
||||
// Format bytes to human readable
|
||||
const formatBytes = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||
// Service type icon component with proper styling
|
||||
const ServiceIcon: Component<{ type: string; class?: string }> = (props) => {
|
||||
const iconClass = () => `${props.class || 'w-4 h-4'}`;
|
||||
|
||||
switch (props.type.toLowerCase()) {
|
||||
case 'mon':
|
||||
return (
|
||||
<svg class={iconClass()} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
);
|
||||
case 'mgr':
|
||||
return (
|
||||
<svg class={iconClass()} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
);
|
||||
case 'osd':
|
||||
return (
|
||||
<svg class={iconClass()} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" />
|
||||
</svg>
|
||||
);
|
||||
case 'mds':
|
||||
return (
|
||||
<svg class={iconClass()} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
|
||||
</svg>
|
||||
);
|
||||
case 'rgw':
|
||||
return (
|
||||
<svg class={iconClass()} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418" />
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<svg class={iconClass()} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Get health color classes
|
||||
const getHealthClasses = (health: string) => {
|
||||
const getHealthInfo = (health: string) => {
|
||||
const h = health?.toUpperCase() || '';
|
||||
if (h === 'OK' || h === 'HEALTH_OK') {
|
||||
return {
|
||||
bg: 'bg-green-100 dark:bg-green-900/30',
|
||||
text: 'text-green-700 dark:text-green-400',
|
||||
border: 'border-green-200 dark:border-green-800',
|
||||
dot: 'bg-green-500',
|
||||
bgClass: 'bg-green-100 dark:bg-green-900/30',
|
||||
textClass: 'text-green-700 dark:text-green-400',
|
||||
borderClass: 'border-green-200 dark:border-green-700',
|
||||
dotClass: 'bg-green-500',
|
||||
label: 'OK',
|
||||
};
|
||||
}
|
||||
if (h === 'WARN' || h === 'HEALTH_WARN' || h === 'WARNING') {
|
||||
return {
|
||||
bg: 'bg-yellow-100 dark:bg-yellow-900/30',
|
||||
text: 'text-yellow-700 dark:text-yellow-400',
|
||||
border: 'border-yellow-200 dark:border-yellow-800',
|
||||
dot: 'bg-yellow-500',
|
||||
bgClass: 'bg-yellow-100 dark:bg-yellow-900/30',
|
||||
textClass: 'text-yellow-700 dark:text-yellow-400',
|
||||
borderClass: 'border-yellow-200 dark:border-yellow-700',
|
||||
dotClass: 'bg-yellow-500',
|
||||
label: 'WARN',
|
||||
};
|
||||
}
|
||||
if (h === 'ERR' || h === 'HEALTH_ERR' || h === 'ERROR' || h === 'CRITICAL') {
|
||||
return {
|
||||
bg: 'bg-red-100 dark:bg-red-900/30',
|
||||
text: 'text-red-700 dark:text-red-400',
|
||||
border: 'border-red-200 dark:border-red-800',
|
||||
dot: 'bg-red-500',
|
||||
bgClass: 'bg-red-100 dark:bg-red-900/30',
|
||||
textClass: 'text-red-700 dark:text-red-400',
|
||||
borderClass: 'border-red-200 dark:border-red-700',
|
||||
dotClass: 'bg-red-500',
|
||||
label: 'ERROR',
|
||||
};
|
||||
}
|
||||
return {
|
||||
bg: 'bg-gray-100 dark:bg-gray-800',
|
||||
text: 'text-gray-700 dark:text-gray-400',
|
||||
border: 'border-gray-200 dark:border-gray-700',
|
||||
dot: 'bg-gray-500',
|
||||
bgClass: 'bg-gray-100 dark:bg-gray-800',
|
||||
textClass: 'text-gray-600 dark:text-gray-400',
|
||||
borderClass: 'border-gray-200 dark:border-gray-700',
|
||||
dotClass: 'bg-gray-400',
|
||||
label: 'UNKNOWN',
|
||||
};
|
||||
};
|
||||
|
||||
// Cluster Overview Card
|
||||
const ClusterCard: Component<{ cluster: CephCluster }> = (props) => {
|
||||
const healthClasses = createMemo(() => getHealthClasses(props.cluster.health));
|
||||
// Cluster health status badge with tooltip
|
||||
const HealthBadge: Component<{ health: string; message?: string }> = (props) => {
|
||||
const [showTooltip, setShowTooltip] = createSignal(false);
|
||||
const [tooltipPos, setTooltipPos] = createSignal({ x: 0, y: 0 });
|
||||
|
||||
const displayHealth = createMemo(() => {
|
||||
const h = props.cluster.health?.toUpperCase() || 'UNKNOWN';
|
||||
return h.replace('HEALTH_', '');
|
||||
});
|
||||
const healthInfo = createMemo(() => getHealthInfo(props.health));
|
||||
|
||||
const handleMouseEnter = (e: MouseEvent) => {
|
||||
if (!props.message) return;
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
setTooltipPos({ x: rect.left + rect.width / 2, y: rect.top });
|
||||
setShowTooltip(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div class={`rounded-xl border ${healthClasses().border} ${healthClasses().bg} p-5 shadow-sm`}>
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{props.cluster.name || 'Ceph Cluster'}
|
||||
</h3>
|
||||
<Show when={props.cluster.fsid}>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 font-mono mt-0.5">
|
||||
{props.cluster.fsid}
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
<div class={`flex items-center gap-2 px-3 py-1.5 rounded-full ${healthClasses().bg} ${healthClasses().text} font-semibold text-sm`}>
|
||||
<span class={`w-2 h-2 rounded-full ${healthClasses().dot} animate-pulse`} />
|
||||
{displayHealth()}
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
<span
|
||||
class={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-semibold uppercase tracking-wide ${healthInfo().bgClass} ${healthInfo().textClass} border ${healthInfo().borderClass}`}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={() => setShowTooltip(false)}
|
||||
>
|
||||
<span class={`w-1.5 h-1.5 rounded-full ${healthInfo().dotClass} animate-pulse`} />
|
||||
{healthInfo().label}
|
||||
</span>
|
||||
|
||||
<Show when={props.cluster.healthMessage}>
|
||||
<p class={`text-sm ${healthClasses().text} mb-4 italic`}>
|
||||
{props.cluster.healthMessage}
|
||||
</p>
|
||||
</Show>
|
||||
|
||||
{/* Capacity Bar */}
|
||||
<div class="mb-4">
|
||||
<div class="flex justify-between text-sm mb-1">
|
||||
<span class="text-gray-600 dark:text-gray-400">Capacity</span>
|
||||
<span class="font-medium text-gray-900 dark:text-gray-100">
|
||||
{props.cluster.usagePercent?.toFixed(1) || 0}% used
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2.5 overflow-hidden">
|
||||
<Show when={showTooltip() && props.message}>
|
||||
<Portal mount={document.body}>
|
||||
<div
|
||||
class={`h-full rounded-full transition-all duration-500 ${(props.cluster.usagePercent || 0) > 85
|
||||
? 'bg-red-500'
|
||||
: (props.cluster.usagePercent || 0) > 70
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-blue-500'
|
||||
}`}
|
||||
style={{ width: `${Math.min(props.cluster.usagePercent || 0, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
<span>{formatBytes(props.cluster.usedBytes || 0)} used</span>
|
||||
<span>{formatBytes(props.cluster.totalBytes || 0)} total</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Service Stats Grid */}
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="text-center p-2 bg-white/50 dark:bg-gray-800/50 rounded-lg">
|
||||
<div class="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{props.cluster.numMons || 0}
|
||||
class="fixed z-[9999] pointer-events-none"
|
||||
style={{
|
||||
left: `${tooltipPos().x}px`,
|
||||
top: `${tooltipPos().y - 8}px`,
|
||||
transform: 'translate(-50%, -100%)',
|
||||
}}
|
||||
>
|
||||
<div class="bg-gray-900 dark:bg-gray-800 text-white text-[10px] rounded-md shadow-lg px-2.5 py-1.5 max-w-[280px] border border-gray-700">
|
||||
{props.message}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Monitors</div>
|
||||
</div>
|
||||
<div class="text-center p-2 bg-white/50 dark:bg-gray-800/50 rounded-lg">
|
||||
<div class="text-2xl font-bold text-purple-600 dark:text-purple-400">
|
||||
{props.cluster.numMgrs || 0}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Managers</div>
|
||||
</div>
|
||||
<div class="text-center p-2 bg-white/50 dark:bg-gray-800/50 rounded-lg">
|
||||
<div class="text-2xl font-bold text-green-600 dark:text-green-400">
|
||||
<span class={props.cluster.numOsdsUp !== props.cluster.numOsds ? 'text-yellow-600 dark:text-yellow-400' : ''}>
|
||||
{props.cluster.numOsdsUp || 0}
|
||||
</span>
|
||||
<span class="text-gray-400 dark:text-gray-500 text-lg">/{props.cluster.numOsds || 0}</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">OSDs Up</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PG Count */}
|
||||
<Show when={props.cluster.numPGs}>
|
||||
<div class="mt-3 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{props.cluster.numPGs?.toLocaleString()}</span>
|
||||
{' '}Placement Groups
|
||||
</div>
|
||||
</Portal>
|
||||
</Show>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// Pool Table
|
||||
const PoolsTable: Component<{ pools: CephPool[] }> = (props) => {
|
||||
return (
|
||||
<div class="overflow-hidden rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-sm">
|
||||
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100">
|
||||
Pools ({props.pools.length})
|
||||
</h3>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50 dark:bg-gray-900/50">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left text-gray-600 dark:text-gray-400 font-medium">Name</th>
|
||||
<th class="px-4 py-2 text-right text-gray-600 dark:text-gray-400 font-medium">Used</th>
|
||||
<th class="px-4 py-2 text-right text-gray-600 dark:text-gray-400 font-medium">Available</th>
|
||||
<th class="px-4 py-2 text-right text-gray-600 dark:text-gray-400 font-medium">Objects</th>
|
||||
<th class="px-4 py-2 text-center text-gray-600 dark:text-gray-400 font-medium">Usage</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
<For each={props.pools}>
|
||||
{(pool) => (
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
||||
<td class="px-4 py-3 font-medium text-gray-900 dark:text-gray-100">
|
||||
{pool.name}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-gray-700 dark:text-gray-300">
|
||||
{formatBytes(pool.storedBytes || 0)}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-gray-700 dark:text-gray-300">
|
||||
{formatBytes(pool.availableBytes || 0)}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-gray-700 dark:text-gray-300">
|
||||
{(pool.objects || 0).toLocaleString()}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-2 justify-center">
|
||||
<div class="w-16 h-2 bg-gray-200 dark:bg-gray-600 rounded-full overflow-hidden">
|
||||
<div
|
||||
class={`h-full rounded-full ${(pool.percentUsed || 0) > 85
|
||||
? 'bg-red-500'
|
||||
: (pool.percentUsed || 0) > 70
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-blue-500'
|
||||
}`}
|
||||
style={{ width: `${Math.min(pool.percentUsed || 0, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400 w-12 text-right">
|
||||
{(pool.percentUsed || 0).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
// Service status cell with tooltip
|
||||
const ServiceStatusCell: Component<{ services: CephServiceStatus[] }> = (props) => {
|
||||
const [showTooltip, setShowTooltip] = createSignal(false);
|
||||
const [tooltipPos, setTooltipPos] = createSignal({ x: 0, y: 0 });
|
||||
|
||||
// Services Status
|
||||
const ServicesStatus: Component<{ services: CephServiceStatus[] }> = (props) => {
|
||||
const getServiceIcon = (type: string) => {
|
||||
switch (type.toLowerCase()) {
|
||||
case 'mon':
|
||||
return '🔵';
|
||||
case 'mgr':
|
||||
return '🟣';
|
||||
case 'osd':
|
||||
return '🟢';
|
||||
case 'mds':
|
||||
return '🟡';
|
||||
case 'rgw':
|
||||
return '🟠';
|
||||
default:
|
||||
return '⚪';
|
||||
}
|
||||
const handleMouseEnter = (e: MouseEvent) => {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
setTooltipPos({ x: rect.left + rect.width / 2, y: rect.top });
|
||||
setShowTooltip(true);
|
||||
};
|
||||
|
||||
const getServiceStatus = (svc: CephServiceStatus) => {
|
||||
if (svc.running === svc.total) return { color: 'text-green-600 dark:text-green-400', status: 'healthy' };
|
||||
if (svc.running > 0) return { color: 'text-yellow-600 dark:text-yellow-400', status: 'degraded' };
|
||||
return { color: 'text-red-600 dark:text-red-400', status: 'down' };
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-sm p-4">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-3">Services</h3>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
<For each={props.services}>
|
||||
{(service) => {
|
||||
const allRunning = service.running === service.total;
|
||||
<>
|
||||
<div
|
||||
class="flex items-center gap-1.5 cursor-help"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={() => setShowTooltip(false)}
|
||||
>
|
||||
<For each={props.services.slice(0, 4)}>
|
||||
{(svc) => {
|
||||
const status = getServiceStatus(svc);
|
||||
return (
|
||||
<div
|
||||
class={`p-3 rounded-lg border ${allRunning
|
||||
? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800'
|
||||
: service.running > 0
|
||||
? 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800'
|
||||
: 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800'
|
||||
}`}
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-lg">{getServiceIcon(service.type)}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-gray-100 uppercase text-sm">
|
||||
{service.type}
|
||||
</span>
|
||||
</div>
|
||||
<div class={`text-lg font-bold ${allRunning
|
||||
? 'text-green-700 dark:text-green-400'
|
||||
: service.running > 0
|
||||
? 'text-yellow-700 dark:text-yellow-400'
|
||||
: 'text-red-700 dark:text-red-400'
|
||||
}`}>
|
||||
{service.running}/{service.total}
|
||||
</div>
|
||||
<Show when={service.message}>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1 truncate" title={service.message}>
|
||||
{service.message}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<span class={`inline-flex items-center gap-0.5 text-xs ${status.color}`}>
|
||||
<ServiceIcon type={svc.type} class="w-3.5 h-3.5" />
|
||||
<span class="font-mono text-[10px]">{svc.running}/{svc.total}</span>
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
<Show when={props.services.length > 4}>
|
||||
<span class="text-[10px] text-gray-500">+{props.services.length - 4}</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={showTooltip() && props.services.length > 0}>
|
||||
<Portal mount={document.body}>
|
||||
<div
|
||||
class="fixed z-[9999] pointer-events-none"
|
||||
style={{
|
||||
left: `${tooltipPos().x}px`,
|
||||
top: `${tooltipPos().y - 8}px`,
|
||||
transform: 'translate(-50%, -100%)',
|
||||
}}
|
||||
>
|
||||
<div class="bg-gray-900 dark:bg-gray-800 text-white text-[10px] rounded-md shadow-lg px-2.5 py-2 min-w-[180px] border border-gray-700">
|
||||
<div class="font-medium mb-1.5 text-gray-300 border-b border-gray-700 pb-1">
|
||||
Ceph Services
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<For each={props.services}>
|
||||
{(svc) => {
|
||||
const status = getServiceStatus(svc);
|
||||
return (
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="flex items-center gap-1.5 text-gray-400">
|
||||
<ServiceIcon type={svc.type} class="w-3.5 h-3.5" />
|
||||
<span class="uppercase">{svc.type}</span>
|
||||
</span>
|
||||
<span class={`font-mono ${status.color}`}>
|
||||
{svc.running}/{svc.total}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// Usage bar component
|
||||
const UsageBar: Component<{ percent: number; size?: 'sm' | 'md' }> = (props) => {
|
||||
const barHeight = () => props.size === 'sm' ? 'h-1.5' : 'h-2';
|
||||
const barColor = () => {
|
||||
const p = props.percent || 0;
|
||||
if (p > 90) return 'bg-red-500';
|
||||
if (p > 75) return 'bg-yellow-500';
|
||||
return 'bg-blue-500';
|
||||
};
|
||||
|
||||
return (
|
||||
<div class={`w-full bg-gray-200 dark:bg-gray-700 rounded-full ${barHeight()} overflow-hidden`}>
|
||||
<div
|
||||
class={`${barHeight()} rounded-full transition-all duration-500 ${barColor()}`}
|
||||
style={{ width: `${Math.min(props.percent || 0, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Main Ceph Page
|
||||
// Main Ceph Page Component
|
||||
const Ceph: Component = () => {
|
||||
const { state } = useWebSocket();
|
||||
const { state, connected, initialDataReceived, reconnecting, reconnect } = useWebSocket();
|
||||
|
||||
const [searchTerm, setSearchTerm] = createSignal('');
|
||||
let searchInputRef: HTMLInputElement | undefined;
|
||||
|
||||
// Keyboard handler for type-to-search
|
||||
createEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const isInputField =
|
||||
target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.tagName === 'SELECT' ||
|
||||
target.contentEditable === 'true';
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
if (searchTerm().trim()) {
|
||||
setSearchTerm('');
|
||||
searchInputRef?.blur();
|
||||
}
|
||||
} else if (!isInputField && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
if (searchInputRef) {
|
||||
searchInputRef.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
onCleanup(() => document.removeEventListener('keydown', handleKeyDown));
|
||||
});
|
||||
|
||||
const clusters = createMemo(() => state.cephClusters || []);
|
||||
const hasClusters = createMemo(() => clusters().length > 0);
|
||||
|
||||
// Aggregate all pools from all clusters
|
||||
const allPools = createMemo(() => {
|
||||
const pools: CephPool[] = [];
|
||||
const pools: (CephPool & { clusterName: string })[] = [];
|
||||
for (const cluster of clusters()) {
|
||||
if (cluster.pools) {
|
||||
pools.push(...cluster.pools);
|
||||
for (const pool of cluster.pools) {
|
||||
pools.push({
|
||||
...pool,
|
||||
clusterName: cluster.name || 'Ceph Cluster',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return pools;
|
||||
|
|
@ -308,55 +310,358 @@ const Ceph: Component = () => {
|
|||
}));
|
||||
});
|
||||
|
||||
// Filter pools by search term
|
||||
const filteredPools = createMemo(() => {
|
||||
const term = searchTerm().toLowerCase().trim();
|
||||
if (!term) return allPools();
|
||||
return allPools().filter(
|
||||
pool =>
|
||||
pool.name.toLowerCase().includes(term) ||
|
||||
pool.clusterName.toLowerCase().includes(term)
|
||||
);
|
||||
});
|
||||
|
||||
// Calculate total storage stats
|
||||
const totalStats = createMemo(() => {
|
||||
let totalBytes = 0;
|
||||
let usedBytes = 0;
|
||||
for (const cluster of clusters()) {
|
||||
totalBytes += cluster.totalBytes || 0;
|
||||
usedBytes += cluster.usedBytes || 0;
|
||||
}
|
||||
const usagePercent = totalBytes > 0 ? (usedBytes / totalBytes) * 100 : 0;
|
||||
return { totalBytes, usedBytes, usagePercent };
|
||||
});
|
||||
|
||||
const isLoading = createMemo(() => connected() && !initialDataReceived());
|
||||
|
||||
const thClass = "px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600/50 whitespace-nowrap transition-colors";
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-4 sm:gap-5">
|
||||
{/* Header */}
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Ceph Storage
|
||||
</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
Distributed storage cluster status and pools
|
||||
</p>
|
||||
</div>
|
||||
<ProxmoxSectionNav current="ceph" />
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
{/* Navigation */}
|
||||
<ProxmoxSectionNav current="ceph" />
|
||||
|
||||
<Show
|
||||
when={hasClusters()}
|
||||
fallback={
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div class="w-16 h-16 mb-4 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
|
||||
{/* Loading State */}
|
||||
<Show when={isLoading()}>
|
||||
<Card padding="lg">
|
||||
<EmptyState
|
||||
icon={
|
||||
<svg class="h-12 w-12 animate-spin text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-1">
|
||||
No Ceph Clusters Detected
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 max-w-md">
|
||||
Ceph cluster data will appear here when detected via the Pulse agent on your Proxmox nodes.
|
||||
Install the agent on a node with Ceph configured.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* Cluster Cards */}
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<For each={clusters()}>
|
||||
{(cluster) => <ClusterCard cluster={cluster} />}
|
||||
</For>
|
||||
</div>
|
||||
}
|
||||
title="Loading Ceph data..."
|
||||
description="Connecting to the monitoring service."
|
||||
/>
|
||||
</Card>
|
||||
</Show>
|
||||
|
||||
{/* Services Overview */}
|
||||
<Show when={allServices().length > 0}>
|
||||
<ServicesStatus services={allServices() as CephServiceStatus[]} />
|
||||
{/* Disconnected State */}
|
||||
<Show when={!connected() && !isLoading()}>
|
||||
<Card padding="lg" tone="danger">
|
||||
<EmptyState
|
||||
icon={
|
||||
<svg class="h-12 w-12 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
}
|
||||
title="Connection lost"
|
||||
description={reconnecting() ? 'Attempting to reconnect…' : 'Unable to connect to the backend server'}
|
||||
tone="danger"
|
||||
actions={
|
||||
!reconnecting() ? (
|
||||
<button
|
||||
onClick={() => reconnect()}
|
||||
class="mt-2 inline-flex items-center px-4 py-2 text-xs font-medium rounded bg-red-600 text-white hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Reconnect now
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</Show>
|
||||
|
||||
<Show when={connected() && initialDataReceived()}>
|
||||
{/* No Clusters Empty State */}
|
||||
<Show when={!hasClusters()}>
|
||||
<Card padding="lg">
|
||||
<EmptyState
|
||||
icon={
|
||||
<svg class="h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
|
||||
</svg>
|
||||
}
|
||||
title="No Ceph Clusters Detected"
|
||||
description="Ceph cluster data will appear here when detected via the Pulse agent on your Proxmox nodes. Install the agent on a node with Ceph configured."
|
||||
/>
|
||||
</Card>
|
||||
</Show>
|
||||
|
||||
{/* Pools Table */}
|
||||
<Show when={allPools().length > 0}>
|
||||
<PoolsTable pools={allPools()} />
|
||||
{/* Clusters Found - Show Content */}
|
||||
<Show when={hasClusters()}>
|
||||
{/* Summary Cards */}
|
||||
<div class="grid gap-3 sm:gap-4 grid-cols-1 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{/* Total Storage Card */}
|
||||
<Card padding="sm" tone="glass">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Total Storage</span>
|
||||
<svg class="w-4 h-4 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{formatBytes(totalStats().totalBytes)}
|
||||
</div>
|
||||
<div class="mt-1.5">
|
||||
<UsageBar percent={totalStats().usagePercent} size="sm" />
|
||||
<div class="flex justify-between text-[10px] text-gray-500 dark:text-gray-400 mt-1">
|
||||
<span>{formatBytes(totalStats().usedBytes)} used</span>
|
||||
<span>{totalStats().usagePercent.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Clusters Card */}
|
||||
<Card padding="sm" tone="glass">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Clusters</span>
|
||||
<svg class="w-4 h-4 text-purple-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 7.5l-2.25-1.313M21 7.5v2.25m0-2.25l-2.25 1.313M3 7.5l2.25-1.313M3 7.5l2.25 1.313M3 7.5v2.25m9 3l2.25-1.313M12 12.75l-2.25-1.313M12 12.75V15m0 6.75l2.25-1.313M12 21.75V19.5m0 2.25l-2.25-1.313m0-16.875L12 2.25l2.25 1.313M21 14.25v2.25l-2.25 1.313m-13.5 0L3 16.5v-2.25" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{clusters().length}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1 mt-2">
|
||||
<For each={clusters()}>
|
||||
{(cluster) => (
|
||||
<HealthBadge health={cluster.health || ''} message={cluster.healthMessage} />
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Services Card */}
|
||||
<Card padding="sm" tone="glass">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Services</span>
|
||||
<svg class="w-4 h-4 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{allServices().reduce((acc, svc) => acc + svc.running, 0)}
|
||||
<span class="text-sm font-normal text-gray-500 dark:text-gray-400">
|
||||
/{allServices().reduce((acc, svc) => acc + svc.total, 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<ServiceStatusCell services={allServices() as CephServiceStatus[]} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Pools Card */}
|
||||
<Card padding="sm" tone="glass">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Pools</span>
|
||||
<svg class="w-4 h-4 text-cyan-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6.878V6a2.25 2.25 0 012.25-2.25h7.5A2.25 2.25 0 0118 6v.878m-12 0c.235-.083.487-.128.75-.128h10.5c.263 0 .515.045.75.128m-12 0A2.25 2.25 0 004.5 9v.878m13.5-3A2.25 2.25 0 0119.5 9v.878m0 0a2.246 2.246 0 00-.75-.128H5.25c-.263 0-.515.045-.75.128m15 0A2.25 2.25 0 0121 12v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6c0-.98.626-1.813 1.5-2.122" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{allPools().length}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
{allPools().reduce((acc, pool) => acc + (pool.objects || 0), 0).toLocaleString()} objects
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Cluster Details Table */}
|
||||
<Show when={clusters().length > 0}>
|
||||
<Card padding="none" tone="glass" class="overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700 bg-gray-50/50 dark:bg-gray-800/50">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
Cluster Overview
|
||||
</h3>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full border-collapse whitespace-nowrap">
|
||||
<thead>
|
||||
<tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-700">
|
||||
<th class={`${thClass} pl-4`}>Cluster</th>
|
||||
<th class={thClass}>Health</th>
|
||||
<th class={thClass}>Monitors</th>
|
||||
<th class={thClass}>Managers</th>
|
||||
<th class={thClass}>OSDs</th>
|
||||
<th class={thClass}>PGs</th>
|
||||
<th class={`${thClass} min-w-[160px]`}>Capacity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
<For each={clusters()}>
|
||||
{(cluster) => (
|
||||
<tr class="hover:bg-gray-50/80 dark:hover:bg-gray-700/30 transition-colors">
|
||||
<td class="px-4 py-2.5">
|
||||
<div class="font-medium text-sm text-gray-900 dark:text-gray-100">
|
||||
{cluster.name || 'Ceph Cluster'}
|
||||
</div>
|
||||
<Show when={cluster.fsid}>
|
||||
<div class="text-[10px] text-gray-500 dark:text-gray-400 font-mono truncate max-w-[180px]">
|
||||
{cluster.fsid}
|
||||
</div>
|
||||
</Show>
|
||||
</td>
|
||||
<td class="px-2 py-2.5">
|
||||
<HealthBadge health={cluster.health || ''} message={cluster.healthMessage} />
|
||||
</td>
|
||||
<td class="px-2 py-2.5">
|
||||
<span class="inline-flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400">
|
||||
<ServiceIcon type="mon" class="w-3.5 h-3.5" />
|
||||
<span class="font-semibold">{cluster.numMons || 0}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-2 py-2.5">
|
||||
<span class="inline-flex items-center gap-1 text-xs text-purple-600 dark:text-purple-400">
|
||||
<ServiceIcon type="mgr" class="w-3.5 h-3.5" />
|
||||
<span class="font-semibold">{cluster.numMgrs || 0}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-2 py-2.5">
|
||||
<span class="inline-flex items-center gap-1 text-xs">
|
||||
<ServiceIcon type="osd" class="w-3.5 h-3.5 text-green-600 dark:text-green-400" />
|
||||
<span class={`font-semibold ${(cluster.numOsdsUp || 0) < (cluster.numOsds || 0) ? 'text-yellow-600 dark:text-yellow-400' : 'text-green-600 dark:text-green-400'}`}>
|
||||
{cluster.numOsdsUp || 0}
|
||||
</span>
|
||||
<span class="text-gray-400 dark:text-gray-500">
|
||||
/{cluster.numOsds || 0}
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-2 py-2.5">
|
||||
<span class="text-xs text-gray-700 dark:text-gray-300 font-medium">
|
||||
{(cluster.numPGs || 0).toLocaleString()}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-2 py-2.5">
|
||||
<div class="w-full max-w-[160px]">
|
||||
<UsageBar percent={cluster.usagePercent || 0} size="sm" />
|
||||
<div class="flex justify-between text-[10px] text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
<span>{formatBytes(cluster.usedBytes || 0)}</span>
|
||||
<span>{(cluster.usagePercent || 0).toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</Show>
|
||||
|
||||
{/* Pools Table */}
|
||||
<Show when={allPools().length > 0}>
|
||||
<Card padding="none" tone="glass" class="overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700 bg-gray-50/50 dark:bg-gray-800/50 flex items-center justify-between gap-4">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
Storage Pools ({filteredPools().length})
|
||||
</h3>
|
||||
{/* Search Input */}
|
||||
<div class="relative max-w-xs flex-1">
|
||||
<input
|
||||
ref={(el) => (searchInputRef = el)}
|
||||
type="text"
|
||||
placeholder="Search pools..."
|
||||
value={searchTerm()}
|
||||
onInput={(e) => setSearchTerm(e.currentTarget.value)}
|
||||
class="w-full pl-8 pr-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg
|
||||
bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500
|
||||
focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 outline-none transition-all"
|
||||
/>
|
||||
<svg
|
||||
class="absolute left-2.5 top-2 h-4 w-4 text-gray-400 dark:text-gray-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<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>
|
||||
<Show when={searchTerm()}>
|
||||
<button
|
||||
onClick={() => setSearchTerm('')}
|
||||
class="absolute right-2.5 top-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={filteredPools().length > 0}
|
||||
fallback={
|
||||
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
|
||||
No pools match "{searchTerm()}"
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full border-collapse whitespace-nowrap">
|
||||
<thead>
|
||||
<tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-700">
|
||||
<th class={`${thClass} pl-4`}>Pool</th>
|
||||
<th class={thClass}>Cluster</th>
|
||||
<th class={thClass}>Used</th>
|
||||
<th class={thClass}>Available</th>
|
||||
<th class={thClass}>Objects</th>
|
||||
<th class={`${thClass} min-w-[120px]`}>Usage</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
<For each={filteredPools()}>
|
||||
{(pool) => (
|
||||
<tr class="hover:bg-gray-50/80 dark:hover:bg-gray-700/30 transition-colors">
|
||||
<td class="px-4 py-2.5 font-medium text-sm text-gray-900 dark:text-gray-100">
|
||||
{pool.name}
|
||||
</td>
|
||||
<td class="px-2 py-2.5 text-xs text-gray-600 dark:text-gray-400">
|
||||
{pool.clusterName}
|
||||
</td>
|
||||
<td class="px-2 py-2.5 text-xs text-gray-700 dark:text-gray-300 font-mono">
|
||||
{formatBytes(pool.storedBytes || 0)}
|
||||
</td>
|
||||
<td class="px-2 py-2.5 text-xs text-gray-700 dark:text-gray-300 font-mono">
|
||||
{formatBytes(pool.availableBytes || 0)}
|
||||
</td>
|
||||
<td class="px-2 py-2.5 text-xs text-gray-700 dark:text-gray-300 font-mono">
|
||||
{(pool.objects || 0).toLocaleString()}
|
||||
</td>
|
||||
<td class="px-2 py-2.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-16">
|
||||
<UsageBar percent={pool.percentUsed || 0} size="sm" />
|
||||
</div>
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400 font-mono w-12 text-right">
|
||||
{(pool.percentUsed || 0).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
</Card>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -340,8 +340,7 @@ export function createWebSocketStore(url: string) {
|
|||
) {
|
||||
// Update state properties individually, but batch the whole payload to
|
||||
// reduce reactive recomputations and UI thrash on large updates.
|
||||
if (message.data) {
|
||||
batch(() => {
|
||||
if (message.data) batch(() => {
|
||||
// Mark that we've received usable data (initial payload or raw update)
|
||||
if (!initialDataReceived()) {
|
||||
setInitialDataReceived(true);
|
||||
|
|
@ -648,8 +647,7 @@ export function createWebSocketStore(url: string) {
|
|||
// Updated recentlyResolved
|
||||
}
|
||||
setState('lastUpdate', message.data.lastUpdate || new Date().toISOString());
|
||||
});
|
||||
}
|
||||
});
|
||||
logger.debug('message', {
|
||||
type: message.type,
|
||||
hasData: !!message.data,
|
||||
|
|
|
|||
Loading…
Reference in a new issue