feat(ui): apply glassmorphism and staggered animations to tables
This commit is contained in:
parent
e9ac1429c0
commit
185476750d
19 changed files with 405 additions and 368 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' : '';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 ?? ''}`}
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 >
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in a new issue