Enhance table responsiveness across multiple components
This commit is contained in:
parent
d36b4ce6c8
commit
c021dc6ca5
9 changed files with 848 additions and 884 deletions
|
|
@ -397,13 +397,12 @@ export function ResourceTable(props: ResourceTableProps) {
|
||||||
title={isDisabledMetric ? 'Disabled (no alerts for this metric)' : ''}
|
title={isDisabledMetric ? 'Disabled (no alerts for this metric)' : ''}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class={`text-sm ${
|
class={`text-sm ${isDisabledMetric
|
||||||
isDisabledMetric
|
|
||||||
? 'text-gray-400 dark:text-gray-500 italic'
|
? 'text-gray-400 dark:text-gray-500 italic'
|
||||||
: metricProps.isOverridden
|
: metricProps.isOverridden
|
||||||
? 'text-gray-900 dark:text-gray-100 font-bold'
|
? 'text-gray-900 dark:text-gray-100 font-bold'
|
||||||
: 'text-gray-900 dark:text-gray-100'
|
: 'text-gray-900 dark:text-gray-100'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{displayText}
|
{displayText}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -523,7 +522,7 @@ export function ResourceTable(props: ResourceTableProps) {
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
{/* Global Defaults Row */}
|
{/* Global Defaults Row */}
|
||||||
<Show
|
<Show
|
||||||
when={props.globalDefaults && props.setGlobalDefaults && props.setHasUnsavedChanges}
|
when={props.globalDefaults && props.setGlobalDefaults && props.setHasUnsavedChanges}
|
||||||
>
|
>
|
||||||
<tr
|
<tr
|
||||||
|
|
@ -591,11 +590,10 @@ export function ResourceTable(props: ResourceTableProps) {
|
||||||
props.setHasUnsavedChanges(true);
|
props.setHasUnsavedChanges(true);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
class={`w-16 px-2 py-0.5 text-sm text-center border rounded ${
|
class={`w-16 px-2 py-0.5 text-sm text-center border rounded ${isOff()
|
||||||
isOff()
|
|
||||||
? 'border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500 italic placeholder:text-gray-400 dark:placeholder:text-gray-500 placeholder:opacity-60 pointer-events-none'
|
? 'border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500 italic placeholder:text-gray-400 dark:placeholder:text-gray-500 placeholder:opacity-60 pointer-events-none'
|
||||||
: 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500'
|
: 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500'
|
||||||
}`}
|
}`}
|
||||||
title={
|
title={
|
||||||
isOff()
|
isOff()
|
||||||
? 'Click to enable this metric'
|
? 'Click to enable this metric'
|
||||||
|
|
@ -908,26 +906,26 @@ export function ResourceTable(props: ResourceTableProps) {
|
||||||
</Show>
|
</Show>
|
||||||
</td>
|
</td>
|
||||||
<td class="p-1 px-2">
|
<td class="p-1 px-2">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
<Show
|
<Show
|
||||||
when={resource.type === 'node'}
|
when={resource.type === 'node'}
|
||||||
fallback={
|
fallback={
|
||||||
<span
|
<span
|
||||||
class={`text-sm font-medium ${resource.disabled ? 'text-gray-500 dark:text-gray-500' : 'text-gray-900 dark:text-gray-100'}`}
|
class={`text-sm font-medium truncate flex-nowrap ${resource.disabled ? 'text-gray-500 dark:text-gray-500' : 'text-gray-900 dark:text-gray-100'}`}
|
||||||
>
|
>
|
||||||
{resource.name}
|
{resource.name}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex flex-wrap items-center gap-3"
|
class="flex items-center gap-3 min-w-0"
|
||||||
title={resource.status || undefined}
|
title={resource.status || undefined}
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={resource.host}
|
when={resource.host}
|
||||||
fallback={
|
fallback={
|
||||||
<span
|
<span
|
||||||
class={`text-sm font-medium ${resource.disabled ? 'text-gray-500 dark:text-gray-500' : 'text-gray-900 dark:text-gray-100'}`}
|
class={`text-sm font-medium truncate flex-nowrap ${resource.disabled ? 'text-gray-500 dark:text-gray-500' : 'text-gray-900 dark:text-gray-100'}`}
|
||||||
>
|
>
|
||||||
{resource.type === 'node'
|
{resource.type === 'node'
|
||||||
? resource.name
|
? resource.name
|
||||||
|
|
@ -941,11 +939,10 @@ export function ResourceTable(props: ResourceTableProps) {
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
class={`text-sm font-medium transition-colors duration-150 ${
|
class={`text-sm font-medium truncate flex-nowrap transition-colors duration-150 ${resource.disabled
|
||||||
resource.disabled
|
|
||||||
? 'text-gray-500 dark:text-gray-500'
|
? 'text-gray-500 dark:text-gray-500'
|
||||||
: 'text-gray-900 dark:text-gray-100 hover:text-sky-600 dark:hover:text-sky-400'
|
: 'text-gray-900 dark:text-gray-100 hover:text-sky-600 dark:hover:text-sky-400'
|
||||||
}`}
|
}`}
|
||||||
title={`Open ${resource.displayName || resource.name} web interface`}
|
title={`Open ${resource.displayName || resource.name} web interface`}
|
||||||
>
|
>
|
||||||
{resource.type === 'node'
|
{resource.type === 'node'
|
||||||
|
|
@ -1052,9 +1049,9 @@ export function ResourceTable(props: ResourceTableProps) {
|
||||||
const sliderMax =
|
const sliderMax =
|
||||||
metric === 'temperature'
|
metric === 'temperature'
|
||||||
? Math.max(
|
? Math.max(
|
||||||
sliderMin,
|
sliderMin,
|
||||||
bounds.max > 0 ? bounds.max : 120,
|
bounds.max > 0 ? bounds.max : 120,
|
||||||
)
|
)
|
||||||
: bounds.max;
|
: bounds.max;
|
||||||
const defaultSliderValue = () => {
|
const defaultSliderValue = () => {
|
||||||
if (metric === 'disk') return 90;
|
if (metric === 'disk') return 90;
|
||||||
|
|
@ -1118,7 +1115,7 @@ export function ResourceTable(props: ResourceTableProps) {
|
||||||
if (
|
if (
|
||||||
isEditing() &&
|
isEditing() &&
|
||||||
activeMetricInput()?.resourceId ===
|
activeMetricInput()?.resourceId ===
|
||||||
resource.id &&
|
resource.id &&
|
||||||
activeMetricInput()?.metric === metric
|
activeMetricInput()?.metric === metric
|
||||||
) {
|
) {
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
|
|
@ -1150,11 +1147,10 @@ export function ResourceTable(props: ResourceTableProps) {
|
||||||
}
|
}
|
||||||
setActiveMetricInput(null);
|
setActiveMetricInput(null);
|
||||||
}}
|
}}
|
||||||
class={`w-16 px-2 py-0.5 text-sm text-center border rounded ${
|
class={`w-16 px-2 py-0.5 text-sm text-center border rounded ${isDisabled()
|
||||||
isDisabled()
|
|
||||||
? 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-600 border-gray-300 dark:border-gray-600'
|
? 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-600 border-gray-300 dark:border-gray-600'
|
||||||
: 'bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border-gray-300 dark:border-gray-600'
|
: 'bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border-gray-300 dark:border-gray-600'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1423,9 +1419,9 @@ export function ResourceTable(props: ResourceTableProps) {
|
||||||
<Show
|
<Show
|
||||||
when={resource.type === 'node'}
|
when={resource.type === 'node'}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
<span
|
<span
|
||||||
class={`text-sm font-medium ${resource.disabled ? 'text-gray-500 dark:text-gray-500' : 'text-gray-900 dark:text-gray-100'}`}
|
class={`text-sm font-medium truncate flex-nowrap ${resource.disabled ? 'text-gray-500 dark:text-gray-500' : 'text-gray-900 dark:text-gray-100'}`}
|
||||||
>
|
>
|
||||||
{resource.name}
|
{resource.name}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -1438,14 +1434,14 @@ export function ResourceTable(props: ResourceTableProps) {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex flex-wrap items-center gap-3"
|
class="flex items-center gap-3 min-w-0"
|
||||||
title={resource.status || undefined}
|
title={resource.status || undefined}
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={resource.host}
|
when={resource.host}
|
||||||
fallback={
|
fallback={
|
||||||
<span
|
<span
|
||||||
class={`text-sm font-medium ${resource.disabled ? 'text-gray-500 dark:text-gray-500' : 'text-gray-900 dark:text-gray-100'}`}
|
class={`text-sm font-medium truncate flex-nowrap ${resource.disabled ? 'text-gray-500 dark:text-gray-500' : 'text-gray-900 dark:text-gray-100'}`}
|
||||||
>
|
>
|
||||||
{resource.type === 'node'
|
{resource.type === 'node'
|
||||||
? resource.name
|
? resource.name
|
||||||
|
|
@ -1459,11 +1455,10 @@ export function ResourceTable(props: ResourceTableProps) {
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
class={`text-sm font-medium transition-colors duration-150 ${
|
class={`text-sm font-medium truncate flex-nowrap transition-colors duration-150 ${resource.disabled
|
||||||
resource.disabled
|
|
||||||
? 'text-gray-500 dark:text-gray-500'
|
? 'text-gray-500 dark:text-gray-500'
|
||||||
: 'text-gray-900 dark:text-gray-100 hover:text-sky-600 dark:hover:text-sky-400'
|
: 'text-gray-900 dark:text-gray-100 hover:text-sky-600 dark:hover:text-sky-400'
|
||||||
}`}
|
}`}
|
||||||
title={`Open ${resource.displayName || resource.name} web interface`}
|
title={`Open ${resource.displayName || resource.name} web interface`}
|
||||||
>
|
>
|
||||||
{resource.type === 'node'
|
{resource.type === 'node'
|
||||||
|
|
@ -1518,12 +1513,12 @@ export function ResourceTable(props: ResourceTableProps) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setActiveMetricInput({ resourceId: resource.id, metric });
|
setActiveMetricInput({ resourceId: resource.id, metric });
|
||||||
props.onEdit(
|
props.onEdit(
|
||||||
resource.id,
|
resource.id,
|
||||||
resource.thresholds ? { ...resource.thresholds } : {},
|
resource.thresholds ? { ...resource.thresholds } : {},
|
||||||
resource.defaults ? { ...resource.defaults } : {},
|
resource.defaults ? { ...resource.defaults } : {},
|
||||||
typeof resource.note === 'string' ? resource.note : undefined,
|
typeof resource.note === 'string' ? resource.note : undefined,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -1559,9 +1554,9 @@ export function ResourceTable(props: ResourceTableProps) {
|
||||||
min="-1"
|
min="-1"
|
||||||
max={
|
max={
|
||||||
metric.includes('disk') ||
|
metric.includes('disk') ||
|
||||||
metric.includes('memory') ||
|
metric.includes('memory') ||
|
||||||
metric.includes('cpu') ||
|
metric.includes('cpu') ||
|
||||||
metric === 'usage'
|
metric === 'usage'
|
||||||
? 100
|
? 100
|
||||||
: 10000
|
: 10000
|
||||||
}
|
}
|
||||||
|
|
@ -1604,11 +1599,10 @@ export function ResourceTable(props: ResourceTableProps) {
|
||||||
}
|
}
|
||||||
setActiveMetricInput(null);
|
setActiveMetricInput(null);
|
||||||
}}
|
}}
|
||||||
class={`w-16 px-2 py-0.5 text-sm text-center border rounded ${
|
class={`w-16 px-2 py-0.5 text-sm text-center border rounded ${isDisabled()
|
||||||
isDisabled()
|
|
||||||
? 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-600 border-gray-300 dark:border-gray-600'
|
? 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-600 border-gray-300 dark:border-gray-600'
|
||||||
: 'bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border-gray-300 dark:border-gray-600'
|
: 'bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border-gray-300 dark:border-gray-600'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
@ -1695,7 +1689,7 @@ export function ResourceTable(props: ResourceTableProps) {
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
props.onEdit(
|
props.onEdit(
|
||||||
resource.id,
|
resource.id,
|
||||||
resource.thresholds ? { ...resource.thresholds } : {},
|
resource.thresholds ? { ...resource.thresholds } : {},
|
||||||
|
|
|
||||||
|
|
@ -210,7 +210,6 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
|
||||||
const selected = props.selectedHostId() === summary.host.id;
|
const selected = props.selectedHostId() === summary.host.id;
|
||||||
const online = isHostOnline(summary.host);
|
const online = isHostOnline(summary.host);
|
||||||
const uptimeLabel = summary.uptimeSeconds ? formatUptime(summary.uptimeSeconds) : '—';
|
const uptimeLabel = summary.uptimeSeconds ? formatUptime(summary.uptimeSeconds) : '—';
|
||||||
const agentLabel = summary.host.agentVersion ? summary.host.agentVersion : '—';
|
|
||||||
|
|
||||||
const rowStyle = () => {
|
const rowStyle = () => {
|
||||||
const styles: Record<string, string> = {};
|
const styles: Record<string, string> = {};
|
||||||
|
|
@ -256,72 +255,34 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
|
||||||
onClick={() => props.onSelect(summary.host.id)}
|
onClick={() => props.onSelect(summary.host.id)}
|
||||||
>
|
>
|
||||||
<td class="pr-2 py-1 pl-3 align-middle">
|
<td class="pr-2 py-1 pl-3 align-middle">
|
||||||
<div class="flex flex-wrap items-center gap-1.5 sm:flex-nowrap sm:whitespace-nowrap sm:min-w-0">
|
<div class="flex items-center gap-1.5 min-w-0">
|
||||||
<StatusDot
|
<StatusDot
|
||||||
variant={hostStatus().variant}
|
variant={hostStatus().variant}
|
||||||
title={hostStatus().label}
|
title={hostStatus().label}
|
||||||
ariaLabel={hostStatus().label}
|
ariaLabel={hostStatus().label}
|
||||||
size="xs"
|
size="xs"
|
||||||
/>
|
/>
|
||||||
<span class="font-medium text-[11px] text-gray-900 dark:text-gray-100 sm:truncate sm:max-w-[200px]">
|
<span class="font-medium text-[11px] text-gray-900 dark:text-gray-100 truncate" title={getDisplayName(summary.host)}>
|
||||||
{getDisplayName(summary.host)}
|
{getDisplayName(summary.host)}
|
||||||
</span>
|
</span>
|
||||||
<Show when={getDisplayName(summary.host) !== summary.host.hostname}>
|
<Show when={getDisplayName(summary.host) !== summary.host.hostname}>
|
||||||
<span class="hidden sm:inline text-[9px] text-gray-500 dark:text-gray-400 sm:whitespace-nowrap">
|
<span class="hidden sm:inline text-[9px] text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||||
({summary.host.hostname})
|
({summary.host.hostname})
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
<span
|
<div class="hidden xl:flex items-center gap-1.5 ml-1">
|
||||||
class={`text-[9px] px-1 py-0 rounded font-medium whitespace-nowrap ${runtimeInfo.badgeClass}`}
|
|
||||||
title={runtimeInfo.raw || runtimeInfo.label}
|
|
||||||
>
|
|
||||||
{runtimeInfo.label}
|
|
||||||
</span>
|
|
||||||
<Show when={runtimeVersion}>
|
|
||||||
<span class="text-[9px] text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
|
||||||
v{runtimeVersion}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<div class="mt-2 grid grid-cols-1 gap-1 text-[10px] text-gray-500 dark:text-gray-400 sm:hidden">
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<span class="font-semibold text-gray-600 dark:text-gray-300">Uptime:</span>
|
|
||||||
<span>{uptimeLabel}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-start gap-1">
|
|
||||||
<span class="font-semibold text-gray-600 dark:text-gray-300">Last:</span>
|
|
||||||
<span class="flex-1">
|
|
||||||
<span>{summary.lastSeenRelative}</span>
|
|
||||||
<Show when={summary.lastSeenAbsolute}>
|
|
||||||
<span class="block text-[9px] text-gray-400 dark:text-gray-500">
|
|
||||||
{summary.lastSeenAbsolute}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap items-center gap-1">
|
|
||||||
<span class="font-semibold text-gray-600 dark:text-gray-300">Agent:</span>
|
|
||||||
<span
|
<span
|
||||||
class={
|
class={`text-[9px] px-1 py-0 rounded font-medium whitespace-nowrap ${runtimeInfo.badgeClass}`}
|
||||||
agentOutdated
|
title={runtimeInfo.raw || runtimeInfo.label}
|
||||||
? 'rounded px-1 py-0.5 bg-yellow-100 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-400 font-medium'
|
|
||||||
: 'rounded px-1 py-0.5 bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-400 font-medium'
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{agentLabel}
|
{runtimeInfo.label}
|
||||||
</span>
|
</span>
|
||||||
<Show when={agentOutdated}>
|
<Show when={runtimeVersion}>
|
||||||
<span class="text-[9px] font-medium text-yellow-600 dark:text-yellow-500">
|
<span class="text-[9px] text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||||
Update recommended
|
v{runtimeVersion}
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<Show when={summary.host.intervalSeconds}>
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<span class="font-semibold text-gray-600 dark:text-gray-300">Interval:</span>
|
|
||||||
<span>{summary.host.intervalSeconds}s</span>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-0.5 md:px-2 py-1 align-middle">
|
<td class="px-0.5 md:px-2 py-1 align-middle">
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -171,345 +171,345 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Card padding="none" class="overflow-hidden">
|
<Card padding="none" class="overflow-hidden">
|
||||||
<ScrollableTable>
|
<ScrollableTable>
|
||||||
<table class="w-full border-collapse">
|
<table class="w-full border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-600">
|
<tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-600">
|
||||||
<th class="pl-4 pr-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[24%]">
|
<th class="pl-4 pr-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[24%]">
|
||||||
Host
|
Host
|
||||||
</th>
|
</th>
|
||||||
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[18%]">
|
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[18%]">
|
||||||
Platform
|
Platform
|
||||||
</th>
|
</th>
|
||||||
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[22%]">
|
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[22%]">
|
||||||
CPU
|
CPU
|
||||||
</th>
|
</th>
|
||||||
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[22%]">
|
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[22%]">
|
||||||
Memory
|
Memory
|
||||||
</th>
|
</th>
|
||||||
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[14%]">
|
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[14%]">
|
||||||
Uptime
|
Uptime
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<For each={filteredHosts()}>
|
<For each={filteredHosts()}>
|
||||||
{(host) => {
|
{(host) => {
|
||||||
const cpuPercent = () => host.cpuUsage ?? 0;
|
const cpuPercent = () => host.cpuUsage ?? 0;
|
||||||
const memPercent = () => host.memory?.usage ?? 0;
|
const memPercent = () => host.memory?.usage ?? 0;
|
||||||
const memUsed = () => formatBytes(host.memory?.used ?? 0, 0);
|
const memUsed = () => formatBytes(host.memory?.used ?? 0, 0);
|
||||||
const memTotal = () => formatBytes(host.memory?.total ?? 0, 0);
|
const memTotal = () => formatBytes(host.memory?.total ?? 0, 0);
|
||||||
const hostStatus = createMemo(() => getHostStatusIndicator(host));
|
const hostStatus = createMemo(() => getHostStatusIndicator(host));
|
||||||
|
|
||||||
// Drawer state
|
// Drawer state
|
||||||
const [drawerOpen, setDrawerOpen] = createSignal(drawerState.get(host.id) ?? false);
|
const [drawerOpen, setDrawerOpen] = createSignal(drawerState.get(host.id) ?? false);
|
||||||
|
|
||||||
// Check if we have additional info to show in drawer
|
// Check if we have additional info to show in drawer
|
||||||
const hasDrawerContent = createMemo(() => {
|
const hasDrawerContent = createMemo(() => {
|
||||||
return (
|
return (
|
||||||
(host.disks && host.disks.length > 0) ||
|
(host.disks && host.disks.length > 0) ||
|
||||||
(host.networkInterfaces && host.networkInterfaces.length > 0) ||
|
(host.networkInterfaces && host.networkInterfaces.length > 0) ||
|
||||||
(host.raid && host.raid.length > 0) ||
|
(host.raid && host.raid.length > 0) ||
|
||||||
host.loadAverage ||
|
host.loadAverage ||
|
||||||
host.cpuCount ||
|
host.cpuCount ||
|
||||||
host.kernelVersion ||
|
host.kernelVersion ||
|
||||||
host.architecture ||
|
host.architecture ||
|
||||||
host.agentVersion ||
|
host.agentVersion ||
|
||||||
(host.sensors?.temperatureCelsius && Object.keys(host.sensors.temperatureCelsius).length > 0)
|
(host.sensors?.temperatureCelsius && Object.keys(host.sensors.temperatureCelsius).length > 0)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggleDrawer = (event: MouseEvent) => {
|
const toggleDrawer = (event: MouseEvent) => {
|
||||||
if (!hasDrawerContent()) return;
|
if (!hasDrawerContent()) return;
|
||||||
const target = event.target as HTMLElement;
|
const target = event.target as HTMLElement;
|
||||||
if (target.closest('a, button, [data-prevent-toggle]')) {
|
if (target.closest('a, button, [data-prevent-toggle]')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setDrawerOpen((prev) => !prev);
|
setDrawerOpen((prev) => !prev);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sync drawer state
|
// Sync drawer state
|
||||||
createEffect(on(() => host.id, (id) => {
|
createEffect(on(() => host.id, (id) => {
|
||||||
const stored = drawerState.get(id);
|
const stored = drawerState.get(id);
|
||||||
if (stored !== undefined) {
|
if (stored !== undefined) {
|
||||||
setDrawerOpen(stored);
|
setDrawerOpen(stored);
|
||||||
} else {
|
} else {
|
||||||
setDrawerOpen(false);
|
setDrawerOpen(false);
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
drawerState.set(host.id, drawerOpen());
|
drawerState.set(host.id, drawerOpen());
|
||||||
});
|
});
|
||||||
|
|
||||||
const rowClass = () => {
|
const rowClass = () => {
|
||||||
const base = 'border-b border-gray-200 dark:border-gray-700 transition-all duration-200';
|
const base = 'border-b border-gray-200 dark:border-gray-700 transition-all duration-200';
|
||||||
const hover = 'hover:bg-gray-50 dark:hover:bg-gray-800/50';
|
const hover = 'hover:bg-gray-50 dark:hover:bg-gray-800/50';
|
||||||
const clickable = hasDrawerContent() ? 'cursor-pointer' : '';
|
const clickable = hasDrawerContent() ? 'cursor-pointer' : '';
|
||||||
const expanded = drawerOpen() ? 'bg-gray-50 dark:bg-gray-800/40' : '';
|
const expanded = drawerOpen() ? 'bg-gray-50 dark:bg-gray-800/40' : '';
|
||||||
return `${base} ${hover} ${clickable} ${expanded}`;
|
return `${base} ${hover} ${clickable} ${expanded}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<tr class={rowClass()} onClick={toggleDrawer} aria-expanded={drawerOpen()}>
|
<tr class={rowClass()} onClick={toggleDrawer} aria-expanded={drawerOpen()}>
|
||||||
<td class="pl-4 pr-2 py-2">
|
<td class="pl-4 pr-2 py-2">
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
<StatusDot
|
<StatusDot
|
||||||
variant={hostStatus().variant}
|
variant={hostStatus().variant}
|
||||||
title={hostStatus().label}
|
title={hostStatus().label}
|
||||||
ariaLabel={hostStatus().label}
|
ariaLabel={hostStatus().label}
|
||||||
size="xs"
|
size="xs"
|
||||||
/>
|
/>
|
||||||
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">
|
||||||
{host.displayName || host.hostname || host.id}
|
{host.displayName || host.hostname || host.id}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
<Show when={host.displayName && host.displayName !== host.hostname}>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate">
|
||||||
|
{host.hostname}
|
||||||
|
</p>
|
||||||
|
</Show>
|
||||||
|
<Show when={host.lastSeen}>
|
||||||
|
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-0.5 truncate">
|
||||||
|
Updated {formatRelativeTime(host.lastSeen!)}
|
||||||
|
</p>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-2">
|
||||||
|
<div class="text-xs text-gray-700 dark:text-gray-300">
|
||||||
|
<p class="font-medium capitalize">{host.platform || '—'}</p>
|
||||||
|
<Show when={host.osName}>
|
||||||
|
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
{host.osName} {host.osVersion}
|
||||||
|
</p>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-2">
|
||||||
|
<Show
|
||||||
|
when={cpuPercent() > 0}
|
||||||
|
fallback={<span class="text-xs text-gray-500 dark:text-gray-400">—</span>}
|
||||||
|
>
|
||||||
|
<MetricBar
|
||||||
|
label={formatPercent(cpuPercent())}
|
||||||
|
value={cpuPercent()}
|
||||||
|
type="cpu"
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-2">
|
||||||
|
<Show
|
||||||
|
when={memPercent() > 0}
|
||||||
|
fallback={<span class="text-xs text-gray-500 dark:text-gray-400">—</span>}
|
||||||
|
>
|
||||||
|
<MetricBar
|
||||||
|
label={`${memUsed()} / ${memTotal()}`}
|
||||||
|
value={memPercent()}
|
||||||
|
type="memory"
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-2">
|
||||||
|
<Show
|
||||||
|
when={host.uptimeSeconds}
|
||||||
|
fallback={<span class="text-xs text-gray-500 dark:text-gray-400">—</span>}
|
||||||
|
>
|
||||||
|
<span class="text-xs text-gray-700 dark:text-gray-300">
|
||||||
|
{formatUptime(host.uptimeSeconds!)}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{/* Drawer - Additional Info */}
|
||||||
|
<Show when={drawerOpen() && hasDrawerContent()}>
|
||||||
|
<tr class="bg-gray-50 dark:bg-gray-900/50">
|
||||||
|
<td class="px-4 py-3" colSpan={5}>
|
||||||
|
<div class="flex flex-wrap justify-start gap-3">
|
||||||
|
{/* System Info */}
|
||||||
|
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
||||||
|
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">System</div>
|
||||||
|
<div class="mt-2 space-y-1 text-[11px] text-gray-600 dark:text-gray-300">
|
||||||
|
<Show when={host.cpuCount}>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="font-medium text-gray-700 dark:text-gray-200">CPUs</span>
|
||||||
|
<span class="text-right text-gray-600 dark:text-gray-300">{host.cpuCount}</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={host.loadAverage && host.loadAverage.length > 0}>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="font-medium text-gray-700 dark:text-gray-200">Load Avg</span>
|
||||||
|
<span class="text-right text-gray-600 dark:text-gray-300">{host.loadAverage!.map(l => l.toFixed(2)).join(', ')}</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={host.architecture}>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="font-medium text-gray-700 dark:text-gray-200">Arch</span>
|
||||||
|
<span class="text-right text-gray-600 dark:text-gray-300">{host.architecture}</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={host.kernelVersion}>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="font-medium text-gray-700 dark:text-gray-200">Kernel</span>
|
||||||
|
<span class="text-right text-gray-600 dark:text-gray-300 truncate">{host.kernelVersion}</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={host.agentVersion}>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="font-medium text-gray-700 dark:text-gray-200">Agent</span>
|
||||||
|
<span class="text-right text-gray-600 dark:text-gray-300">{host.agentVersion}</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<Show when={host.displayName && host.displayName !== host.hostname}>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
|
||||||
{host.hostname}
|
|
||||||
</p>
|
|
||||||
</Show>
|
|
||||||
<Show when={host.lastSeen}>
|
|
||||||
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-0.5">
|
|
||||||
Updated {formatRelativeTime(host.lastSeen!)}
|
|
||||||
</p>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
|
||||||
<td class="px-2 py-2">
|
|
||||||
<div class="text-xs text-gray-700 dark:text-gray-300">
|
|
||||||
<p class="font-medium capitalize">{host.platform || '—'}</p>
|
|
||||||
<Show when={host.osName}>
|
|
||||||
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-0.5">
|
|
||||||
{host.osName} {host.osVersion}
|
|
||||||
</p>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-2 py-2">
|
|
||||||
<Show
|
|
||||||
when={cpuPercent() > 0}
|
|
||||||
fallback={<span class="text-xs text-gray-500 dark:text-gray-400">—</span>}
|
|
||||||
>
|
|
||||||
<MetricBar
|
|
||||||
label={formatPercent(cpuPercent())}
|
|
||||||
value={cpuPercent()}
|
|
||||||
type="cpu"
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</td>
|
|
||||||
<td class="px-2 py-2">
|
|
||||||
<Show
|
|
||||||
when={memPercent() > 0}
|
|
||||||
fallback={<span class="text-xs text-gray-500 dark:text-gray-400">—</span>}
|
|
||||||
>
|
|
||||||
<MetricBar
|
|
||||||
label={`${memUsed()} / ${memTotal()}`}
|
|
||||||
value={memPercent()}
|
|
||||||
type="memory"
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</td>
|
|
||||||
<td class="px-2 py-2">
|
|
||||||
<Show
|
|
||||||
when={host.uptimeSeconds}
|
|
||||||
fallback={<span class="text-xs text-gray-500 dark:text-gray-400">—</span>}
|
|
||||||
>
|
|
||||||
<span class="text-xs text-gray-700 dark:text-gray-300">
|
|
||||||
{formatUptime(host.uptimeSeconds!)}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
{/* Drawer - Additional Info */}
|
{/* Network Interfaces */}
|
||||||
<Show when={drawerOpen() && hasDrawerContent()}>
|
<Show when={host.networkInterfaces && host.networkInterfaces.length > 0}>
|
||||||
<tr class="bg-gray-50 dark:bg-gray-900/50">
|
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
||||||
<td class="px-4 py-3" colSpan={5}>
|
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">Network</div>
|
||||||
<div class="flex flex-wrap justify-start gap-3">
|
<div class="mt-2 space-y-2 text-[11px]">
|
||||||
{/* System Info */}
|
<For each={host.networkInterfaces?.slice(0, 4)}>
|
||||||
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
{(iface) => (
|
||||||
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">System</div>
|
<div class="rounded border border-dashed border-gray-200 p-2 dark:border-gray-700/70">
|
||||||
<div class="mt-2 space-y-1 text-[11px] text-gray-600 dark:text-gray-300">
|
<div class="font-medium text-gray-700 dark:text-gray-200">{iface.name}</div>
|
||||||
<Show when={host.cpuCount}>
|
<Show when={iface.addresses && iface.addresses.length > 0}>
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex flex-wrap gap-1 mt-1 text-[10px]">
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-200">CPUs</span>
|
<For each={iface.addresses}>
|
||||||
<span class="text-right text-gray-600 dark:text-gray-300">{host.cpuCount}</span>
|
{(addr) => (
|
||||||
</div>
|
<span class="rounded bg-blue-100 px-1.5 py-0.5 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
|
||||||
</Show>
|
{addr}
|
||||||
<Show when={host.loadAverage && host.loadAverage.length > 0}>
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-200">Load Avg</span>
|
|
||||||
<span class="text-right text-gray-600 dark:text-gray-300">{host.loadAverage!.map(l => l.toFixed(2)).join(', ')}</span>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<Show when={host.architecture}>
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-200">Arch</span>
|
|
||||||
<span class="text-right text-gray-600 dark:text-gray-300">{host.architecture}</span>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<Show when={host.kernelVersion}>
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-200">Kernel</span>
|
|
||||||
<span class="text-right text-gray-600 dark:text-gray-300 truncate">{host.kernelVersion}</span>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<Show when={host.agentVersion}>
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-200">Agent</span>
|
|
||||||
<span class="text-right text-gray-600 dark:text-gray-300">{host.agentVersion}</span>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Network Interfaces */}
|
|
||||||
<Show when={host.networkInterfaces && host.networkInterfaces.length > 0}>
|
|
||||||
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
|
||||||
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">Network</div>
|
|
||||||
<div class="mt-2 space-y-2 text-[11px]">
|
|
||||||
<For each={host.networkInterfaces?.slice(0, 4)}>
|
|
||||||
{(iface) => (
|
|
||||||
<div class="rounded border border-dashed border-gray-200 p-2 dark:border-gray-700/70">
|
|
||||||
<div class="font-medium text-gray-700 dark:text-gray-200">{iface.name}</div>
|
|
||||||
<Show when={iface.addresses && iface.addresses.length > 0}>
|
|
||||||
<div class="flex flex-wrap gap-1 mt-1 text-[10px]">
|
|
||||||
<For each={iface.addresses}>
|
|
||||||
{(addr) => (
|
|
||||||
<span class="rounded bg-blue-100 px-1.5 py-0.5 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
|
|
||||||
{addr}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* Disk Info */}
|
|
||||||
<Show when={host.disks && host.disks.length > 0}>
|
|
||||||
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
|
||||||
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">Disks</div>
|
|
||||||
<div class="mt-2 space-y-2 text-[11px]">
|
|
||||||
<For each={host.disks?.slice(0, 3)}>
|
|
||||||
{(disk) => {
|
|
||||||
const diskPercent = () => disk.usage ?? 0;
|
|
||||||
return (
|
|
||||||
<div class="rounded border border-dashed border-gray-200 p-2 dark:border-gray-700/70">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-200 truncate">{disk.mountpoint || disk.device}</span>
|
|
||||||
<span class="text-[10px] text-gray-500 dark:text-gray-400">
|
|
||||||
{formatBytes(disk.used ?? 0, 0)} / {formatBytes(disk.total ?? 0, 0)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
)}
|
||||||
<Show when={diskPercent() > 0}>
|
</For>
|
||||||
<div class="mt-1">
|
</div>
|
||||||
<MetricBar
|
</Show>
|
||||||
value={diskPercent()}
|
</div>
|
||||||
label={formatPercent(diskPercent())}
|
)}
|
||||||
type="disk"
|
</For>
|
||||||
/>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* Temperature Sensors */}
|
{/* Disk Info */}
|
||||||
<Show when={host.sensors?.temperatureCelsius && Object.keys(host.sensors.temperatureCelsius).length > 0}>
|
<Show when={host.disks && host.disks.length > 0}>
|
||||||
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
||||||
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">Temperatures</div>
|
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">Disks</div>
|
||||||
<div class="mt-2 space-y-1 text-[11px] text-gray-600 dark:text-gray-300">
|
<div class="mt-2 space-y-2 text-[11px]">
|
||||||
<For each={Object.entries(host.sensors!.temperatureCelsius!).slice(0, 5)}>
|
<For each={host.disks?.slice(0, 3)}>
|
||||||
{([name, temp]) => (
|
{(disk) => {
|
||||||
<div class="flex items-center justify-between gap-2">
|
const diskPercent = () => disk.usage ?? 0;
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-200 truncate">{name}</span>
|
return (
|
||||||
<span class={`text-right ${temp > 80 ? 'text-red-600 dark:text-red-400 font-semibold' : 'text-gray-600 dark:text-gray-300'}`}>
|
<div class="rounded border border-dashed border-gray-200 p-2 dark:border-gray-700/70">
|
||||||
{temp.toFixed(1)}°C
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="font-medium text-gray-700 dark:text-gray-200 truncate">{disk.mountpoint || disk.device}</span>
|
||||||
|
<span class="text-[10px] text-gray-500 dark:text-gray-400">
|
||||||
|
{formatBytes(disk.used ?? 0, 0)} / {formatBytes(disk.total ?? 0, 0)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<Show when={diskPercent() > 0}>
|
||||||
</For>
|
<div class="mt-1">
|
||||||
</div>
|
<MetricBar
|
||||||
</div>
|
value={diskPercent()}
|
||||||
</Show>
|
label={formatPercent(diskPercent())}
|
||||||
|
type="disk"
|
||||||
{/* RAID Arrays */}
|
/>
|
||||||
<Show when={host.raid && host.raid.length > 0}>
|
|
||||||
<For each={host.raid!}>
|
|
||||||
{(array) => {
|
|
||||||
const isDegraded = () => array.state.toLowerCase().includes('degraded') || array.failedDevices > 0;
|
|
||||||
const isRebuilding = () => array.state.toLowerCase().includes('recover') || array.state.toLowerCase().includes('resync') || array.rebuildPercent > 0;
|
|
||||||
const isHealthy = () => !isDegraded() && !isRebuilding() && array.state.toLowerCase().includes('clean');
|
|
||||||
|
|
||||||
const stateColor = () => {
|
|
||||||
if (isDegraded()) return 'text-red-600 dark:text-red-400 font-semibold';
|
|
||||||
if (isRebuilding()) return 'text-amber-600 dark:text-amber-400 font-semibold';
|
|
||||||
if (isHealthy()) return 'text-green-600 dark:text-green-400';
|
|
||||||
return 'text-gray-600 dark:text-gray-300';
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
|
||||||
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">
|
|
||||||
RAID {array.level.replace('raid', '')} - {array.device}
|
|
||||||
</div>
|
|
||||||
<div class="mt-2 space-y-1 text-[11px]">
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-200">State</span>
|
|
||||||
<span class={stateColor()}>{array.state}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between gap-2">
|
</Show>
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-200">Devices</span>
|
|
||||||
<span class="text-gray-600 dark:text-gray-300">
|
|
||||||
{array.activeDevices}/{array.totalDevices}
|
|
||||||
{array.failedDevices > 0 && <span class="text-red-600 dark:text-red-400"> ({array.failedDevices} failed)</span>}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Show when={isRebuilding() && array.rebuildPercent > 0}>
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-200">Rebuild</span>
|
|
||||||
<span class="text-amber-600 dark:text-amber-400 font-medium">
|
|
||||||
{array.rebuildPercent.toFixed(1)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Show when={array.rebuildSpeed}>
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-200">Speed</span>
|
|
||||||
<span class="text-gray-600 dark:text-gray-300">{array.rebuildSpeed}</span>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</For>
|
</For>
|
||||||
</Show>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</Show>
|
||||||
</tr>
|
|
||||||
</Show>
|
{/* Temperature Sensors */}
|
||||||
</>
|
<Show when={host.sensors?.temperatureCelsius && Object.keys(host.sensors.temperatureCelsius).length > 0}>
|
||||||
);
|
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
||||||
}}
|
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">Temperatures</div>
|
||||||
</For>
|
<div class="mt-2 space-y-1 text-[11px] text-gray-600 dark:text-gray-300">
|
||||||
</tbody>
|
<For each={Object.entries(host.sensors!.temperatureCelsius!).slice(0, 5)}>
|
||||||
</table>
|
{([name, temp]) => (
|
||||||
</ScrollableTable>
|
<div class="flex items-center justify-between gap-2">
|
||||||
</Card>
|
<span class="font-medium text-gray-700 dark:text-gray-200 truncate">{name}</span>
|
||||||
</Show>
|
<span class={`text-right ${temp > 80 ? 'text-red-600 dark:text-red-400 font-semibold' : 'text-gray-600 dark:text-gray-300'}`}>
|
||||||
|
{temp.toFixed(1)}°C
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* RAID Arrays */}
|
||||||
|
<Show when={host.raid && host.raid.length > 0}>
|
||||||
|
<For each={host.raid!}>
|
||||||
|
{(array) => {
|
||||||
|
const isDegraded = () => array.state.toLowerCase().includes('degraded') || array.failedDevices > 0;
|
||||||
|
const isRebuilding = () => array.state.toLowerCase().includes('recover') || array.state.toLowerCase().includes('resync') || array.rebuildPercent > 0;
|
||||||
|
const isHealthy = () => !isDegraded() && !isRebuilding() && array.state.toLowerCase().includes('clean');
|
||||||
|
|
||||||
|
const stateColor = () => {
|
||||||
|
if (isDegraded()) return 'text-red-600 dark:text-red-400 font-semibold';
|
||||||
|
if (isRebuilding()) return 'text-amber-600 dark:text-amber-400 font-semibold';
|
||||||
|
if (isHealthy()) return 'text-green-600 dark:text-green-400';
|
||||||
|
return 'text-gray-600 dark:text-gray-300';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
||||||
|
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">
|
||||||
|
RAID {array.level.replace('raid', '')} - {array.device}
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 space-y-1 text-[11px]">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="font-medium text-gray-700 dark:text-gray-200">State</span>
|
||||||
|
<span class={stateColor()}>{array.state}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="font-medium text-gray-700 dark:text-gray-200">Devices</span>
|
||||||
|
<span class="text-gray-600 dark:text-gray-300">
|
||||||
|
{array.activeDevices}/{array.totalDevices}
|
||||||
|
{array.failedDevices > 0 && <span class="text-red-600 dark:text-red-400"> ({array.failedDevices} failed)</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Show when={isRebuilding() && array.rebuildPercent > 0}>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="font-medium text-gray-700 dark:text-gray-200">Rebuild</span>
|
||||||
|
<span class="text-amber-600 dark:text-amber-400 font-medium">
|
||||||
|
{array.rebuildPercent.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Show when={array.rebuildSpeed}>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="font-medium text-gray-700 dark:text-gray-200">Speed</span>
|
||||||
|
<span class="text-gray-600 dark:text-gray-300">{array.rebuildSpeed}</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</ScrollableTable>
|
||||||
|
</Card>
|
||||||
|
</Show>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -422,7 +422,7 @@ const MailGateway: Component = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr>
|
<tr>
|
||||||
<td class="px-2 py-1.5 text-xs font-medium text-gray-900 dark:text-gray-100">{node.name}</td>
|
<td class="px-2 py-1.5 text-xs font-medium text-gray-900 dark:text-gray-100 truncate max-w-[150px]">{node.name}</td>
|
||||||
<td class="px-2 py-1.5 text-xs text-gray-700 dark:text-gray-300 capitalize">{node.role || '—'}</td>
|
<td class="px-2 py-1.5 text-xs text-gray-700 dark:text-gray-300 capitalize">{node.role || '—'}</td>
|
||||||
<td class="px-2 py-1.5">
|
<td class="px-2 py-1.5">
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ const Replication: Component = () => {
|
||||||
return (
|
return (
|
||||||
<tr class="hover:bg-gray-50/80 dark:hover:bg-gray-900/40 transition-colors">
|
<tr class="hover:bg-gray-50/80 dark:hover:bg-gray-900/40 transition-colors">
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<div class="font-medium text-gray-900 dark:text-gray-100">
|
<div class="font-medium text-gray-900 dark:text-gray-100 truncate max-w-[200px]">
|
||||||
{job.guestName || `VM ${job.guestId ?? ''}`}
|
{job.guestName || `VM ${job.guestId ?? ''}`}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
|
|
||||||
|
|
@ -319,7 +319,7 @@ export const PveNodesTable: Component<PveNodesTableProps> = (props) => {
|
||||||
<p class="font-medium text-gray-900 dark:text-gray-100 truncate">
|
<p class="font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||||
{node.name}
|
{node.name}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-gray-600 dark:text-gray-400 break-all">
|
<p class="text-xs text-gray-600 dark:text-gray-400 truncate">
|
||||||
{node.host}
|
{node.host}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -339,16 +339,16 @@ export const PveNodesTable: Component<PveNodesTableProps> = (props) => {
|
||||||
const pulseStatus = endpoint.PulseReachable === null || endpoint.PulseReachable === undefined
|
const pulseStatus = endpoint.PulseReachable === null || endpoint.PulseReachable === undefined
|
||||||
? 'unknown'
|
? 'unknown'
|
||||||
: endpoint.PulseReachable
|
: endpoint.PulseReachable
|
||||||
? 'reachable'
|
? 'reachable'
|
||||||
: 'unreachable';
|
: 'unreachable';
|
||||||
|
|
||||||
const statusColor = endpoint.Online && pulseStatus === 'reachable'
|
const statusColor = endpoint.Online && pulseStatus === 'reachable'
|
||||||
? 'border-green-200 bg-green-50 text-green-700 dark:border-green-700 dark:bg-green-900/20 dark:text-green-300'
|
? 'border-green-200 bg-green-50 text-green-700 dark:border-green-700 dark:bg-green-900/20 dark:text-green-300'
|
||||||
: pulseStatus === 'unreachable'
|
: pulseStatus === 'unreachable'
|
||||||
? 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-300'
|
? 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-300'
|
||||||
: endpoint.Online
|
: endpoint.Online
|
||||||
? 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-700 dark:bg-blue-900/20 dark:text-blue-300'
|
? 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-700 dark:bg-blue-900/20 dark:text-blue-300'
|
||||||
: 'border-gray-200 bg-gray-100 text-gray-600 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400';
|
: 'border-gray-200 bg-gray-100 text-gray-600 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={`rounded border px-3 py-2 text-[0.7rem] ${statusColor}`}>
|
<div class={`rounded border px-3 py-2 text-[0.7rem] ${statusColor}`}>
|
||||||
|
|
@ -576,7 +576,7 @@ export const PbsNodesTable: Component<PbsNodesTableProps> = (props) => {
|
||||||
<p class="font-medium text-gray-900 dark:text-gray-100 truncate">
|
<p class="font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||||
{node.name}
|
{node.name}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-gray-600 dark:text-gray-400 break-all">
|
<p class="text-xs text-gray-600 dark:text-gray-400 truncate">
|
||||||
{node.host}
|
{node.host}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -759,7 +759,7 @@ export const PmgNodesTable: Component<PmgNodesTableProps> = (props) => {
|
||||||
<p class="font-medium text-gray-900 dark:text-gray-100 truncate">
|
<p class="font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||||
{node.name}
|
{node.name}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-gray-600 dark:text-gray-400 break-all">
|
<p class="text-xs text-gray-600 dark:text-gray-400 truncate">
|
||||||
{node.host}
|
{node.host}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1469,6 +1469,10 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||||
if req.URL.Path == "/api/setup-script-url" {
|
if req.URL.Path == "/api/setup-script-url" {
|
||||||
skipCSRF = true
|
skipCSRF = true
|
||||||
}
|
}
|
||||||
|
// Skip CSRF for bootstrap token validation (used during initial setup before session exists)
|
||||||
|
if req.URL.Path == "/api/security/validate-bootstrap-token" {
|
||||||
|
skipCSRF = true
|
||||||
|
}
|
||||||
if strings.HasPrefix(req.URL.Path, "/api/") && !skipCSRF && !CheckCSRF(w, req) {
|
if strings.HasPrefix(req.URL.Path, "/api/") && !skipCSRF && !CheckCSRF(w, req) {
|
||||||
http.Error(w, "CSRF token validation failed", http.StatusForbidden)
|
http.Error(w, "CSRF token validation failed", http.StatusForbidden)
|
||||||
LogAuditEvent("csrf_failure", "", GetClientIP(req), req.URL.Path, false, "Invalid CSRF token")
|
LogAuditEvent("csrf_failure", "", GetClientIP(req), req.URL.Path, false, "Invalid CSRF token")
|
||||||
|
|
|
||||||
7
mock.env
7
mock.env
|
|
@ -1 +1,6 @@
|
||||||
PULSE_MOCK_MODE=false
|
PULSE_MOCK_MODE=true
|
||||||
|
PULSE_MOCK_NODES=7
|
||||||
|
PULSE_MOCK_VMS_PER_NODE=5
|
||||||
|
PULSE_MOCK_LXCS_PER_NODE=8
|
||||||
|
PULSE_MOCK_RANDOM_METRICS=true
|
||||||
|
PULSE_MOCK_STOPPED_PERCENT=20
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue