feat(ui): apply glassmorphism and staggered animations to tables

This commit is contained in:
courtmanr@gmail.com 2025-11-27 10:44:36 +00:00
parent e9ac1429c0
commit 185476750d
19 changed files with 405 additions and 368 deletions

View file

@ -398,10 +398,10 @@ export function ResourceTable(props: ResourceTableProps) {
> >
<span <span
class={`text-sm ${isDisabledMetric class={`text-sm ${isDisabledMetric
? 'text-gray-400 dark:text-gray-500 italic' ? 'text-gray-400 dark:text-gray-500 italic'
: metricProps.isOverridden : metricProps.isOverridden
? 'text-gray-900 dark:text-gray-100 font-bold' ? 'text-gray-900 dark:text-gray-100 font-bold'
: 'text-gray-900 dark:text-gray-100' : 'text-gray-900 dark:text-gray-100'
}`} }`}
> >
{displayText} {displayText}
@ -483,6 +483,7 @@ export function ResourceTable(props: ResourceTableProps) {
padding="none" padding="none"
class="overflow-hidden border border-gray-200 dark:border-gray-700" class="overflow-hidden border border-gray-200 dark:border-gray-700"
border={false} border={false}
tone="glass"
> >
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700"> <div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<SectionHeader title={props.title} size="sm" /> <SectionHeader title={props.title} size="sm" />
@ -591,8 +592,8 @@ export function ResourceTable(props: ResourceTableProps) {
} }
}} }}
class={`w-16 px-2 py-0.5 text-sm text-center border rounded ${isOff() class={`w-16 px-2 py-0.5 text-sm text-center border rounded ${isOff()
? 'border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500 italic placeholder:text-gray-400 dark:placeholder:text-gray-500 placeholder:opacity-60 pointer-events-none' ? 'border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500 italic placeholder:text-gray-400 dark:placeholder:text-gray-500 placeholder:opacity-60 pointer-events-none'
: 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500' : 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500'
}`} }`}
title={ title={
isOff() isOff()
@ -868,7 +869,7 @@ export function ResourceTable(props: ResourceTableProps) {
return ( return (
<tr <tr
class={`hover:bg-gray-50 dark:hover:bg-gray-900/50 transition-colors ${resource.disabled || props.globalDisableFlag?.() ? 'opacity-40' : ''}`} class={`hover:bg-gray-50 dark:hover:bg-gray-900/50 transition-colors animate-enter ${resource.disabled || props.globalDisableFlag?.() ? 'opacity-40' : ''}`}
> >
{/* Alert toggle column */} {/* Alert toggle column */}
<td class="p-1 px-2 text-center align-middle"> <td class="p-1 px-2 text-center align-middle">
@ -940,8 +941,8 @@ export function ResourceTable(props: ResourceTableProps) {
rel="noopener noreferrer" rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
class={`text-sm font-medium truncate flex-nowrap transition-colors duration-150 ${resource.disabled class={`text-sm font-medium truncate flex-nowrap transition-colors duration-150 ${resource.disabled
? 'text-gray-500 dark:text-gray-500' ? 'text-gray-500 dark:text-gray-500'
: 'text-gray-900 dark:text-gray-100 hover:text-sky-600 dark:hover:text-sky-400' : 'text-gray-900 dark:text-gray-100 hover:text-sky-600 dark:hover:text-sky-400'
}`} }`}
title={`Open ${resource.displayName || resource.name} web interface`} title={`Open ${resource.displayName || resource.name} web interface`}
> >
@ -1148,8 +1149,8 @@ export function ResourceTable(props: ResourceTableProps) {
setActiveMetricInput(null); setActiveMetricInput(null);
}} }}
class={`w-16 px-2 py-0.5 text-sm text-center border rounded ${isDisabled() class={`w-16 px-2 py-0.5 text-sm text-center border rounded ${isDisabled()
? 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-600 border-gray-300 dark:border-gray-600' ? 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-600 border-gray-300 dark:border-gray-600'
: 'bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border-gray-300 dark:border-gray-600' : 'bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border-gray-300 dark:border-gray-600'
}`} }`}
/> />
</div> </div>
@ -1379,7 +1380,7 @@ export function ResourceTable(props: ResourceTableProps) {
return ( return (
<tr <tr
class={`hover:bg-gray-50 dark:hover:bg-gray-900/50 transition-colors ${resource.disabled || props.globalDisableFlag?.() ? 'opacity-40' : ''}`} class={`hover:bg-gray-50 dark:hover:bg-gray-900/50 transition-colors animate-enter ${resource.disabled || props.globalDisableFlag?.() ? 'opacity-40' : ''}`}
> >
{/* Alert toggle column */} {/* Alert toggle column */}
<td class="p-1 px-2 text-center align-middle"> <td class="p-1 px-2 text-center align-middle">
@ -1456,8 +1457,8 @@ export function ResourceTable(props: ResourceTableProps) {
rel="noopener noreferrer" rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
class={`text-sm font-medium truncate flex-nowrap transition-colors duration-150 ${resource.disabled class={`text-sm font-medium truncate flex-nowrap transition-colors duration-150 ${resource.disabled
? 'text-gray-500 dark:text-gray-500' ? 'text-gray-500 dark:text-gray-500'
: 'text-gray-900 dark:text-gray-100 hover:text-sky-600 dark:hover:text-sky-400' : 'text-gray-900 dark:text-gray-100 hover:text-sky-600 dark:hover:text-sky-400'
}`} }`}
title={`Open ${resource.displayName || resource.name} web interface`} title={`Open ${resource.displayName || resource.name} web interface`}
> >
@ -1600,8 +1601,8 @@ export function ResourceTable(props: ResourceTableProps) {
setActiveMetricInput(null); setActiveMetricInput(null);
}} }}
class={`w-16 px-2 py-0.5 text-sm text-center border rounded ${isDisabled() class={`w-16 px-2 py-0.5 text-sm text-center border rounded ${isDisabled()
? 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-600 border-gray-300 dark:border-gray-600' ? 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-600 border-gray-300 dark:border-gray-600'
: 'bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border-gray-300 dark:border-gray-600' : 'bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border-gray-300 dark:border-gray-600'
}`} }`}
/> />
</div> </div>

View file

@ -1,6 +1,7 @@
import { createSignal, createMemo, Show, For, onMount, onCleanup, createEffect } from 'solid-js'; import { createSignal, createMemo, Show, For, onMount, onCleanup, createEffect } from 'solid-js';
import { useNavigate, useLocation } from '@solidjs/router'; import { useNavigate, useLocation } from '@solidjs/router';
import Toggle from '@/components/shared/Toggle'; import Toggle from '@/components/shared/Toggle';
import { Card } from '@/components/shared/Card';
// Workaround for eslint false-positive when `For` is used only in JSX // Workaround for eslint false-positive when `For` is used only in JSX
const __ensureForUsage = For; const __ensureForUsage = For;
@ -198,37 +199,37 @@ interface ThresholdsTableProps {
setDockerDefaults: ( setDockerDefaults: (
value: value:
| { | {
cpu: number; cpu: number;
memory: number; memory: number;
disk: number; disk: number;
restartCount: number; restartCount: number;
restartWindow: number; restartWindow: number;
memoryWarnPct: number; memoryWarnPct: number;
memoryCriticalPct: number; memoryCriticalPct: number;
serviceWarnGapPercent: number; serviceWarnGapPercent: number;
serviceCriticalGapPercent: number; serviceCriticalGapPercent: number;
} }
| ((prev: { | ((prev: {
cpu: number; cpu: number;
memory: number; memory: number;
disk: number; disk: number;
restartCount: number; restartCount: number;
restartWindow: number; restartWindow: number;
memoryWarnPct: number; memoryWarnPct: number;
memoryCriticalPct: number; memoryCriticalPct: number;
serviceWarnGapPercent: number; serviceWarnGapPercent: number;
serviceCriticalGapPercent: number; serviceCriticalGapPercent: number;
}) => { }) => {
cpu: number; cpu: number;
memory: number; memory: number;
disk: number; disk: number;
restartCount: number; restartCount: number;
restartWindow: number; restartWindow: number;
memoryWarnPct: number; memoryWarnPct: number;
memoryCriticalPct: number; memoryCriticalPct: number;
serviceWarnGapPercent: number; serviceWarnGapPercent: number;
serviceCriticalGapPercent: number; serviceCriticalGapPercent: number;
}), }),
) => void; ) => void;
dockerIgnoredPrefixes: () => string[]; dockerIgnoredPrefixes: () => string[];
setDockerIgnoredPrefixes: (value: string[] | ((prev: string[]) => string[])) => void; setDockerIgnoredPrefixes: (value: string[] | ((prev: string[]) => string[])) => void;
@ -309,9 +310,9 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
const [searchTerm, setSearchTerm] = createSignal(''); const [searchTerm, setSearchTerm] = createSignal('');
const [editingId, setEditingId] = createSignal<string | null>(null); const [editingId, setEditingId] = createSignal<string | null>(null);
const [editingThresholds, setEditingThresholds] = createSignal< const [editingThresholds, setEditingThresholds] = createSignal<
Record<string, number | undefined> Record<string, number | undefined>
>({}); >({});
const [editingNote, setEditingNote] = createSignal(''); const [editingNote, setEditingNote] = createSignal('');
const [activeTab, setActiveTab] = createSignal<'proxmox' | 'pmg' | 'hosts' | 'docker'>('proxmox'); const [activeTab, setActiveTab] = createSignal<'proxmox' | 'pmg' | 'hosts' | 'docker'>('proxmox');
let searchInputRef: HTMLInputElement | undefined; let searchInputRef: HTMLInputElement | undefined;
@ -640,7 +641,7 @@ const [editingThresholds, setEditingThresholds] = createSignal<
if (search) { if (search) {
return nodes.filter((n) => n.name.toLowerCase().includes(search)); return nodes.filter((n) => n.name.toLowerCase().includes(search));
} }
return nodes; return nodes;
}, []); }, []);
const hostAgentsWithOverrides = createMemo<Resource[]>((prev = []) => { const hostAgentsWithOverrides = createMemo<Resource[]>((prev = []) => {
@ -1103,13 +1104,13 @@ const [editingThresholds, setEditingThresholds] = createSignal<
const differs = const differs =
current.enabled !== factory.enabled || current.enabled !== factory.enabled ||
(current.warningDays ?? DEFAULT_SNAPSHOT_WARNING) !== (current.warningDays ?? DEFAULT_SNAPSHOT_WARNING) !==
(factory.warningDays ?? DEFAULT_SNAPSHOT_WARNING) || (factory.warningDays ?? DEFAULT_SNAPSHOT_WARNING) ||
(current.criticalDays ?? DEFAULT_SNAPSHOT_CRITICAL) !== (current.criticalDays ?? DEFAULT_SNAPSHOT_CRITICAL) !==
(factory.criticalDays ?? DEFAULT_SNAPSHOT_CRITICAL) || (factory.criticalDays ?? DEFAULT_SNAPSHOT_CRITICAL) ||
(current.warningSizeGiB ?? DEFAULT_SNAPSHOT_WARNING_SIZE) !== (current.warningSizeGiB ?? DEFAULT_SNAPSHOT_WARNING_SIZE) !==
(factory.warningSizeGiB ?? DEFAULT_SNAPSHOT_WARNING_SIZE) || (factory.warningSizeGiB ?? DEFAULT_SNAPSHOT_WARNING_SIZE) ||
(current.criticalSizeGiB ?? DEFAULT_SNAPSHOT_CRITICAL_SIZE) !== (current.criticalSizeGiB ?? DEFAULT_SNAPSHOT_CRITICAL_SIZE) !==
(factory.criticalSizeGiB ?? DEFAULT_SNAPSHOT_CRITICAL_SIZE); (factory.criticalSizeGiB ?? DEFAULT_SNAPSHOT_CRITICAL_SIZE);
return differs ? 1 : 0; return differs ? 1 : 0;
}); });
@ -1118,9 +1119,9 @@ const [editingThresholds, setEditingThresholds] = createSignal<
const backupFactory = backupFactoryConfig(); const backupFactory = backupFactoryConfig();
return backupCurrent.enabled !== backupFactory.enabled || return backupCurrent.enabled !== backupFactory.enabled ||
(backupCurrent.warningDays ?? DEFAULT_BACKUP_WARNING) !== (backupCurrent.warningDays ?? DEFAULT_BACKUP_WARNING) !==
(backupFactory.warningDays ?? DEFAULT_BACKUP_WARNING) || (backupFactory.warningDays ?? DEFAULT_BACKUP_WARNING) ||
(backupCurrent.criticalDays ?? DEFAULT_BACKUP_CRITICAL) !== (backupCurrent.criticalDays ?? DEFAULT_BACKUP_CRITICAL) !==
(backupFactory.criticalDays ?? DEFAULT_BACKUP_CRITICAL) (backupFactory.criticalDays ?? DEFAULT_BACKUP_CRITICAL)
? 1 ? 1
: 0; : 0;
}); });
@ -1179,11 +1180,11 @@ const [editingThresholds, setEditingThresholds] = createSignal<
const filteredGuests = search const filteredGuests = search
? guests.filter( ? guests.filter(
(g) => (g) =>
g.name.toLowerCase().includes(search) || g.name.toLowerCase().includes(search) ||
g.vmid?.toString().includes(search) || g.vmid?.toString().includes(search) ||
g.node?.toLowerCase().includes(search), g.node?.toLowerCase().includes(search),
) )
: guests; : guests;
// Group by node // Group by node
@ -1631,9 +1632,9 @@ const [editingThresholds, setEditingThresholds] = createSignal<
const hasStateOnlyOverride = Boolean( const hasStateOnlyOverride = Boolean(
resource.disabled || resource.disabled ||
resource.disableConnectivity || resource.disableConnectivity ||
resource.poweredOffSeverity !== undefined || resource.poweredOffSeverity !== undefined ||
noteForOverride !== undefined, noteForOverride !== undefined,
); );
// If no threshold overrides or state flags remain, remove the override entirely // If no threshold overrides or state flags remain, remove the override entirely
@ -1739,11 +1740,11 @@ const [editingThresholds, setEditingThresholds] = createSignal<
setEditingNote(''); setEditingNote('');
}; };
const cancelEdit = () => { const cancelEdit = () => {
setEditingId(null); setEditingId(null);
setEditingThresholds({}); setEditingThresholds({});
setEditingNote(''); setEditingNote('');
}; };
const updateMetricDelay = ( const updateMetricDelay = (
typeKey: 'guest' | 'node' | 'storage' | 'pbs', typeKey: 'guest' | 'node' | 'storage' | 'pbs',
@ -2217,44 +2218,40 @@ const cancelEdit = () => {
<button <button
type="button" type="button"
onClick={() => handleTabClick('proxmox')} onClick={() => handleTabClick('proxmox')}
class={`py-3 px-1 border-b-2 font-medium text-sm transition-colors cursor-pointer ${ class={`py-3 px-1 border-b-2 font-medium text-sm transition-colors cursor-pointer ${activeTab() === 'proxmox'
activeTab() === 'proxmox'
? 'border-blue-500 text-blue-600 dark:text-blue-400' ? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
}`} }`}
> >
Proxmox / PBS Proxmox / PBS
</button> </button>
<button <button
type="button" type="button"
onClick={() => handleTabClick('pmg')} onClick={() => handleTabClick('pmg')}
class={`py-3 px-1 border-b-2 font-medium text-sm transition-colors cursor-pointer ${ class={`py-3 px-1 border-b-2 font-medium text-sm transition-colors cursor-pointer ${activeTab() === 'pmg'
activeTab() === 'pmg'
? 'border-blue-500 text-blue-600 dark:text-blue-400' ? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
}`} }`}
> >
Mail Gateway Mail Gateway
</button> </button>
<button <button
type="button" type="button"
onClick={() => handleTabClick('hosts')} onClick={() => handleTabClick('hosts')}
class={`py-3 px-1 border-b-2 font-medium text-sm transition-colors cursor-pointer ${ class={`py-3 px-1 border-b-2 font-medium text-sm transition-colors cursor-pointer ${activeTab() === 'hosts'
activeTab() === 'hosts'
? 'border-blue-500 text-blue-600 dark:text-blue-400' ? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
}`} }`}
> >
Host Agents Host Agents
</button> </button>
<button <button
type="button" type="button"
onClick={() => handleTabClick('docker')} onClick={() => handleTabClick('docker')}
class={`py-3 px-1 border-b-2 font-medium text-sm transition-colors cursor-pointer ${ class={`py-3 px-1 border-b-2 font-medium text-sm transition-colors cursor-pointer ${activeTab() === 'docker'
activeTab() === 'docker'
? 'border-blue-500 text-blue-600 dark:text-blue-400' ? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
}`} }`}
> >
Containers Containers
</button> </button>
@ -2360,9 +2357,9 @@ const cancelEdit = () => {
factoryDefaults={ factoryDefaults={
props.factoryNodeDefaults props.factoryNodeDefaults
? { ? {
cpu: props.factoryNodeDefaults.cpu, cpu: props.factoryNodeDefaults.cpu,
memory: props.factoryNodeDefaults.memory, memory: props.factoryNodeDefaults.memory,
} }
: undefined : undefined
} }
onResetDefaults={props.resetNodeDefaults} onResetDefaults={props.resetNodeDefaults}
@ -2745,7 +2742,7 @@ const cancelEdit = () => {
</Show> </Show>
<Show when={activeTab() === 'docker'}> <Show when={activeTab() === 'docker'}>
<div class="mb-6 rounded-lg border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-900"> <Card padding="md" tone="glass" class="mb-6">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100"> <h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">
@ -2763,26 +2760,26 @@ const cancelEdit = () => {
onClick={handleResetDockerIgnored} onClick={handleResetDockerIgnored}
> >
Reset Reset
</button> </button>
</Show> </Show>
</div> </div>
<textarea <textarea
value={dockerIgnoredInput()} value={dockerIgnoredInput()}
onInput={(event) => handleDockerIgnoredChange(event.currentTarget.value)} onInput={(event) => handleDockerIgnoredChange(event.currentTarget.value)}
onKeyDown={(event) => { onKeyDown={(event) => {
// Ensure Enter key works in textarea for creating new lines // Ensure Enter key works in textarea for creating new lines
if (event.key === 'Enter') { if (event.key === 'Enter') {
// Don't prevent default - allow the newline to be inserted // Don't prevent default - allow the newline to be inserted
event.stopPropagation(); event.stopPropagation();
} }
}} }}
placeholder="runner-" placeholder="runner-"
rows={4} rows={4}
class="mt-4 w-full rounded-md border border-gray-300 bg-white p-3 text-sm text-gray-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:focus:border-sky-400 dark:focus:ring-sky-600/40" class="mt-4 w-full rounded-md border border-gray-300 bg-white p-3 text-sm text-gray-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:focus:border-sky-400 dark:focus:ring-sky-600/40"
/> />
</div> </Card>
<div class="mb-6 rounded-lg border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-900"> <Card padding="md" tone="glass" class="mb-6">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Swarm service alerts</h3> <h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Swarm service alerts</h3>
@ -2865,7 +2862,7 @@ const cancelEdit = () => {
{serviceGapValidationMessage()} {serviceGapValidationMessage()}
</p> </p>
)} )}
</div> </Card>
<Show when={hasSection('dockerHosts')}> <Show when={hasSection('dockerHosts')}>
<div ref={registerSection('dockerHosts')} class="scroll-mt-24"> <div ref={registerSection('dockerHosts')} class="scroll-mt-24">

View file

@ -1864,7 +1864,7 @@ const UnifiedBackups: Component = () => {
/> />
{/* Table */} {/* Table */}
<Card padding="none" class="mb-4 overflow-hidden"> <Card padding="none" tone="glass" class="mb-4 overflow-hidden">
<div class="overflow-x-auto" style="scrollbar-width: none; -ms-overflow-style: none;"> <div class="overflow-x-auto" style="scrollbar-width: none; -ms-overflow-style: none;">
<style>{` <style>{`
.overflow-x-auto::-webkit-scrollbar { display: none; } .overflow-x-auto::-webkit-scrollbar { display: none; }
@ -2138,7 +2138,7 @@ const UnifiedBackups: Component = () => {
</tr> </tr>
<For each={group.items}> <For each={group.items}>
{(item) => ( {(item) => (
<tr class="border-t border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/30"> <tr class="border-t border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/30 animate-enter">
<td class="p-0.5 pl-5 pr-1.5 text-sm align-middle">{item.vmid}</td> <td class="p-0.5 pl-5 pr-1.5 text-sm align-middle">{item.vmid}</td>
<td class="p-0.5 px-1.5 align-middle"> <td class="p-0.5 px-1.5 align-middle">
<span <span

View file

@ -929,165 +929,165 @@ export function Dashboard(props: DashboardProps) {
{/* Table View */} {/* Table View */}
<Show when={connected() && initialDataReceived() && filteredGuests().length > 0}> <Show when={connected() && initialDataReceived() && filteredGuests().length > 0}>
<ComponentErrorBoundary name="Guest Table"> <ComponentErrorBoundary name="Guest Table">
<Card padding="none" class="mb-4 bg-white dark:bg-gray-800"> <Card padding="none" tone="glass" class="mb-4">
<div class="overflow-x-auto"> <div class="overflow-x-auto">
{/* Desktop Header */} {/* Desktop Header */}
<div
class="grid border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 text-[11px] sm:text-xs font-medium uppercase tracking-wider sticky top-0 z-20 min-w-[520px] md:min-w-0"
style={{ 'grid-template-columns': headerGridTemplate() }}
>
{/* Name Header */}
<div <div
class="pl-4 pr-2 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center whitespace-nowrap" class="grid border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 text-[11px] sm:text-xs font-medium uppercase tracking-wider sticky top-0 z-20 min-w-[520px] md:min-w-0"
onClick={() => handleSort('name')} style={{ 'grid-template-columns': headerGridTemplate() }}
> >
Name {sortKey() === 'name' && (sortDirection() === 'asc' ? '▲' : '▼')} {/* Name Header */}
<div
class="pl-4 pr-2 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center whitespace-nowrap"
onClick={() => handleSort('name')}
>
Name {sortKey() === 'name' && (sortDirection() === 'asc' ? '▲' : '▼')}
</div>
{/* Type */}
<div
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap"
onClick={() => handleSort('type')}
title="Type"
>
<span class="hidden xl:inline">Type</span>
<span class="xl:hidden">T</span>
{sortKey() === 'type' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
</div>
{/* VMID */}
<div
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap"
onClick={() => handleSort('vmid')}
title="VMID"
>
<span class="hidden xl:inline">VMID</span>
<span class="xl:hidden">ID</span>
{sortKey() === 'vmid' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
</div>
{/* Uptime */}
<div
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap"
onClick={() => handleSort('uptime')}
title="Uptime"
>
<span class="hidden xl:inline">Uptime</span>
<span class="xl:hidden">Up</span>
{sortKey() === 'uptime' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
</div>
{/* CPU */}
<div
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap"
onClick={() => handleSort('cpu')}
>
CPU {sortKey() === 'cpu' && (sortDirection() === 'asc' ? '▲' : '▼')}
</div>
{/* Memory */}
<div
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap"
onClick={() => handleSort('memory')}
title="Memory"
>
<span class="hidden xl:inline">Memory</span>
<span class="xl:hidden">Mem</span>
{sortKey() === 'memory' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
</div>
{/* Disk */}
<div
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap"
onClick={() => handleSort('disk')}
>
Disk {sortKey() === 'disk' && (sortDirection() === 'asc' ? '▲' : '▼')}
</div>
{/* Disk Read */}
<div
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap"
onClick={() => handleSort('diskRead')}
title="Disk Read"
>
<span class="hidden xl:inline">Disk Read</span>
<span class="xl:hidden">D Rd</span>
{sortKey() === 'diskRead' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
</div>
{/* Disk Write */}
<div
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap"
onClick={() => handleSort('diskWrite')}
title="Disk Write"
>
<span class="hidden xl:inline">Disk Write</span>
<span class="xl:hidden">D Wr</span>
{sortKey() === 'diskWrite' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
</div>
{/* Net In */}
<div
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap"
onClick={() => handleSort('networkIn')}
title="Net In"
>
<span class="hidden xl:inline">Net In</span>
<span class="xl:hidden">N In</span>
{sortKey() === 'networkIn' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
</div>
{/* Net Out */}
<div
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap"
onClick={() => handleSort('networkOut')}
title="Net Out"
>
<span class="hidden xl:inline">Net Out</span>
<span class="xl:hidden">N Out</span>
{sortKey() === 'networkOut' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
</div>
</div> </div>
{/* Type */} {/* Guest List */}
<div <div class="divide-y divide-gray-200 dark:divide-gray-700 min-w-[520px] md:min-w-0">
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap" <For
onClick={() => handleSort('type')} each={Object.entries(groupedGuests()).sort(([instanceIdA], [instanceIdB]) => {
title="Type" const nodeA = nodeByInstance()[instanceIdA];
> const nodeB = nodeByInstance()[instanceIdB];
<span class="hidden xl:inline">Type</span> const labelA = nodeA ? getNodeDisplayName(nodeA) : instanceIdA;
<span class="xl:hidden">T</span> const labelB = nodeB ? getNodeDisplayName(nodeB) : instanceIdB;
{sortKey() === 'type' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')} return labelA.localeCompare(labelB) || instanceIdA.localeCompare(instanceIdB);
})}
fallback={<></>}
>
{([instanceId, guests]) => {
const node = nodeByInstance()[instanceId];
return (
<>
<Show when={node && groupingMode() === 'grouped'}>
<NodeGroupHeader node={node!} />
</Show>
<For each={guests} fallback={<></>}>
{(guest) => {
const guestId = guest.id || `${guest.instance}-${guest.vmid}`;
const metadata =
guestMetadata()[guestId] ||
guestMetadata()[`${guest.node}-${guest.vmid}`];
const parentNode = node ?? resolveParentNode(guest);
const parentNodeOnline = parentNode ? isNodeOnline(parentNode) : true;
return (
<ComponentErrorBoundary name="GuestRow">
<GuestRow
guest={guest}
alertStyles={getAlertStyles(guestId, activeAlerts, alertsEnabled())}
customUrl={metadata?.customUrl}
onTagClick={handleTagClick}
activeSearch={search()}
parentNodeOnline={parentNodeOnline}
onCustomUrlUpdate={handleCustomUrlUpdate}
isGroupedView={groupingMode() === 'grouped'}
/>
</ComponentErrorBoundary>
);
}}
</For>
</>
);
}}
</For>
</div> </div>
{/* VMID */}
<div
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap"
onClick={() => handleSort('vmid')}
title="VMID"
>
<span class="hidden xl:inline">VMID</span>
<span class="xl:hidden">ID</span>
{sortKey() === 'vmid' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
</div>
{/* Uptime */}
<div
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap"
onClick={() => handleSort('uptime')}
title="Uptime"
>
<span class="hidden xl:inline">Uptime</span>
<span class="xl:hidden">Up</span>
{sortKey() === 'uptime' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
</div>
{/* CPU */}
<div
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap"
onClick={() => handleSort('cpu')}
>
CPU {sortKey() === 'cpu' && (sortDirection() === 'asc' ? '▲' : '▼')}
</div>
{/* Memory */}
<div
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap"
onClick={() => handleSort('memory')}
title="Memory"
>
<span class="hidden xl:inline">Memory</span>
<span class="xl:hidden">Mem</span>
{sortKey() === 'memory' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
</div>
{/* Disk */}
<div
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap"
onClick={() => handleSort('disk')}
>
Disk {sortKey() === 'disk' && (sortDirection() === 'asc' ? '▲' : '▼')}
</div>
{/* Disk Read */}
<div
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap"
onClick={() => handleSort('diskRead')}
title="Disk Read"
>
<span class="hidden xl:inline">Disk Read</span>
<span class="xl:hidden">D Rd</span>
{sortKey() === 'diskRead' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
</div>
{/* Disk Write */}
<div
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap"
onClick={() => handleSort('diskWrite')}
title="Disk Write"
>
<span class="hidden xl:inline">Disk Write</span>
<span class="xl:hidden">D Wr</span>
{sortKey() === 'diskWrite' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
</div>
{/* Net In */}
<div
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap"
onClick={() => handleSort('networkIn')}
title="Net In"
>
<span class="hidden xl:inline">Net In</span>
<span class="xl:hidden">N In</span>
{sortKey() === 'networkIn' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
</div>
{/* Net Out */}
<div
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap"
onClick={() => handleSort('networkOut')}
title="Net Out"
>
<span class="hidden xl:inline">Net Out</span>
<span class="xl:hidden">N Out</span>
{sortKey() === 'networkOut' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
</div>
</div>
{/* Guest List */}
<div class="divide-y divide-gray-200 dark:divide-gray-700 min-w-[520px] md:min-w-0">
<For
each={Object.entries(groupedGuests()).sort(([instanceIdA], [instanceIdB]) => {
const nodeA = nodeByInstance()[instanceIdA];
const nodeB = nodeByInstance()[instanceIdB];
const labelA = nodeA ? getNodeDisplayName(nodeA) : instanceIdA;
const labelB = nodeB ? getNodeDisplayName(nodeB) : instanceIdB;
return labelA.localeCompare(labelB) || instanceIdA.localeCompare(instanceIdB);
})}
fallback={<></>}
>
{([instanceId, guests]) => {
const node = nodeByInstance()[instanceId];
return (
<>
<Show when={node && groupingMode() === 'grouped'}>
<NodeGroupHeader node={node!} />
</Show>
<For each={guests} fallback={<></>}>
{(guest) => {
const guestId = guest.id || `${guest.instance}-${guest.vmid}`;
const metadata =
guestMetadata()[guestId] ||
guestMetadata()[`${guest.node}-${guest.vmid}`];
const parentNode = node ?? resolveParentNode(guest);
const parentNodeOnline = parentNode ? isNodeOnline(parentNode) : true;
return (
<ComponentErrorBoundary name="GuestRow">
<GuestRow
guest={guest}
alertStyles={getAlertStyles(guestId, activeAlerts, alertsEnabled())}
customUrl={metadata?.customUrl}
onTagClick={handleTagClick}
activeSearch={search()}
parentNodeOnline={parentNodeOnline}
onCustomUrlUpdate={handleCustomUrlUpdate}
isGroupedView={groupingMode() === 'grouped'}
/>
</ComponentErrorBoundary>
);
}}
</For>
</>
);
}}
</For>
</div>
</div> </div>
</Card> </Card>
</ComponentErrorBoundary> </ComponentErrorBoundary>

View file

@ -455,7 +455,7 @@ export function GuestRow(props: GuestRowProps) {
}); });
const rowClass = createMemo(() => { const rowClass = createMemo(() => {
const base = 'transition-all duration-200 relative'; const base = 'transition-all duration-200 relative animate-enter';
const hover = 'hover:shadow-sm'; const hover = 'hover:shadow-sm';
const alertBg = hasUnacknowledgedAlert() const alertBg = hasUnacknowledgedAlert()
? props.alertStyles?.severity === 'critical' ? props.alertStyles?.severity === 'critical'

View file

@ -142,7 +142,7 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
}; };
return ( return (
<Card padding="none" class="mb-4 overflow-hidden"> <Card padding="none" tone="glass" class="mb-4 overflow-hidden">
<ScrollableTable minWidth="300px" persistKey="docker-host-summary"> <ScrollableTable minWidth="300px" persistKey="docker-host-summary">
<table class="w-full table-fixed border-collapse sm:whitespace-nowrap"> <table class="w-full table-fixed border-collapse sm:whitespace-nowrap">
<thead> <thead>
@ -227,7 +227,7 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
}; };
const rowClass = () => { const rowClass = () => {
const baseHover = 'cursor-pointer transition-all duration-200 relative hover:bg-gray-50 dark:hover:bg-gray-700/50 hover:shadow-sm'; const baseHover = 'cursor-pointer transition-all duration-200 relative hover:bg-gray-50 dark:hover:bg-gray-700/50 hover:shadow-sm animate-enter';
if (selected) { if (selected) {
return 'cursor-pointer transition-all duration-200 relative bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/30 hover:shadow-sm z-10'; return 'cursor-pointer transition-all duration-200 relative bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/30 hover:shadow-sm z-10';

View file

@ -1252,7 +1252,7 @@ const DockerContainerRow: Component<{
return ( return (
<> <>
<div <div
class={`grid items-center transition-all duration-200 ${hasDrawerContent() ? 'cursor-pointer' : ''} ${expanded() ? 'bg-gray-50 dark:bg-gray-800/40' : 'hover:bg-gray-50 dark:hover:bg-gray-800/50'} ${!isRunning() ? 'opacity-60' : ''}`} class={`grid items-center transition-all duration-200 animate-enter ${hasDrawerContent() ? 'cursor-pointer' : ''} ${expanded() ? 'bg-gray-50 dark:bg-gray-800/40' : 'hover:bg-gray-50 dark:hover:bg-gray-800/50'} ${!isRunning() ? 'opacity-60' : ''}`}
style={{ 'grid-template-columns': props.gridTemplate() }} style={{ 'grid-template-columns': props.gridTemplate() }}
onClick={toggle} onClick={toggle}
aria-expanded={expanded()} aria-expanded={expanded()}
@ -1949,7 +1949,7 @@ const DockerServiceRow: Component<{
return ( return (
<> <>
<div <div
class={`grid items-center transition-all duration-200 ${hasTasks() ? 'cursor-pointer' : ''} ${expanded() ? 'bg-gray-50 dark:bg-gray-800/40' : 'hover:bg-gray-50 dark:hover:bg-gray-800/50'} ${!isHealthy() ? 'opacity-60' : ''}`} class={`grid items-center transition-all duration-200 animate-enter ${hasTasks() ? 'cursor-pointer' : ''} ${expanded() ? 'bg-gray-50 dark:bg-gray-800/40' : 'hover:bg-gray-50 dark:hover:bg-gray-800/50'} ${!isHealthy() ? 'opacity-60' : ''}`}
style={{ 'grid-template-columns': props.gridTemplate() }} style={{ 'grid-template-columns': props.gridTemplate() }}
onClick={toggle} onClick={toggle}
aria-expanded={expanded()} aria-expanded={expanded()}
@ -2407,7 +2407,7 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
</Card> </Card>
} }
> >
<Card padding="none" class="overflow-hidden"> <Card padding="none" tone="glass" class="overflow-hidden">
<div class="overflow-x-auto"> <div class="overflow-x-auto">
{/* Header Row */} {/* Header Row */}
<div <div

View file

@ -170,7 +170,7 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
</Card> </Card>
} }
> >
<Card padding="none" class="overflow-hidden"> <Card padding="none" tone="glass" class="overflow-hidden">
<ScrollableTable> <ScrollableTable>
<table class="w-full border-collapse"> <table class="w-full border-collapse">
<thead> <thead>
@ -243,7 +243,7 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
}); });
const rowClass = () => { const rowClass = () => {
const base = 'border-b border-gray-200 dark:border-gray-700 transition-all duration-200'; const base = 'border-b border-gray-200 dark:border-gray-700 transition-all duration-200 animate-enter';
const hover = 'hover:bg-gray-50 dark:hover:bg-gray-800/50'; const hover = 'hover:bg-gray-50 dark:hover:bg-gray-800/50';
const clickable = hasDrawerContent() ? 'cursor-pointer' : ''; const clickable = hasDrawerContent() ? 'cursor-pointer' : '';
const expanded = drawerOpen() ? 'bg-gray-50 dark:bg-gray-800/40' : ''; const expanded = drawerOpen() ? 'bg-gray-50 dark:bg-gray-800/40' : '';

View file

@ -97,7 +97,7 @@ const MailGateway: Component = () => {
const rblPct = (rbl / Math.max(inbound, 1)) * 100; const rblPct = (rbl / Math.max(inbound, 1)) * 100;
return ( return (
<div class="border border-gray-200 dark:border-gray-700 rounded overflow-hidden"> <Card padding="none" tone="glass" class="overflow-hidden">
{/* Instance Header Strip */} {/* Instance Header Strip */}
<div class="sticky top-0 z-10 flex items-center justify-between px-3 py-2 bg-gray-50 dark:bg-gray-800/40 border-b border-gray-200 dark:border-gray-700"> <div class="sticky top-0 z-10 flex items-center justify-between px-3 py-2 bg-gray-50 dark:bg-gray-800/40 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@ -133,14 +133,14 @@ const MailGateway: Component = () => {
</div> </div>
<table class="w-full text-xs"> <table class="w-full text-xs">
<tbody class="divide-y divide-gray-200 dark:divide-gray-700"> <tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr> <tr class="animate-enter">
<td class="px-2 py-1.5"> <td class="px-2 py-1.5">
<div class="text-xs font-semibold text-gray-900 dark:text-gray-100">{formatNum(total)}</div> <div class="text-xs font-semibold text-gray-900 dark:text-gray-100">{formatNum(total)}</div>
<div class="text-[11px] text-gray-500 dark:text-gray-400">{formatDec(total / 24)}/hr</div> <div class="text-[11px] text-gray-500 dark:text-gray-400">{formatDec(total / 24)}/hr</div>
</td> </td>
<td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Total processed</td> <td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Total processed</td>
</tr> </tr>
<tr> <tr class="animate-enter">
<td class="px-2 py-1.5"> <td class="px-2 py-1.5">
<div class="text-xs text-gray-900 dark:text-gray-100">{formatNum(inbound)}</div> <div class="text-xs text-gray-900 dark:text-gray-100">{formatNum(inbound)}</div>
<div class="text-[11px] text-gray-500 dark:text-gray-400"> <div class="text-[11px] text-gray-500 dark:text-gray-400">
@ -149,7 +149,7 @@ const MailGateway: Component = () => {
</td> </td>
<td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Inbound</td> <td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Inbound</td>
</tr> </tr>
<tr> <tr class="animate-enter">
<td class="px-2 py-1.5"> <td class="px-2 py-1.5">
<div class="text-xs text-gray-900 dark:text-gray-100">{formatNum(outbound)}</div> <div class="text-xs text-gray-900 dark:text-gray-100">{formatNum(outbound)}</div>
<div class="text-[11px] text-gray-500 dark:text-gray-400"> <div class="text-[11px] text-gray-500 dark:text-gray-400">
@ -158,7 +158,7 @@ const MailGateway: Component = () => {
</td> </td>
<td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Outbound</td> <td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Outbound</td>
</tr> </tr>
<tr> <tr class="animate-enter">
<td class="px-2 py-1.5"> <td class="px-2 py-1.5">
<div class="text-xs font-medium text-orange-600 dark:text-orange-400">{formatNum(spam)}</div> <div class="text-xs font-medium text-orange-600 dark:text-orange-400">{formatNum(spam)}</div>
<div class="text-[11px] text-gray-500 dark:text-gray-400"> <div class="text-[11px] text-gray-500 dark:text-gray-400">
@ -167,7 +167,7 @@ const MailGateway: Component = () => {
</td> </td>
<td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Spam caught</td> <td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Spam caught</td>
</tr> </tr>
<tr> <tr class="animate-enter">
<td class="px-2 py-1.5"> <td class="px-2 py-1.5">
<div class="text-xs font-medium text-red-600 dark:text-red-400">{formatNum(virus)}</div> <div class="text-xs font-medium text-red-600 dark:text-red-400">{formatNum(virus)}</div>
<div class="text-[11px] text-gray-500 dark:text-gray-400"> <div class="text-[11px] text-gray-500 dark:text-gray-400">
@ -189,7 +189,7 @@ const MailGateway: Component = () => {
</div> </div>
<table class="w-full text-xs"> <table class="w-full text-xs">
<tbody class="divide-y divide-gray-200 dark:divide-gray-700"> <tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr> <tr class="animate-enter">
<td class="px-2 py-1.5"> <td class="px-2 py-1.5">
<div class="text-xs text-gray-900 dark:text-gray-100">{formatDec(bytesIn / 1024 / 1024)} MB</div> <div class="text-xs text-gray-900 dark:text-gray-100">{formatDec(bytesIn / 1024 / 1024)} MB</div>
<div class="text-[11px] text-gray-500 dark:text-gray-400"> <div class="text-[11px] text-gray-500 dark:text-gray-400">
@ -198,7 +198,7 @@ const MailGateway: Component = () => {
</td> </td>
<td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Inbound bytes</td> <td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Inbound bytes</td>
</tr> </tr>
<tr> <tr class="animate-enter">
<td class="px-2 py-1.5"> <td class="px-2 py-1.5">
<div class="text-xs text-gray-900 dark:text-gray-100">{formatDec(bytesOut / 1024 / 1024)} MB</div> <div class="text-xs text-gray-900 dark:text-gray-100">{formatDec(bytesOut / 1024 / 1024)} MB</div>
<div class="text-[11px] text-gray-500 dark:text-gray-400"> <div class="text-[11px] text-gray-500 dark:text-gray-400">
@ -223,7 +223,7 @@ const MailGateway: Component = () => {
</div> </div>
<table class="w-full text-xs"> <table class="w-full text-xs">
<tbody class="divide-y divide-gray-200 dark:divide-gray-700"> <tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr> <tr class="animate-enter">
<td class="px-2 py-1.5"> <td class="px-2 py-1.5">
<div class="text-xs font-medium text-orange-600 dark:text-orange-400">{formatNum(qSpam)}</div> <div class="text-xs font-medium text-orange-600 dark:text-orange-400">{formatNum(qSpam)}</div>
<div class="text-[11px] text-gray-500 dark:text-gray-400"> <div class="text-[11px] text-gray-500 dark:text-gray-400">
@ -232,7 +232,7 @@ const MailGateway: Component = () => {
</td> </td>
<td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Spam quarantined</td> <td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Spam quarantined</td>
</tr> </tr>
<tr> <tr class="animate-enter">
<td class="px-2 py-1.5"> <td class="px-2 py-1.5">
<div class="text-xs font-medium text-red-600 dark:text-red-400">{formatNum(qVirus)}</div> <div class="text-xs font-medium text-red-600 dark:text-red-400">{formatNum(qVirus)}</div>
<div class="text-[11px] text-gray-500 dark:text-gray-400"> <div class="text-[11px] text-gray-500 dark:text-gray-400">
@ -241,7 +241,7 @@ const MailGateway: Component = () => {
</td> </td>
<td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Virus quarantined</td> <td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Virus quarantined</td>
</tr> </tr>
<tr> <tr class="animate-enter">
<td class="px-2 py-1.5"> <td class="px-2 py-1.5">
<div class="text-xs font-medium text-yellow-600 dark:text-yellow-400">{formatNum(qAttachment)}</div> <div class="text-xs font-medium text-yellow-600 dark:text-yellow-400">{formatNum(qAttachment)}</div>
<div class="text-[11px] text-gray-500 dark:text-gray-400"> <div class="text-[11px] text-gray-500 dark:text-gray-400">
@ -250,7 +250,7 @@ const MailGateway: Component = () => {
</td> </td>
<td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Attachments blocked</td> <td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Attachments blocked</td>
</tr> </tr>
<tr> <tr class="animate-enter">
<td class="px-2 py-1.5"> <td class="px-2 py-1.5">
<div class="text-xs text-gray-900 dark:text-gray-100">{formatNum(qBlacklist)}</div> <div class="text-xs text-gray-900 dark:text-gray-100">{formatNum(qBlacklist)}</div>
<div class="text-[11px] text-gray-500 dark:text-gray-400"> <div class="text-[11px] text-gray-500 dark:text-gray-400">
@ -259,7 +259,7 @@ const MailGateway: Component = () => {
</td> </td>
<td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Blacklisted</td> <td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Blacklisted</td>
</tr> </tr>
<tr> <tr class="animate-enter">
<td class="px-2 py-1.5"> <td class="px-2 py-1.5">
<div class="text-xs font-semibold text-gray-900 dark:text-gray-100">{formatNum(qTotal)}</div> <div class="text-xs font-semibold text-gray-900 dark:text-gray-100">{formatNum(qTotal)}</div>
<div class="text-[11px] text-gray-500 dark:text-gray-400"> <div class="text-[11px] text-gray-500 dark:text-gray-400">
@ -315,7 +315,7 @@ const MailGateway: Component = () => {
</div> </div>
<table class="w-full text-xs"> <table class="w-full text-xs">
<tbody class="divide-y divide-gray-200 dark:divide-gray-700"> <tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr> <tr class="animate-enter">
<td class="px-2 py-1.5"> <td class="px-2 py-1.5">
<div class="text-xs text-gray-900 dark:text-gray-100">{formatNum(bouncesIn)}</div> <div class="text-xs text-gray-900 dark:text-gray-100">{formatNum(bouncesIn)}</div>
<div class="text-[11px] text-gray-500 dark:text-gray-400"> <div class="text-[11px] text-gray-500 dark:text-gray-400">
@ -324,7 +324,7 @@ const MailGateway: Component = () => {
</td> </td>
<td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Bounces inbound</td> <td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Bounces inbound</td>
</tr> </tr>
<tr> <tr class="animate-enter">
<td class="px-2 py-1.5"> <td class="px-2 py-1.5">
<div class="text-xs text-gray-900 dark:text-gray-100">{formatNum(bouncesOut)}</div> <div class="text-xs text-gray-900 dark:text-gray-100">{formatNum(bouncesOut)}</div>
<div class="text-[11px] text-gray-500 dark:text-gray-400"> <div class="text-[11px] text-gray-500 dark:text-gray-400">
@ -333,7 +333,7 @@ const MailGateway: Component = () => {
</td> </td>
<td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Bounces outbound</td> <td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Bounces outbound</td>
</tr> </tr>
<tr> <tr class="animate-enter">
<td class="px-2 py-1.5"> <td class="px-2 py-1.5">
<div class="text-xs text-gray-900 dark:text-gray-100">{formatNum(rbl)}</div> <div class="text-xs text-gray-900 dark:text-gray-100">{formatNum(rbl)}</div>
<div class="text-[11px] text-gray-500 dark:text-gray-400"> <div class="text-[11px] text-gray-500 dark:text-gray-400">
@ -342,7 +342,7 @@ const MailGateway: Component = () => {
</td> </td>
<td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">RBL rejects</td> <td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">RBL rejects</td>
</tr> </tr>
<tr> <tr class="animate-enter">
<td class="px-2 py-1.5"> <td class="px-2 py-1.5">
<div class="text-xs text-gray-900 dark:text-gray-100">{formatNum(pregreet)}</div> <div class="text-xs text-gray-900 dark:text-gray-100">{formatNum(pregreet)}</div>
<div class="text-[11px] text-gray-500 dark:text-gray-400"> <div class="text-[11px] text-gray-500 dark:text-gray-400">
@ -351,7 +351,7 @@ const MailGateway: Component = () => {
</td> </td>
<td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Pregreet rejects</td> <td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Pregreet rejects</td>
</tr> </tr>
<tr> <tr class="animate-enter">
<td class="px-2 py-1.5"> <td class="px-2 py-1.5">
<div class="text-xs text-gray-900 dark:text-gray-100">{formatNum(greylist)}</div> <div class="text-xs text-gray-900 dark:text-gray-100">{formatNum(greylist)}</div>
<div class="text-[11px] text-gray-500 dark:text-gray-400"> <div class="text-[11px] text-gray-500 dark:text-gray-400">
@ -360,7 +360,7 @@ const MailGateway: Component = () => {
</td> </td>
<td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Greylisted</td> <td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Greylisted</td>
</tr> </tr>
<tr> <tr class="animate-enter">
<td class="px-2 py-1.5"> <td class="px-2 py-1.5">
<div class="text-xs text-gray-900 dark:text-gray-100">{formatNum(junk)}</div> <div class="text-xs text-gray-900 dark:text-gray-100">{formatNum(junk)}</div>
<div class="text-[11px] text-gray-500 dark:text-gray-400"> <div class="text-[11px] text-gray-500 dark:text-gray-400">
@ -369,7 +369,7 @@ const MailGateway: Component = () => {
</td> </td>
<td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Junk mail</td> <td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Junk mail</td>
</tr> </tr>
<tr> <tr class="animate-enter">
<td class="px-2 py-1.5"> <td class="px-2 py-1.5">
<div class="text-xs text-gray-900 dark:text-gray-100"> <div class="text-xs text-gray-900 dark:text-gray-100">
{pmg.mailStats?.averageProcessTimeMs ? formatDec(pmg.mailStats.averageProcessTimeMs / 1000, 2) : '—'} s {pmg.mailStats?.averageProcessTimeMs ? formatDec(pmg.mailStats.averageProcessTimeMs / 1000, 2) : '—'} s
@ -421,7 +421,7 @@ const MailGateway: Component = () => {
const oldestClass = oldestAge > 1800 ? 'text-amber-600 dark:text-amber-400' : 'text-gray-700 dark:text-gray-300'; const oldestClass = oldestAge > 1800 ? 'text-amber-600 dark:text-amber-400' : 'text-gray-700 dark:text-gray-300';
return ( return (
<tr> <tr class="animate-enter">
<td class="px-2 py-1.5 text-xs font-medium text-gray-900 dark:text-gray-100 truncate max-w-[150px]">{node.name}</td> <td class="px-2 py-1.5 text-xs font-medium text-gray-900 dark:text-gray-100 truncate max-w-[150px]">{node.name}</td>
<td class="px-2 py-1.5 text-xs text-gray-700 dark:text-gray-300 capitalize">{node.role || '—'}</td> <td class="px-2 py-1.5 text-xs text-gray-700 dark:text-gray-300 capitalize">{node.role || '—'}</td>
<td class="px-2 py-1.5"> <td class="px-2 py-1.5">
@ -463,7 +463,7 @@ const MailGateway: Component = () => {
</div> </div>
</Show> </Show>
</div> </div>
</div> </Card>
); );
}} }}
</For> </For>

View file

@ -101,7 +101,7 @@ const Replication: Component = () => {
</Card> </Card>
} }
> >
<Card padding="none"> <Card padding="none" tone="glass">
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="min-w-[1000px] w-full divide-y divide-gray-200 dark:divide-gray-700"> <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"> <thead class="bg-gray-50 dark:bg-gray-900/40">
@ -121,7 +121,7 @@ const Replication: Component = () => {
{(job) => { {(job) => {
const badge = getStatusBadge(job); const badge = getStatusBadge(job);
return ( return (
<tr class="hover:bg-gray-50/80 dark:hover:bg-gray-900/40 transition-colors"> <tr class="hover:bg-gray-50/80 dark:hover:bg-gray-900/40 transition-colors animate-enter">
<td class="px-4 py-3"> <td class="px-4 py-3">
<div class="font-medium text-gray-900 dark:text-gray-100 truncate max-w-[200px]"> <div class="font-medium text-gray-900 dark:text-gray-100 truncate max-w-[200px]">
{job.guestName || `VM ${job.guestId ?? ''}`} {job.guestName || `VM ${job.guestId ?? ''}`}

View file

@ -122,26 +122,26 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
label: 'Host agent', label: 'Host agent',
scopes: [HOST_AGENT_SCOPE], scopes: [HOST_AGENT_SCOPE],
description: 'Allow pulse-host-agent to submit OS, CPU, and disk metrics.', description: 'Allow pulse-host-agent to submit OS, CPU, and disk metrics.',
}, },
{ {
label: 'Container report', label: 'Container report',
scopes: [DOCKER_REPORT_SCOPE], scopes: [DOCKER_REPORT_SCOPE],
description: 'Permits container agents (Docker or Podman) to stream host and container telemetry only.', description: 'Permits container agents (Docker or Podman) to stream host and container telemetry only.',
}, },
{ {
label: 'Container manage', label: 'Container manage',
scopes: [DOCKER_REPORT_SCOPE, DOCKER_MANAGE_SCOPE], scopes: [DOCKER_REPORT_SCOPE, DOCKER_MANAGE_SCOPE],
description: 'Extends container reporting with lifecycle actions (restart, stop, etc.).', description: 'Extends container reporting with lifecycle actions (restart, stop, etc.).',
}, },
{ {
label: 'Settings read', label: 'Settings read',
scopes: [SETTINGS_READ_SCOPE], scopes: [SETTINGS_READ_SCOPE],
description: 'Read configuration snapshots and diagnostics without modifying anything.', description: 'Read configuration snapshots and diagnostics without modifying anything.',
}, },
{ {
label: 'Settings admin', label: 'Settings admin',
scopes: [SETTINGS_READ_SCOPE, SETTINGS_WRITE_SCOPE], scopes: [SETTINGS_READ_SCOPE, SETTINGS_WRITE_SCOPE],
description: 'Full settings read/write equivalent to automation with admin privileges.', description: 'Full settings read/write equivalent to automation with admin privileges.',
}, },
]; ];
@ -421,36 +421,32 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
</p> </p>
</div> </div>
<div <div
class={`rounded-lg border p-4 text-sm shadow-sm ${ class={`rounded-lg border p-4 text-sm shadow-sm ${hasWildcardTokens()
hasWildcardTokens()
? 'border-amber-300/80 bg-amber-50/80 text-amber-900 dark:border-amber-700/70 dark:bg-amber-900/20 dark:text-amber-100' ? 'border-amber-300/80 bg-amber-50/80 text-amber-900 dark:border-amber-700/70 dark:bg-amber-900/20 dark:text-amber-100'
: 'border-gray-200/70 bg-white/70 text-gray-700 dark:border-gray-700/70 dark:bg-gray-900/40 dark:text-gray-300' : 'border-gray-200/70 bg-white/70 text-gray-700 dark:border-gray-700/70 dark:bg-gray-900/40 dark:text-gray-300'
}`} }`}
> >
<div <div
class={`text-[0.7rem] font-semibold uppercase tracking-wide ${ class={`text-[0.7rem] font-semibold uppercase tracking-wide ${hasWildcardTokens()
hasWildcardTokens()
? 'text-amber-700 dark:text-amber-300' ? 'text-amber-700 dark:text-amber-300'
: 'text-gray-500 dark:text-gray-400' : 'text-gray-500 dark:text-gray-400'
}`} }`}
> >
Full access tokens Full access tokens
</div> </div>
<div <div
class={`mt-1 text-2xl font-semibold ${ class={`mt-1 text-2xl font-semibold ${hasWildcardTokens()
hasWildcardTokens()
? 'text-amber-800 dark:text-amber-100' ? 'text-amber-800 dark:text-amber-100'
: 'text-gray-900 dark:text-gray-100' : 'text-gray-900 dark:text-gray-100'
}`} }`}
> >
{wildcardCount()} {wildcardCount()}
</div> </div>
<p <p
class={`mt-1 text-xs ${ class={`mt-1 text-xs ${hasWildcardTokens()
hasWildcardTokens()
? 'text-amber-700 dark:text-amber-200' ? 'text-amber-700 dark:text-amber-200'
: 'text-gray-500 dark:text-gray-400' : 'text-gray-500 dark:text-gray-400'
}`} }`}
> >
{hasWildcardTokens() {hasWildcardTokens()
? 'Legacy wildcard tokens rotate into scoped presets when possible.' ? 'Legacy wildcard tokens rotate into scoped presets when possible.'
@ -517,7 +513,7 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
</Card> </Card>
} }
> >
<Card padding="none" class="overflow-hidden border border-gray-200 dark:border-gray-700"> <Card padding="none" tone="glass" class="overflow-hidden">
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-gray-200 bg-gray-50/60 px-5 py-4 dark:border-gray-700 dark:bg-gray-900/40"> <div class="flex flex-wrap items-center justify-between gap-3 border-b border-gray-200 bg-gray-50/60 px-5 py-4 dark:border-gray-700 dark:bg-gray-900/40">
<div> <div>
<h4 class="text-sm font-semibold text-gray-800 dark:text-gray-100">Token inventory</h4> <h4 class="text-sm font-semibold text-gray-800 dark:text-gray-100">Token inventory</h4>
@ -579,18 +575,17 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
const scopeBadges = rawScopes.includes('*') const scopeBadges = rawScopes.includes('*')
? [{ value: '*', label: 'Full' }] ? [{ value: '*', label: 'Full' }]
: rawScopes.map((scope) => ({ : rawScopes.map((scope) => ({
value: scope, value: scope,
label: API_SCOPE_LABELS[scope] ?? scope, label: API_SCOPE_LABELS[scope] ?? scope,
})); }));
const rowIsWildcard = scopeBadges.some((scope) => scope.value === '*'); const rowIsWildcard = scopeBadges.some((scope) => scope.value === '*');
return ( return (
<tr <tr
class={`transition-colors ${ class={`transition-colors animate-enter ${rowIsWildcard
rowIsWildcard
? 'bg-amber-50/50 dark:bg-amber-900/10' ? 'bg-amber-50/50 dark:bg-amber-900/10'
: 'bg-white dark:bg-gray-900/10' : 'bg-white dark:bg-gray-900/10'
} hover:bg-blue-50/40 dark:hover:bg-gray-800/50`} } hover:bg-blue-50/40 dark:hover:bg-gray-800/50`}
> >
<td class="whitespace-nowrap px-5 py-3 font-medium text-gray-900 dark:text-gray-100"> <td class="whitespace-nowrap px-5 py-3 font-medium text-gray-900 dark:text-gray-100">
{token.name || 'Untitled'} {token.name || 'Untitled'}
@ -605,11 +600,10 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
const isWildcard = scope.value === '*'; const isWildcard = scope.value === '*';
return ( return (
<span <span
class={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${ class={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${isWildcard
isWildcard
? 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200' ? 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200'
: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300' : 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300'
}`} }`}
title={scope.value} title={scope.value}
> >
{scope.label} {scope.label}
@ -667,9 +661,8 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
<Card <Card
padding="lg" padding="lg"
class={`border border-gray-200 dark:border-gray-700 transition-shadow ${ class={`border border-gray-200 dark:border-gray-700 transition-shadow ${createHighlight() ? 'ring-2 ring-blue-500/60 shadow-lg' : ''
createHighlight() ? 'ring-2 ring-blue-500/60 shadow-lg' : '' }`}
}`}
ref={(el: HTMLDivElement) => { ref={(el: HTMLDivElement) => {
createSectionRef = el; createSectionRef = el;
}} }}
@ -723,11 +716,10 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<button <button
type="button" type="button"
class={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-semibold transition ${ class={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-semibold transition ${isFullAccessSelected()
isFullAccessSelected()
? 'border-blue-500 bg-blue-600 text-white shadow-sm' ? 'border-blue-500 bg-blue-600 text-white shadow-sm'
: 'border-gray-300 bg-white text-gray-700 hover:border-blue-400 hover:text-blue-600 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-blue-400 dark:hover:text-blue-200' : 'border-gray-300 bg-white text-gray-700 hover:border-blue-400 hover:text-blue-600 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-blue-400 dark:hover:text-blue-200'
}`} }`}
onClick={clearScopes} onClick={clearScopes}
title="Legacy wildcard all permissions" title="Legacy wildcard all permissions"
> >
@ -738,11 +730,10 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
{(preset) => ( {(preset) => (
<button <button
type="button" type="button"
class={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-semibold transition ${ class={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-semibold transition ${presetMatchesSelection(preset.scopes)
presetMatchesSelection(preset.scopes)
? 'border-blue-500 bg-blue-600 text-white shadow-sm' ? 'border-blue-500 bg-blue-600 text-white shadow-sm'
: 'border-gray-300 bg-white text-gray-700 hover:border-blue-400 hover:text-blue-600 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-blue-400 dark:hover:text-blue-200' : 'border-gray-300 bg-white text-gray-700 hover:border-blue-400 hover:text-blue-600 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-blue-400 dark:hover:text-blue-200'
}`} }`}
onClick={() => applyScopePreset(preset.scopes)} onClick={() => applyScopePreset(preset.scopes)}
title={preset.description} title={preset.description}
> >
@ -771,11 +762,10 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
return ( return (
<button <button
type="button" type="button"
class={`rounded-full border px-3 py-1 text-xs font-semibold transition ${ class={`rounded-full border px-3 py-1 text-xs font-semibold transition ${isActive()
isActive()
? 'border-blue-500 bg-blue-600 text-white shadow-sm' ? 'border-blue-500 bg-blue-600 text-white shadow-sm'
: 'border-gray-300 bg-white text-gray-700 hover:border-blue-400 hover:text-blue-600 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-blue-400 dark:hover:text-blue-200' : 'border-gray-300 bg-white text-gray-700 hover:border-blue-400 hover:text-blue-600 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-blue-400 dark:hover:text-blue-200'
}`} }`}
onClick={() => { onClick={() => {
setSelectedScopes((prev) => { setSelectedScopes((prev) => {
if (prev.includes(option.value)) { if (prev.includes(option.value)) {

View file

@ -1,5 +1,6 @@
import { Component, For, Show, createMemo } from 'solid-js'; import { Component, For, Show, createMemo } from 'solid-js';
import type { NodeConfig } from '@/types/nodes'; import type { NodeConfig } from '@/types/nodes';
import { Card } from '@/components/shared/Card';
type NodeConfigWithStatus = NodeConfig & { type NodeConfigWithStatus = NodeConfig & {
hasPassword?: boolean; hasPassword?: boolean;
@ -271,7 +272,7 @@ const resolvePveStatusMeta = (
export const PveNodesTable: Component<PveNodesTableProps> = (props) => { export const PveNodesTable: Component<PveNodesTableProps> = (props) => {
return ( return (
<div class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700"> <Card padding="none" tone="glass" class="overflow-x-auto rounded-lg">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm"> <table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
<thead class="bg-gray-50 dark:bg-gray-800/70"> <thead class="bg-gray-50 dark:bg-gray-800/70">
<tr> <tr>
@ -310,7 +311,7 @@ export const PveNodesTable: Component<PveNodesTableProps> = (props) => {
), ),
); );
return ( return (
<tr class="even:bg-gray-50/60 dark:even:bg-gray-800/30 hover:bg-blue-50/40 dark:hover:bg-blue-900/20 transition-colors"> <tr class="even:bg-gray-50/60 dark:even:bg-gray-800/30 hover:bg-blue-50/40 dark:hover:bg-blue-900/20 transition-colors animate-enter">
<td class="align-top py-3 pl-4 pr-3"> <td class="align-top py-3 pl-4 pr-3">
<div class="min-w-0 space-y-1"> <div class="min-w-0 space-y-1">
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
@ -492,7 +493,7 @@ export const PveNodesTable: Component<PveNodesTableProps> = (props) => {
</For> </For>
</tbody> </tbody>
</table> </table>
</div> </Card>
); );
}; };
@ -541,7 +542,7 @@ const resolvePbsStatusMeta = (
export const PbsNodesTable: Component<PbsNodesTableProps> = (props) => { export const PbsNodesTable: Component<PbsNodesTableProps> = (props) => {
return ( return (
<div class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700"> <Card padding="none" tone="glass" class="overflow-x-auto rounded-lg">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm"> <table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
<thead class="bg-gray-50 dark:bg-gray-800/70"> <thead class="bg-gray-50 dark:bg-gray-800/70">
<tr> <tr>
@ -567,7 +568,7 @@ export const PbsNodesTable: Component<PbsNodesTableProps> = (props) => {
{(node) => { {(node) => {
const statusMeta = createMemo(() => resolvePbsStatusMeta(node, props.statePbs)); const statusMeta = createMemo(() => resolvePbsStatusMeta(node, props.statePbs));
return ( return (
<tr class="even:bg-gray-50/60 dark:even:bg-gray-800/30 hover:bg-blue-50/40 dark:hover:bg-blue-900/20 transition-colors"> <tr class="even:bg-gray-50/60 dark:even:bg-gray-800/30 hover:bg-blue-50/40 dark:hover:bg-blue-900/20 transition-colors animate-enter">
<td class="align-top py-3 pl-4 pr-3"> <td class="align-top py-3 pl-4 pr-3">
<div class="min-w-0 space-y-1"> <div class="min-w-0 space-y-1">
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
@ -675,7 +676,7 @@ export const PbsNodesTable: Component<PbsNodesTableProps> = (props) => {
</For> </For>
</tbody> </tbody>
</table> </table>
</div> </Card>
); );
}; };
@ -724,7 +725,7 @@ const resolvePmgStatusMeta = (
export const PmgNodesTable: Component<PmgNodesTableProps> = (props) => { export const PmgNodesTable: Component<PmgNodesTableProps> = (props) => {
return ( return (
<div class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700"> <Card padding="none" tone="glass" class="overflow-x-auto rounded-lg">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm"> <table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
<thead class="bg-gray-50 dark:bg-gray-800/70"> <thead class="bg-gray-50 dark:bg-gray-800/70">
<tr> <tr>
@ -750,7 +751,7 @@ export const PmgNodesTable: Component<PmgNodesTableProps> = (props) => {
{(node) => { {(node) => {
const statusMeta = createMemo(() => resolvePmgStatusMeta(node, props.statePmg)); const statusMeta = createMemo(() => resolvePmgStatusMeta(node, props.statePmg));
return ( return (
<tr class="even:bg-gray-50/60 dark:even:bg-gray-800/30 hover:bg-blue-50/40 dark:hover:bg-blue-900/20 transition-colors"> <tr class="even:bg-gray-50/60 dark:even:bg-gray-800/30 hover:bg-blue-50/40 dark:hover:bg-blue-900/20 transition-colors animate-enter">
<td class="align-top py-3 pl-4 pr-3"> <td class="align-top py-3 pl-4 pr-3">
<div class="min-w-0 space-y-1"> <div class="min-w-0 space-y-1">
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
@ -847,6 +848,6 @@ export const PmgNodesTable: Component<PmgNodesTableProps> = (props) => {
</For> </For>
</tbody> </tbody>
</table> </table>
</div> </Card>
); );
}; };

View file

@ -635,7 +635,7 @@ export const UnifiedAgents: Component = () => {
</div> </div>
</Show> </Show>
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700"> <Card padding="none" tone="glass" class="overflow-hidden rounded-lg">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700"> <table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800"> <thead class="bg-gray-50 dark:bg-gray-800">
<tr> <tr>
@ -656,7 +656,7 @@ export const UnifiedAgents: Component = () => {
</tr> </tr>
}> }>
{(agent) => ( {(agent) => (
<tr> <tr class="animate-enter">
<td class="whitespace-nowrap px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100"> <td class="whitespace-nowrap px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100">
{agent.displayName || agent.hostname} {agent.displayName || agent.hostname}
<Show when={agent.displayName && agent.displayName !== agent.hostname}> <Show when={agent.displayName && agent.displayName !== agent.hostname}>
@ -709,7 +709,7 @@ export const UnifiedAgents: Component = () => {
</For> </For>
</tbody> </tbody>
</table> </table>
</div> </Card>
</Card> </Card>
</div > </div >
); );

View file

@ -149,7 +149,7 @@ export const DiskList: Component<DiskListProps> = (props) => {
</Show> </Show>
<Show when={filteredDisks().length > 0}> <Show when={filteredDisks().length > 0}>
<Card padding="none" class="overflow-hidden"> <Card padding="none" tone="glass" class="overflow-hidden">
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full"> <table class="w-full">
<thead> <thead>
@ -191,7 +191,7 @@ export const DiskList: Component<DiskListProps> = (props) => {
return ( return (
<> <>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors"> <tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors animate-enter">
<td class="px-2 py-1.5 text-xs"> <td class="px-2 py-1.5 text-xs">
<span class="font-medium text-gray-900 dark:text-gray-100"> <span class="font-medium text-gray-900 dark:text-gray-100">
{disk.node} {disk.node}

View file

@ -742,7 +742,7 @@ const Storage: Component = () => {
{/* Storage Table - shows for both PVE and PBS storage */} {/* Storage Table - shows for both PVE and PBS storage */}
<Show when={connected() && initialDataReceived() && sortedStorage().length > 0}> <Show when={connected() && initialDataReceived() && sortedStorage().length > 0}>
<ComponentErrorBoundary name="Storage Table"> <ComponentErrorBoundary name="Storage Table">
<Card padding="none" class="mb-4 overflow-hidden"> <Card padding="none" tone="glass" class="mb-4 overflow-hidden">
<div class="overflow-x-auto" style="scrollbar-width: none; -ms-overflow-style: none;"> <div class="overflow-x-auto" style="scrollbar-width: none; -ms-overflow-style: none;">
<style>{` <style>{`
.overflow-x-auto::-webkit-scrollbar { display: none; } .overflow-x-auto::-webkit-scrollbar { display: none; }
@ -1043,7 +1043,7 @@ const Storage: Component = () => {
return ( return (
<> <>
<tr <tr
class={`${rowClass()} transition-colors`} class={`${rowClass()} transition-colors animate-enter`}
style={rowStyle()} style={rowStyle()}
onClick={toggleDrawer} onClick={toggleDrawer}
aria-expanded={canExpand() && isExpanded() ? 'true' : 'false'} aria-expanded={canExpand() && isExpanded() ? 'true' : 'false'}

View file

@ -1,6 +1,6 @@
import { JSX, splitProps, mergeProps } from 'solid-js'; import { JSX, splitProps, mergeProps } from 'solid-js';
type Tone = 'default' | 'muted' | 'info' | 'success' | 'warning' | 'danger'; type Tone = 'default' | 'muted' | 'info' | 'success' | 'warning' | 'danger' | 'glass';
type Padding = 'none' | 'sm' | 'md' | 'lg'; type Padding = 'none' | 'sm' | 'md' | 'lg';
type CardProps = { type CardProps = {
@ -17,6 +17,7 @@ const toneClassMap: Record<Tone, string> = {
success: 'bg-green-50/70 dark:bg-green-900/20', success: 'bg-green-50/70 dark:bg-green-900/20',
warning: 'bg-amber-50/80 dark:bg-amber-900/20', warning: 'bg-amber-50/80 dark:bg-amber-900/20',
danger: 'bg-red-50/80 dark:bg-red-900/20', danger: 'bg-red-50/80 dark:bg-red-900/20',
glass: 'glass',
}; };
const paddingClassMap: Record<Padding, string> = { const paddingClassMap: Record<Padding, string> = {

View file

@ -498,7 +498,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
}; };
return ( return (
<Card padding="none" class="mb-4 overflow-hidden"> <Card padding="none" tone="glass" class="mb-4 overflow-hidden">
<div> <div>
{/* Header */} {/* Header */}
<div <div
@ -569,7 +569,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
}); });
const rowClass = createMemo(() => { const rowClass = createMemo(() => {
const baseHover = 'cursor-pointer transition-all duration-200 relative hover:shadow-sm group'; const baseHover = 'cursor-pointer transition-all duration-200 relative hover:shadow-sm group animate-enter';
if (isSelected()) { if (isSelected()) {
return `cursor-pointer transition-all duration-200 relative hover:shadow-sm z-10 group`; return `cursor-pointer transition-all duration-200 relative hover:shadow-sm z-10 group`;

View file

@ -14,9 +14,9 @@ interface StatusDotProps {
} }
const VARIANT_CLASSES: Record<StatusIndicatorVariant, string> = { const VARIANT_CLASSES: Record<StatusIndicatorVariant, string> = {
success: 'bg-emerald-500 dark:bg-emerald-400', success: 'bg-emerald-500 dark:bg-emerald-400 glow-success',
warning: 'bg-amber-500 dark:bg-amber-400', warning: 'bg-amber-500 dark:bg-amber-400 glow-warning',
danger: 'bg-red-500 dark:bg-red-400', danger: 'bg-red-500 dark:bg-red-400 glow-danger',
muted: 'bg-gray-400 dark:bg-gray-500', muted: 'bg-gray-400 dark:bg-gray-500',
}; };

View file

@ -478,4 +478,51 @@ body,
.delay-300 { .delay-300 {
animation-delay: 300ms; animation-delay: 300ms;
}
/* Glassmorphism Utilities */
.glass {
@apply bg-white/80 dark:bg-gray-800/80 backdrop-blur-md border border-white/20 dark:border-gray-700/30 shadow-sm;
}
.glass-panel {
@apply bg-white/60 dark:bg-gray-900/60 backdrop-blur-xl border border-gray-200/50 dark:border-gray-700/50;
}
.glass-hover {
@apply hover:bg-white/90 dark:hover:bg-gray-800/90 transition-colors duration-200;
}
/* Staggered Entry Animation */
@keyframes enter {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-enter {
animation: enter 0.4s cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
}
/* Glow effects for status dots */
.glow-success {
box-shadow: 0 0 8px rgba(16, 185, 129, 0.4);
}
.glow-warning {
box-shadow: 0 0 8px rgba(245, 158, 11, 0.4);
}
.glow-danger {
box-shadow: 0 0 8px rgba(239, 68, 68, 0.4);
}
.glow-info {
box-shadow: 0 0 8px rgba(59, 130, 246, 0.4);
} }