Frontend updates
This commit is contained in:
parent
c0da4768bb
commit
6988eb746a
12 changed files with 290 additions and 113 deletions
|
|
@ -5,6 +5,7 @@ import type { JSX } from 'solid-js';
|
|||
import type { Alert } from '@/types/api';
|
||||
import type { AlertConfig, AlertThresholds, HysteresisThreshold } from '@/types/alerts';
|
||||
import { showError, showSuccess } from '@/utils/toast';
|
||||
import { formatAlertValue, formatAlertThreshold } from '@/utils/alertFormatters';
|
||||
|
||||
interface ActivationModalProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -85,7 +86,7 @@ const summarizeThresholds = (config: AlertConfig | null): ThresholdSummary[] =>
|
|||
...nodeItems,
|
||||
{
|
||||
label: 'Temperature',
|
||||
value: formatThreshold(extractTrigger(config.nodeDefaults?.temperature)),
|
||||
value: formatAlertThreshold(extractTrigger(config.nodeDefaults?.temperature), 'temperature'),
|
||||
},
|
||||
];
|
||||
summaries.push({ heading: 'Node thresholds', items: nodeWithTemperature });
|
||||
|
|
@ -263,20 +264,18 @@ export function ActivationModal(props: ActivationModalProps): JSX.Element {
|
|||
<For each={violations()}>
|
||||
{(alert) => (
|
||||
<div
|
||||
class={`border rounded-md p-3 text-sm transition-colors ${
|
||||
alert.level === 'critical'
|
||||
? 'border-red-300 dark:border-red-700 bg-red-50 dark:bg-red-900/20'
|
||||
: 'border-yellow-300 dark:border-yellow-700 bg-yellow-50 dark:bg-yellow-900/20'
|
||||
}`}
|
||||
class={`border rounded-md p-3 text-sm transition-colors ${alert.level === 'critical'
|
||||
? 'border-red-300 dark:border-red-700 bg-red-50 dark:bg-red-900/20'
|
||||
: 'border-yellow-300 dark:border-yellow-700 bg-yellow-50 dark:bg-yellow-900/20'
|
||||
}`}
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class={`px-2 py-0.5 rounded-full text-xs font-semibold uppercase ${
|
||||
alert.level === 'critical'
|
||||
? 'bg-red-600 text-white'
|
||||
: 'bg-yellow-500 text-gray-900'
|
||||
}`}
|
||||
class={`px-2 py-0.5 rounded-full text-xs font-semibold uppercase ${alert.level === 'critical'
|
||||
? 'bg-red-600 text-white'
|
||||
: 'bg-yellow-500 text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{alert.level}
|
||||
</span>
|
||||
|
|
@ -288,7 +287,7 @@ export function ActivationModal(props: ActivationModalProps): JSX.Element {
|
|||
</div>
|
||||
<p class="mt-2 text-xs text-gray-600 dark:text-gray-300">{alert.message}</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Threshold {alert.threshold}% • Current {alert.value}% • Since{' '}
|
||||
Threshold {formatAlertValue(alert.threshold, alert.type)} • Current {formatAlertValue(alert.value, alert.type)} • Since{' '}
|
||||
{new Date(alert.startTime).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -303,11 +302,10 @@ export function ActivationModal(props: ActivationModalProps): JSX.Element {
|
|||
Notification channels
|
||||
</h3>
|
||||
<div
|
||||
class={`mt-3 rounded-md border p-4 ${
|
||||
channelSummary().status === 'configured'
|
||||
? 'border-green-200 dark:border-green-700 bg-green-50 dark:bg-green-900/20'
|
||||
: 'border-blue-200 dark:border-blue-700 bg-blue-50 dark:bg-blue-900/20'
|
||||
}`}
|
||||
class={`mt-3 rounded-md border p-4 ${channelSummary().status === 'configured'
|
||||
? 'border-green-200 dark:border-green-700 bg-green-50 dark:bg-green-900/20'
|
||||
: 'border-blue-200 dark:border-blue-700 bg-blue-50 dark:bg-blue-900/20'
|
||||
}`}
|
||||
>
|
||||
<p class="text-sm text-gray-800 dark:text-gray-100">{channelSummary().message}</p>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Show, createSignal } from 'solid-js';
|
||||
import { aiChatStore } from '@/stores/aiChat';
|
||||
import type { Alert } from '@/types/api';
|
||||
import { formatAlertValue } from '@/utils/alertFormatters';
|
||||
|
||||
interface InvestigateAlertButtonProps {
|
||||
alert: Alert;
|
||||
|
|
@ -37,8 +38,8 @@ export function InvestigateAlertButton(props: InvestigateAlertButtonProps) {
|
|||
|
||||
**Resource:** ${props.alert.resourceName}
|
||||
**Alert Type:** ${props.alert.type}
|
||||
**Current Value:** ${props.alert.value.toFixed(1)}%
|
||||
**Threshold:** ${props.alert.threshold.toFixed(1)}%
|
||||
**Current Value:** ${formatAlertValue(props.alert.value, props.alert.type)}
|
||||
**Threshold:** ${formatAlertValue(props.alert.threshold, props.alert.type)}
|
||||
**Duration:** ${durationStr}
|
||||
${props.alert.node ? `**Node:** ${props.alert.node}` : ''}
|
||||
|
||||
|
|
|
|||
|
|
@ -1094,21 +1094,24 @@ export function Dashboard(props: DashboardProps) {
|
|||
|
||||
return (
|
||||
<th
|
||||
class={`py-1 text-[11px] sm:text-xs font-medium uppercase tracking-wider whitespace-nowrap
|
||||
class={`py-2 text-[11px] sm:text-xs font-medium uppercase tracking-wider whitespace-nowrap
|
||||
${isFirst() ? 'pl-4 pr-2 text-left' : 'px-2 text-center'}
|
||||
${isSortable ? 'cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600' : ''}`}
|
||||
style={col.width ? { "min-width": col.width } : undefined}
|
||||
style={{
|
||||
...(col.width ? { "min-width": col.width } : {}),
|
||||
"vertical-align": 'middle'
|
||||
}}
|
||||
onClick={() => isSortable && handleSort(sortKeyForCol!)}
|
||||
title={col.icon ? col.label : undefined}
|
||||
>
|
||||
<span class="inline-flex items-center justify-center gap-0.5">
|
||||
<div class={`flex items-center gap-0.5 ${isFirst() ? 'justify-start' : 'justify-center'}`} style={{ "min-height": "14px" }}>
|
||||
{col.icon ? (
|
||||
<span innerHTML={col.icon} />
|
||||
<span class="flex items-center" innerHTML={col.icon} />
|
||||
) : (
|
||||
col.label
|
||||
)}
|
||||
{isSorted() && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
|
||||
</span>
|
||||
</div>
|
||||
</th>
|
||||
);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Show, createMemo, createSignal } from 'solid-js';
|
|||
import { Portal } from 'solid-js/web';
|
||||
import { formatPercent } from '@/utils/format';
|
||||
import { useMetricsViewMode } from '@/stores/metricsViewMode';
|
||||
import { getMetricHistory } from '@/stores/metricsHistory';
|
||||
import { getMetricHistoryForRange } from '@/stores/metricsHistory';
|
||||
import { Sparkline } from '@/components/shared/Sparkline';
|
||||
|
||||
interface EnhancedCPUBarProps {
|
||||
|
|
@ -35,12 +35,12 @@ export function EnhancedCPUBar(props: EnhancedCPUBarProps) {
|
|||
setShowTooltip(false);
|
||||
};
|
||||
|
||||
const { viewMode } = useMetricsViewMode();
|
||||
const { viewMode, timeRange } = useMetricsViewMode();
|
||||
|
||||
// Get metric history for sparkline
|
||||
const metricHistory = createMemo(() => {
|
||||
if (viewMode() !== 'sparklines' || !props.resourceId) return [];
|
||||
return getMetricHistory(props.resourceId);
|
||||
return getMetricHistoryForRange(props.resourceId, timeRange());
|
||||
});
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -432,20 +432,20 @@ export const GUEST_COLUMNS: GuestColumnDef[] = [
|
|||
{ id: 'disk', label: 'Disk', priority: 'essential', width: '140px', sortKey: 'disk' },
|
||||
|
||||
// Secondary - visible on md+ (768px), user toggleable - use icons
|
||||
{ id: 'ip', label: 'IP', icon: '<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/></svg>', priority: 'secondary', width: '45px', toggleable: true },
|
||||
{ id: 'uptime', label: 'Uptime', icon: '<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>', priority: 'secondary', width: '60px', toggleable: true, sortKey: 'uptime' },
|
||||
{ id: 'node', label: 'Node', icon: '<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"/></svg>', priority: 'secondary', width: '55px', toggleable: true, sortKey: 'node' },
|
||||
{ id: 'ip', label: 'IP', icon: '<svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/></svg>', priority: 'secondary', width: '45px', toggleable: true },
|
||||
{ id: 'uptime', label: 'Uptime', icon: '<svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>', priority: 'secondary', width: '60px', toggleable: true, sortKey: 'uptime' },
|
||||
{ id: 'node', label: 'Node', icon: '<svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"/></svg>', priority: 'secondary', width: '55px', toggleable: true, sortKey: 'node' },
|
||||
|
||||
// Supplementary - visible on lg+ (1024px), user toggleable
|
||||
{ id: 'backup', label: 'Backup', icon: '<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/></svg>', priority: 'supplementary', width: '50px', toggleable: true },
|
||||
{ id: 'tags', label: 'Tags', icon: '<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"/></svg>', priority: 'supplementary', width: '60px', toggleable: true },
|
||||
{ id: 'backup', label: 'Backup', icon: '<svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/></svg>', priority: 'supplementary', width: '50px', toggleable: true },
|
||||
{ id: 'tags', label: 'Tags', icon: '<svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"/></svg>', priority: 'supplementary', width: '60px', toggleable: true },
|
||||
|
||||
// Detailed - visible on xl+ (1280px), user toggleable
|
||||
{ id: 'os', label: 'OS', priority: 'detailed', width: '45px', toggleable: true },
|
||||
{ id: 'diskRead', label: 'D Read', icon: '<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>', priority: 'detailed', width: '55px', toggleable: true, sortKey: 'diskRead' },
|
||||
{ id: 'diskWrite', label: 'D Write', icon: '<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/></svg>', priority: 'detailed', width: '55px', toggleable: true, sortKey: 'diskWrite' },
|
||||
{ id: 'netIn', label: 'Net In', icon: '<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16l-4-4m0 0l4-4m-4 4h18"/></svg>', priority: 'detailed', width: '55px', toggleable: true, sortKey: 'networkIn' },
|
||||
{ id: 'netOut', label: 'Net Out', icon: '<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3"/></svg>', priority: 'detailed', width: '55px', toggleable: true, sortKey: 'networkOut' },
|
||||
{ id: 'diskRead', label: 'D Read', icon: '<svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>', priority: 'detailed', width: '55px', toggleable: true, sortKey: 'diskRead' },
|
||||
{ id: 'diskWrite', label: 'D Write', icon: '<svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/></svg>', priority: 'detailed', width: '55px', toggleable: true, sortKey: 'diskWrite' },
|
||||
{ id: 'netIn', label: 'Net In', icon: '<svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16l-4-4m0 0l4-4m-4 4h18"/></svg>', priority: 'detailed', width: '55px', toggleable: true, sortKey: 'networkIn' },
|
||||
{ id: 'netOut', label: 'Net Out', icon: '<svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3"/></svg>', priority: 'detailed', width: '55px', toggleable: true, sortKey: 'networkOut' },
|
||||
];
|
||||
|
||||
interface GuestRowProps {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Show, createMemo, createSignal, onMount, onCleanup } from 'solid-js';
|
||||
import { Sparkline } from '@/components/shared/Sparkline';
|
||||
import { useMetricsViewMode } from '@/stores/metricsViewMode';
|
||||
import { getMetricHistory } from '@/stores/metricsHistory';
|
||||
import { getMetricHistoryForRange } from '@/stores/metricsHistory';
|
||||
|
||||
interface MetricBarProps {
|
||||
value: number;
|
||||
|
|
@ -19,7 +19,7 @@ const estimateTextWidth = (text: string): number => {
|
|||
};
|
||||
|
||||
export function MetricBar(props: MetricBarProps) {
|
||||
const { viewMode } = useMetricsViewMode();
|
||||
const { viewMode, timeRange } = useMetricsViewMode();
|
||||
const width = createMemo(() => Math.min(props.value, 100));
|
||||
|
||||
// Track container width
|
||||
|
|
@ -88,7 +88,7 @@ export function MetricBar(props: MetricBarProps) {
|
|||
// Get metric history for sparkline
|
||||
const metricHistory = createMemo(() => {
|
||||
if (viewMode() !== 'sparklines' || !props.resourceId) return [];
|
||||
return getMetricHistory(props.resourceId);
|
||||
return getMetricHistoryForRange(props.resourceId, timeRange());
|
||||
});
|
||||
|
||||
// Determine which metric type to use for sparkline
|
||||
|
|
|
|||
|
|
@ -2,48 +2,72 @@
|
|||
* Metrics View Mode Toggle
|
||||
*
|
||||
* Toggle button to switch between progress bars and sparkline views for metrics.
|
||||
* When in sparklines mode, also shows time range selection buttons.
|
||||
* This is a global setting that affects all tables throughout the app.
|
||||
*/
|
||||
|
||||
import { Component } from 'solid-js';
|
||||
import { useMetricsViewMode } from '@/stores/metricsViewMode';
|
||||
import { Component, Show } from 'solid-js';
|
||||
import { useMetricsViewMode, TIME_RANGE_OPTIONS } from '@/stores/metricsViewMode';
|
||||
import type { TimeRange } from '@/api/charts';
|
||||
|
||||
export const MetricsViewToggle: Component = () => {
|
||||
const { viewMode, setViewMode } = useMetricsViewMode();
|
||||
const { viewMode, setViewMode, timeRange, setTimeRange } = useMetricsViewMode();
|
||||
|
||||
return (
|
||||
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode('bars')}
|
||||
class={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95 ${viewMode() === 'bars'
|
||||
<div class="inline-flex items-center gap-2">
|
||||
{/* Time Range Selector - only shown in sparklines mode */}
|
||||
<Show when={viewMode() === 'sparklines'}>
|
||||
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
|
||||
{TIME_RANGE_OPTIONS.map((option) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTimeRange(option.value as TimeRange)}
|
||||
class={`px-2 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95 ${timeRange() === option.value
|
||||
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-gray-200 dark:ring-gray-600'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600/50'
|
||||
}`}
|
||||
title={`Show last ${option.label} of data`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* View Mode Toggle */}
|
||||
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode('bars')}
|
||||
class={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95 ${viewMode() === 'bars'
|
||||
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-gray-200 dark:ring-gray-600'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-600/50'
|
||||
}`}
|
||||
title="Bar view"
|
||||
>
|
||||
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="12" width="4" height="9" rx="1" />
|
||||
<rect x="10" y="6" width="4" height="15" rx="1" />
|
||||
<rect x="17" y="3" width="4" height="18" rx="1" />
|
||||
</svg>
|
||||
Bars
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode('sparklines')}
|
||||
class={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95 ${viewMode() === 'sparklines'
|
||||
}`}
|
||||
title="Bar view"
|
||||
>
|
||||
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="12" width="4" height="9" rx="1" />
|
||||
<rect x="10" y="6" width="4" height="15" rx="1" />
|
||||
<rect x="17" y="3" width="4" height="18" rx="1" />
|
||||
</svg>
|
||||
Bars
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode('sparklines')}
|
||||
class={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95 ${viewMode() === 'sparklines'
|
||||
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-gray-200 dark:ring-gray-600'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-600/50'
|
||||
}`}
|
||||
title="Sparkline view"
|
||||
>
|
||||
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="3 17 9 11 13 15 21 7" />
|
||||
<polyline points="17 7 21 7 21 11" />
|
||||
</svg>
|
||||
Trends
|
||||
</button>
|
||||
}`}
|
||||
title="Sparkline view"
|
||||
>
|
||||
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="3 17 9 11 13 15 21 7" />
|
||||
<polyline points="17 7 21 7 21 11" />
|
||||
</svg>
|
||||
Trends
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -142,34 +142,10 @@ export const Sparkline: Component<SparklineProps> = (props) => {
|
|||
points.push({ x, y });
|
||||
});
|
||||
|
||||
// Draw threshold reference lines
|
||||
const t = thresholds();
|
||||
ctx.strokeStyle = '#94a3b8'; // gray-400
|
||||
ctx.lineWidth = 0.5;
|
||||
ctx.globalAlpha = 0.3;
|
||||
ctx.setLineDash([2, 2]);
|
||||
|
||||
// Warning threshold line
|
||||
const warningY = h - ((t.warning - minValue) / (maxValue - minValue)) * h;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, warningY);
|
||||
ctx.lineTo(w, warningY);
|
||||
ctx.stroke();
|
||||
|
||||
// Critical threshold line
|
||||
const criticalY = h - ((t.critical - minValue) / (maxValue - minValue)) * h;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, criticalY);
|
||||
ctx.lineTo(w, criticalY);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.setLineDash([]);
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// Draw gradient fill
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, h);
|
||||
gradient.addColorStop(0, `${color}40`); // 25% opacity at top
|
||||
gradient.addColorStop(1, `${color}10`); // 6% opacity at bottom
|
||||
gradient.addColorStop(0, `${color}5A`); // 35% opacity at top
|
||||
gradient.addColorStop(1, `${color}1F`); // 12% opacity at bottom
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.beginPath();
|
||||
|
|
@ -193,15 +169,6 @@ export const Sparkline: Component<SparklineProps> = (props) => {
|
|||
}
|
||||
});
|
||||
ctx.stroke();
|
||||
|
||||
// Draw current value dot with opacity
|
||||
if (points.length > 0) {
|
||||
const lastPoint = points[points.length - 1];
|
||||
ctx.fillStyle = colorWithOpacity;
|
||||
ctx.beginPath();
|
||||
ctx.arc(lastPoint.x, lastPoint.y, 2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
};
|
||||
|
||||
// Redraw when data or dimensions change
|
||||
|
|
|
|||
|
|
@ -23,11 +23,28 @@ interface RingBuffer {
|
|||
}
|
||||
|
||||
// Configuration
|
||||
const MAX_AGE_MS = 2 * 60 * 60 * 1000; // 2 hours
|
||||
const MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours (to support all time ranges)
|
||||
const SAMPLE_INTERVAL_MS = 30 * 1000; // 30 seconds
|
||||
const MAX_POINTS = Math.ceil(MAX_AGE_MS / SAMPLE_INTERVAL_MS); // ~240 points
|
||||
const MAX_POINTS = Math.ceil(MAX_AGE_MS / SAMPLE_INTERVAL_MS); // ~2880 points
|
||||
const STORAGE_KEY = 'pulse_metrics_history';
|
||||
const STORAGE_VERSION = 1;
|
||||
const STORAGE_VERSION = 2; // Bumped version due to increased buffer size
|
||||
|
||||
/**
|
||||
* Convert TimeRange string to milliseconds
|
||||
*/
|
||||
function timeRangeToMs(range: TimeRange): number {
|
||||
switch (range) {
|
||||
case '5m': return 5 * 60 * 1000;
|
||||
case '15m': return 15 * 60 * 1000;
|
||||
case '30m': return 30 * 60 * 1000;
|
||||
case '1h': return 60 * 60 * 1000;
|
||||
case '4h': return 4 * 60 * 60 * 1000;
|
||||
case '12h': return 12 * 60 * 60 * 1000;
|
||||
case '24h': return 24 * 60 * 60 * 1000;
|
||||
case '7d': return 7 * 24 * 60 * 60 * 1000;
|
||||
default: return 60 * 60 * 1000; // Default 1h
|
||||
}
|
||||
}
|
||||
|
||||
// Store - map of resourceId to ring buffer
|
||||
const metricsHistoryMap = new Map<string, RingBuffer>();
|
||||
|
|
@ -399,7 +416,7 @@ export function hasSeedData(): boolean {
|
|||
|
||||
|
||||
/**
|
||||
* Get metric history for a resource
|
||||
* Get metric history for a resource (full history)
|
||||
*/
|
||||
export function getMetricHistory(resourceId: string): MetricSnapshot[] {
|
||||
const ring = metricsHistoryMap.get(resourceId);
|
||||
|
|
@ -409,6 +426,18 @@ export function getMetricHistory(resourceId: string): MetricSnapshot[] {
|
|||
return getRingBufferData(ring, cutoffTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metric history for a resource filtered by time range
|
||||
*/
|
||||
export function getMetricHistoryForRange(resourceId: string, range: TimeRange): MetricSnapshot[] {
|
||||
const ring = metricsHistoryMap.get(resourceId);
|
||||
if (!ring) return [];
|
||||
|
||||
const rangeMs = timeRangeToMs(range);
|
||||
const cutoffTime = Date.now() - rangeMs;
|
||||
return getRingBufferData(ring, cutoffTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear history for a specific resource
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -2,16 +2,26 @@
|
|||
* Metrics View Mode Store
|
||||
*
|
||||
* Global preference for displaying metrics as progress bars or sparklines.
|
||||
* Also manages the time range for sparkline data.
|
||||
* This affects all tables: node summaries, guest tables, and container tables.
|
||||
*/
|
||||
|
||||
import { createSignal } from 'solid-js';
|
||||
import { STORAGE_KEYS } from '@/utils/localStorage';
|
||||
import { seedFromBackend } from './metricsHistory';
|
||||
import { seedFromBackend, resetSeedingState } from './metricsHistory';
|
||||
import type { TimeRange } from '@/api/charts';
|
||||
|
||||
export type MetricsViewMode = 'bars' | 'sparklines';
|
||||
|
||||
// Read initial value from localStorage
|
||||
// Available time ranges for sparklines
|
||||
export const TIME_RANGE_OPTIONS: { value: TimeRange; label: string }[] = [
|
||||
{ value: '15m', label: '15m' },
|
||||
{ value: '1h', label: '1h' },
|
||||
{ value: '4h', label: '4h' },
|
||||
{ value: '24h', label: '24h' },
|
||||
];
|
||||
|
||||
// Read initial view mode from localStorage
|
||||
const getInitialViewMode = (): MetricsViewMode => {
|
||||
if (typeof window === 'undefined') return 'bars';
|
||||
|
||||
|
|
@ -27,11 +37,31 @@ const getInitialViewMode = (): MetricsViewMode => {
|
|||
return 'bars'; // Default to bars (current behavior)
|
||||
};
|
||||
|
||||
// Create signal
|
||||
// Read initial time range from localStorage
|
||||
const getInitialTimeRange = (): TimeRange => {
|
||||
if (typeof window === 'undefined') return '1h';
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.METRICS_TIME_RANGE);
|
||||
if (stored && ['5m', '15m', '30m', '1h', '4h', '12h', '24h', '7d'].includes(stored)) {
|
||||
return stored as TimeRange;
|
||||
}
|
||||
} catch (_err) {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
|
||||
return '1h'; // Default to 1 hour
|
||||
};
|
||||
|
||||
// Create signals
|
||||
const [metricsViewMode, setMetricsViewMode] = createSignal<MetricsViewMode>(
|
||||
getInitialViewMode()
|
||||
);
|
||||
|
||||
const [metricsTimeRange, setMetricsTimeRange] = createSignal<TimeRange>(
|
||||
getInitialTimeRange()
|
||||
);
|
||||
|
||||
/**
|
||||
* Get the current metrics view mode
|
||||
*/
|
||||
|
|
@ -39,6 +69,13 @@ export function getMetricsViewMode(): MetricsViewMode {
|
|||
return metricsViewMode();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current metrics time range
|
||||
*/
|
||||
export function getMetricsTimeRange(): TimeRange {
|
||||
return metricsTimeRange();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the metrics view mode and persist to localStorage
|
||||
*/
|
||||
|
|
@ -57,7 +94,34 @@ export function setMetricsViewModePreference(mode: MetricsViewMode): void {
|
|||
// When switching to sparklines, seed historical data from backend
|
||||
if (mode === 'sparklines') {
|
||||
// Fire and forget - don't block the UI
|
||||
seedFromBackend('1h').catch(() => {
|
||||
seedFromBackend(metricsTimeRange()).catch(() => {
|
||||
// Errors are already logged in seedFromBackend
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the metrics time range and persist to localStorage
|
||||
*/
|
||||
export function setMetricsTimeRangePreference(range: TimeRange): void {
|
||||
const previousRange = metricsTimeRange();
|
||||
setMetricsTimeRange(range);
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEYS.METRICS_TIME_RANGE, range);
|
||||
} catch (err) {
|
||||
// Ignore localStorage errors
|
||||
console.warn('Failed to save metrics time range preference', err);
|
||||
}
|
||||
}
|
||||
|
||||
// If we're in sparklines mode and range changed, re-seed from backend
|
||||
if (metricsViewMode() === 'sparklines' && range !== previousRange) {
|
||||
// Reset seeding state to force a fresh fetch
|
||||
resetSeedingState();
|
||||
// Fire and forget - don't block the UI
|
||||
seedFromBackend(range).catch(() => {
|
||||
// Errors are already logged in seedFromBackend
|
||||
});
|
||||
}
|
||||
|
|
@ -80,5 +144,7 @@ export function useMetricsViewMode() {
|
|||
viewMode: metricsViewMode,
|
||||
setViewMode: setMetricsViewModePreference,
|
||||
toggle: toggleMetricsViewMode,
|
||||
timeRange: metricsTimeRange,
|
||||
setTimeRange: setMetricsTimeRangePreference,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
88
frontend-modern/src/utils/alertFormatters.ts
Normal file
88
frontend-modern/src/utils/alertFormatters.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
/**
|
||||
* Utility functions for formatting alert values based on their metric type.
|
||||
* Temperature metrics should show °C, not percentages.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Metric types that are measured in degrees Celsius rather than percentages.
|
||||
*/
|
||||
const TEMPERATURE_METRIC_TYPES = new Set(['temperature', 'temp']);
|
||||
|
||||
/**
|
||||
* Metric types that are measured in MB/s rather than percentages.
|
||||
*/
|
||||
const THROUGHPUT_METRIC_TYPES = new Set(['diskRead', 'diskWrite', 'networkIn', 'networkOut']);
|
||||
|
||||
/**
|
||||
* Returns the appropriate unit suffix for a given metric type.
|
||||
* @param metricType The alert's metric type (e.g., 'cpu', 'memory', 'temperature')
|
||||
* @returns The unit suffix to append to values (e.g., '%', '°C', ' MB/s')
|
||||
*/
|
||||
export function getAlertUnit(metricType?: string): string {
|
||||
if (!metricType) return '%';
|
||||
const typeLower = metricType.toLowerCase();
|
||||
|
||||
if (TEMPERATURE_METRIC_TYPES.has(typeLower)) {
|
||||
return '°C';
|
||||
}
|
||||
|
||||
if (THROUGHPUT_METRIC_TYPES.has(typeLower)) {
|
||||
return ' MB/s';
|
||||
}
|
||||
|
||||
return '%';
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats an alert value with the appropriate unit based on metric type.
|
||||
* @param value The numeric value to format
|
||||
* @param metricType The alert's metric type (e.g., 'cpu', 'memory', 'temperature')
|
||||
* @param decimals Number of decimal places (default: 1)
|
||||
* @returns Formatted string with appropriate unit (e.g., '82.5%', '74.0°C')
|
||||
*/
|
||||
export function formatAlertValue(
|
||||
value: number | undefined,
|
||||
metricType?: string,
|
||||
decimals = 1
|
||||
): string {
|
||||
if (value === undefined || !Number.isFinite(value)) {
|
||||
return 'N/A';
|
||||
}
|
||||
return `${value.toFixed(decimals)}${getAlertUnit(metricType)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a threshold value with the appropriate unit based on metric type.
|
||||
* Returns 'Disabled' for values <= 0 and 'Not configured' for undefined values.
|
||||
* @param value The threshold value to format
|
||||
* @param metricType The alert's metric type (e.g., 'cpu', 'memory', 'temperature')
|
||||
* @returns Formatted threshold string
|
||||
*/
|
||||
export function formatAlertThreshold(
|
||||
value: number | undefined,
|
||||
metricType?: string
|
||||
): string {
|
||||
if (value === undefined || Number.isNaN(value)) {
|
||||
return 'Not configured';
|
||||
}
|
||||
if (value <= 0) {
|
||||
return 'Disabled';
|
||||
}
|
||||
return `${value}${getAlertUnit(metricType)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a metric type represents a temperature measurement.
|
||||
*/
|
||||
export function isTemperatureMetric(metricType?: string): boolean {
|
||||
if (!metricType) return false;
|
||||
return TEMPERATURE_METRIC_TYPES.has(metricType.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a metric type represents a throughput measurement (MB/s).
|
||||
*/
|
||||
export function isThroughputMetric(metricType?: string): boolean {
|
||||
if (!metricType) return false;
|
||||
return THROUGHPUT_METRIC_TYPES.has(metricType);
|
||||
}
|
||||
|
|
@ -108,6 +108,7 @@ export const STORAGE_KEYS = {
|
|||
|
||||
// Metrics display
|
||||
METRICS_VIEW_MODE: 'metricsViewMode', // 'bars' | 'sparklines'
|
||||
METRICS_TIME_RANGE: 'metricsTimeRange', // '15m' | '1h' | '4h' | '24h'
|
||||
|
||||
// Column visibility
|
||||
DASHBOARD_HIDDEN_COLUMNS: 'dashboardHiddenColumns',
|
||||
|
|
|
|||
Loading…
Reference in a new issue