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
class={`text-sm ${isDisabledMetric
? 'text-gray-400 dark:text-gray-500 italic'
: metricProps.isOverridden
? 'text-gray-900 dark:text-gray-100 font-bold'
: 'text-gray-900 dark:text-gray-100'
? 'text-gray-400 dark:text-gray-500 italic'
: metricProps.isOverridden
? 'text-gray-900 dark:text-gray-100 font-bold'
: 'text-gray-900 dark:text-gray-100'
}`}
>
{displayText}
@ -483,6 +483,7 @@ export function ResourceTable(props: ResourceTableProps) {
padding="none"
class="overflow-hidden border border-gray-200 dark:border-gray-700"
border={false}
tone="glass"
>
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<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()
? '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-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'
}`}
title={
isOff()
@ -868,7 +869,7 @@ export function ResourceTable(props: ResourceTableProps) {
return (
<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 */}
<td class="p-1 px-2 text-center align-middle">
@ -940,8 +941,8 @@ export function ResourceTable(props: ResourceTableProps) {
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
class={`text-sm font-medium truncate flex-nowrap transition-colors duration-150 ${resource.disabled
? '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-500 dark:text-gray-500'
: 'text-gray-900 dark:text-gray-100 hover:text-sky-600 dark:hover:text-sky-400'
}`}
title={`Open ${resource.displayName || resource.name} web interface`}
>
@ -1148,8 +1149,8 @@ export function ResourceTable(props: ResourceTableProps) {
setActiveMetricInput(null);
}}
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-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 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'
}`}
/>
</div>
@ -1379,7 +1380,7 @@ export function ResourceTable(props: ResourceTableProps) {
return (
<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 */}
<td class="p-1 px-2 text-center align-middle">
@ -1456,8 +1457,8 @@ export function ResourceTable(props: ResourceTableProps) {
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
class={`text-sm font-medium truncate flex-nowrap transition-colors duration-150 ${resource.disabled
? '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-500 dark:text-gray-500'
: 'text-gray-900 dark:text-gray-100 hover:text-sky-600 dark:hover:text-sky-400'
}`}
title={`Open ${resource.displayName || resource.name} web interface`}
>
@ -1600,8 +1601,8 @@ export function ResourceTable(props: ResourceTableProps) {
setActiveMetricInput(null);
}}
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-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 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'
}`}
/>
</div>

View file

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

View file

@ -1864,7 +1864,7 @@ const UnifiedBackups: Component = () => {
/>
{/* 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;">
<style>{`
.overflow-x-auto::-webkit-scrollbar { display: none; }
@ -2138,7 +2138,7 @@ const UnifiedBackups: Component = () => {
</tr>
<For each={group.items}>
{(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 px-1.5 align-middle">
<span

View file

@ -929,165 +929,165 @@ export function Dashboard(props: DashboardProps) {
{/* Table View */}
<Show when={connected() && initialDataReceived() && filteredGuests().length > 0}>
<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">
{/* 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 */}
{/* Desktop 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')}
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 {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>
{/* 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' ? ' ▲' : ' ▼')}
{/* 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>
{/* 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>
</Card>
</ComponentErrorBoundary>

View file

@ -455,7 +455,7 @@ export function GuestRow(props: GuestRowProps) {
});
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 alertBg = hasUnacknowledgedAlert()
? props.alertStyles?.severity === 'critical'

View file

@ -142,7 +142,7 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
};
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">
<table class="w-full table-fixed border-collapse sm:whitespace-nowrap">
<thead>
@ -227,7 +227,7 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
};
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) {
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 (
<>
<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() }}
onClick={toggle}
aria-expanded={expanded()}
@ -1949,7 +1949,7 @@ const DockerServiceRow: Component<{
return (
<>
<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() }}
onClick={toggle}
aria-expanded={expanded()}
@ -2407,7 +2407,7 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
</Card>
}
>
<Card padding="none" class="overflow-hidden">
<Card padding="none" tone="glass" class="overflow-hidden">
<div class="overflow-x-auto">
{/* Header Row */}
<div

View file

@ -170,7 +170,7 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
</Card>
}
>
<Card padding="none" class="overflow-hidden">
<Card padding="none" tone="glass" class="overflow-hidden">
<ScrollableTable>
<table class="w-full border-collapse">
<thead>
@ -243,7 +243,7 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
});
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 clickable = hasDrawerContent() ? 'cursor-pointer' : '';
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;
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 */}
<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">
@ -133,14 +133,14 @@ const MailGateway: Component = () => {
</div>
<table class="w-full text-xs">
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr>
<tr class="animate-enter">
<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-[11px] text-gray-500 dark:text-gray-400">{formatDec(total / 24)}/hr</div>
</td>
<td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Total processed</td>
</tr>
<tr>
<tr class="animate-enter">
<td class="px-2 py-1.5">
<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">
@ -149,7 +149,7 @@ const MailGateway: Component = () => {
</td>
<td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Inbound</td>
</tr>
<tr>
<tr class="animate-enter">
<td class="px-2 py-1.5">
<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">
@ -158,7 +158,7 @@ const MailGateway: Component = () => {
</td>
<td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Outbound</td>
</tr>
<tr>
<tr class="animate-enter">
<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-[11px] text-gray-500 dark:text-gray-400">
@ -167,7 +167,7 @@ const MailGateway: Component = () => {
</td>
<td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Spam caught</td>
</tr>
<tr>
<tr class="animate-enter">
<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-[11px] text-gray-500 dark:text-gray-400">
@ -189,7 +189,7 @@ const MailGateway: Component = () => {
</div>
<table class="w-full text-xs">
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr>
<tr class="animate-enter">
<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-[11px] text-gray-500 dark:text-gray-400">
@ -198,7 +198,7 @@ const MailGateway: Component = () => {
</td>
<td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Inbound bytes</td>
</tr>
<tr>
<tr class="animate-enter">
<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-[11px] text-gray-500 dark:text-gray-400">
@ -223,7 +223,7 @@ const MailGateway: Component = () => {
</div>
<table class="w-full text-xs">
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr>
<tr class="animate-enter">
<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-[11px] text-gray-500 dark:text-gray-400">
@ -232,7 +232,7 @@ const MailGateway: Component = () => {
</td>
<td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Spam quarantined</td>
</tr>
<tr>
<tr class="animate-enter">
<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-[11px] text-gray-500 dark:text-gray-400">
@ -241,7 +241,7 @@ const MailGateway: Component = () => {
</td>
<td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Virus quarantined</td>
</tr>
<tr>
<tr class="animate-enter">
<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-[11px] text-gray-500 dark:text-gray-400">
@ -250,7 +250,7 @@ const MailGateway: Component = () => {
</td>
<td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Attachments blocked</td>
</tr>
<tr>
<tr class="animate-enter">
<td class="px-2 py-1.5">
<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">
@ -259,7 +259,7 @@ const MailGateway: Component = () => {
</td>
<td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Blacklisted</td>
</tr>
<tr>
<tr class="animate-enter">
<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-[11px] text-gray-500 dark:text-gray-400">
@ -315,7 +315,7 @@ const MailGateway: Component = () => {
</div>
<table class="w-full text-xs">
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr>
<tr class="animate-enter">
<td class="px-2 py-1.5">
<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">
@ -324,7 +324,7 @@ const MailGateway: Component = () => {
</td>
<td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Bounces inbound</td>
</tr>
<tr>
<tr class="animate-enter">
<td class="px-2 py-1.5">
<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">
@ -333,7 +333,7 @@ const MailGateway: Component = () => {
</td>
<td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Bounces outbound</td>
</tr>
<tr>
<tr class="animate-enter">
<td class="px-2 py-1.5">
<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">
@ -342,7 +342,7 @@ const MailGateway: Component = () => {
</td>
<td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">RBL rejects</td>
</tr>
<tr>
<tr class="animate-enter">
<td class="px-2 py-1.5">
<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">
@ -351,7 +351,7 @@ const MailGateway: Component = () => {
</td>
<td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Pregreet rejects</td>
</tr>
<tr>
<tr class="animate-enter">
<td class="px-2 py-1.5">
<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">
@ -360,7 +360,7 @@ const MailGateway: Component = () => {
</td>
<td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Greylisted</td>
</tr>
<tr>
<tr class="animate-enter">
<td class="px-2 py-1.5">
<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">
@ -369,7 +369,7 @@ const MailGateway: Component = () => {
</td>
<td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Junk mail</td>
</tr>
<tr>
<tr class="animate-enter">
<td class="px-2 py-1.5">
<div class="text-xs text-gray-900 dark:text-gray-100">
{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';
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 text-gray-700 dark:text-gray-300 capitalize">{node.role || '—'}</td>
<td class="px-2 py-1.5">
@ -463,7 +463,7 @@ const MailGateway: Component = () => {
</div>
</Show>
</div>
</div>
</Card>
);
}}
</For>

View file

@ -101,7 +101,7 @@ const Replication: Component = () => {
</Card>
}
>
<Card padding="none">
<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">
@ -121,7 +121,7 @@ const Replication: Component = () => {
{(job) => {
const badge = getStatusBadge(job);
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">
<div class="font-medium text-gray-900 dark:text-gray-100 truncate max-w-[200px]">
{job.guestName || `VM ${job.guestId ?? ''}`}

View file

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

View file

@ -1,5 +1,6 @@
import { Component, For, Show, createMemo } from 'solid-js';
import type { NodeConfig } from '@/types/nodes';
import { Card } from '@/components/shared/Card';
type NodeConfigWithStatus = NodeConfig & {
hasPassword?: boolean;
@ -271,7 +272,7 @@ const resolvePveStatusMeta = (
export const PveNodesTable: Component<PveNodesTableProps> = (props) => {
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">
<thead class="bg-gray-50 dark:bg-gray-800/70">
<tr>
@ -310,7 +311,7 @@ export const PveNodesTable: Component<PveNodesTableProps> = (props) => {
),
);
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">
<div class="min-w-0 space-y-1">
<div class="flex items-start gap-3">
@ -492,7 +493,7 @@ export const PveNodesTable: Component<PveNodesTableProps> = (props) => {
</For>
</tbody>
</table>
</div>
</Card>
);
};
@ -541,7 +542,7 @@ const resolvePbsStatusMeta = (
export const PbsNodesTable: Component<PbsNodesTableProps> = (props) => {
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">
<thead class="bg-gray-50 dark:bg-gray-800/70">
<tr>
@ -567,7 +568,7 @@ export const PbsNodesTable: Component<PbsNodesTableProps> = (props) => {
{(node) => {
const statusMeta = createMemo(() => resolvePbsStatusMeta(node, props.statePbs));
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">
<div class="min-w-0 space-y-1">
<div class="flex items-start gap-3">
@ -675,7 +676,7 @@ export const PbsNodesTable: Component<PbsNodesTableProps> = (props) => {
</For>
</tbody>
</table>
</div>
</Card>
);
};
@ -724,7 +725,7 @@ const resolvePmgStatusMeta = (
export const PmgNodesTable: Component<PmgNodesTableProps> = (props) => {
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">
<thead class="bg-gray-50 dark:bg-gray-800/70">
<tr>
@ -750,7 +751,7 @@ export const PmgNodesTable: Component<PmgNodesTableProps> = (props) => {
{(node) => {
const statusMeta = createMemo(() => resolvePmgStatusMeta(node, props.statePmg));
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">
<div class="min-w-0 space-y-1">
<div class="flex items-start gap-3">
@ -847,6 +848,6 @@ export const PmgNodesTable: Component<PmgNodesTableProps> = (props) => {
</For>
</tbody>
</table>
</div>
</Card>
);
};

View file

@ -635,7 +635,7 @@ export const UnifiedAgents: Component = () => {
</div>
</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">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr>
@ -656,7 +656,7 @@ export const UnifiedAgents: Component = () => {
</tr>
}>
{(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">
{agent.displayName || agent.hostname}
<Show when={agent.displayName && agent.displayName !== agent.hostname}>
@ -709,7 +709,7 @@ export const UnifiedAgents: Component = () => {
</For>
</tbody>
</table>
</div>
</Card>
</Card>
</div >
);

View file

@ -149,7 +149,7 @@ export const DiskList: Component<DiskListProps> = (props) => {
</Show>
<Show when={filteredDisks().length > 0}>
<Card padding="none" class="overflow-hidden">
<Card padding="none" tone="glass" class="overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead>
@ -191,7 +191,7 @@ export const DiskList: Component<DiskListProps> = (props) => {
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">
<span class="font-medium text-gray-900 dark:text-gray-100">
{disk.node}

View file

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

View file

@ -1,6 +1,6 @@
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 CardProps = {
@ -17,6 +17,7 @@ const toneClassMap: Record<Tone, string> = {
success: 'bg-green-50/70 dark:bg-green-900/20',
warning: 'bg-amber-50/80 dark:bg-amber-900/20',
danger: 'bg-red-50/80 dark:bg-red-900/20',
glass: 'glass',
};
const paddingClassMap: Record<Padding, string> = {

View file

@ -498,7 +498,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
};
return (
<Card padding="none" class="mb-4 overflow-hidden">
<Card padding="none" tone="glass" class="mb-4 overflow-hidden">
<div>
{/* Header */}
<div
@ -569,7 +569,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
});
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()) {
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> = {
success: 'bg-emerald-500 dark:bg-emerald-400',
warning: 'bg-amber-500 dark:bg-amber-400',
danger: 'bg-red-500 dark:bg-red-400',
success: 'bg-emerald-500 dark:bg-emerald-400 glow-success',
warning: 'bg-amber-500 dark:bg-amber-400 glow-warning',
danger: 'bg-red-500 dark:bg-red-400 glow-danger',
muted: 'bg-gray-400 dark:bg-gray-500',
};

View file

@ -478,4 +478,51 @@ body,
.delay-300 {
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);
}