@@ -227,8 +231,13 @@ export function AlertResourceTableMobile(props: AlertResourceTableMobileProps) {
min={bounds.min}
max={bounds.max}
step={getAlertResourceMetricStep(metric)}
- value={isOff() ? '' : value()}
- placeholder={getAlertResourceTableMetricPlaceholder(isOff())}
+ value={isOff() || cell().follows ? '' : (value() ?? '')}
+ placeholder={
+ cell().follows && !isOff()
+ ? fallback()?.label
+ : getAlertResourceTableMetricPlaceholder(isOff())
+ }
+ title={cell().follows ? fallback()?.title : undefined}
disabled={isOff()}
class={`min-h-11 w-full rounded-sm border p-1 text-center text-sm sm:min-h-0 ${isOff() ? 'bg-surface-hover' : ' border-border'}`}
onInput={(e) => {
@@ -257,7 +266,7 @@ export function AlertResourceTableMobile(props: AlertResourceTableMobileProps) {
onClick={() => {
props.table.setGlobalDefaults?.((prev) => ({
...prev,
- [metric]: getAlertResourceEnabledDefault(metric),
+ [metric]: cell().enableValue,
}));
props.table.setHasUnsavedChanges?.(true);
}}
@@ -271,7 +280,7 @@ export function AlertResourceTableMobile(props: AlertResourceTableMobileProps) {
props.table.setGlobalDefaults?.((prev) => ({
...prev,
[metric]: isOff()
- ? getAlertResourceEnabledDefault(metric)
+ ? cell().enableValue
: ALERT_RESOURCE_METRIC_OFF_VALUE,
}));
props.table.setHasUnsavedChanges?.(true);
diff --git a/frontend-modern/src/components/Alerts/ResourceTable.test.tsx b/frontend-modern/src/components/Alerts/ResourceTable.test.tsx
index d478a6815f..ad29f3d84f 100644
--- a/frontend-modern/src/components/Alerts/ResourceTable.test.tsx
+++ b/frontend-modern/src/components/Alerts/ResourceTable.test.tsx
@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { createSignal } from 'solid-js';
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
import { ALERT_BULK_EDIT_CLEAR_LABEL } from '@/utils/alertBulkEditPresentation';
import resourceTableSource from '@/components/Alerts/ResourceTable.tsx?raw';
@@ -1007,6 +1008,144 @@ describe('ResourceTable', () => {
expect(updater({ cpu: 0 })).toEqual({ cpu: 80 });
});
+ it.each([
+ ['desktop', false],
+ ['mobile', true],
+ ])('lets an unset %s global default follow its fallback', (_layout, mobile) => {
+ mockIsMobile.mockReturnValue(mobile);
+ const setGlobalDefaults = vi.fn();
+ const props = makeProps({
+ resources: [],
+ columns: ['Temp °C'],
+ globalDefaults: { temperature: undefined },
+ globalDefaultFallbacks: {
+ temperature: { label: 'By type', title: 'Follows Disk temperature by type' },
+ },
+ setGlobalDefaults,
+ setHasUnsavedChanges: vi.fn(),
+ });
+ render(() =>
);
+
+ // Unset reads On and names where the value comes from.
+ expect(screen.getByTestId('status-badge')).toHaveTextContent('On');
+ const input = screen.getByPlaceholderText('By type') as HTMLInputElement;
+ expect(input.value).toBe('');
+ expect(input.disabled).toBe(false);
+ expect(input.title).toBe('Follows Disk temperature by type');
+
+ fireEvent.click(screen.getByTestId('status-badge'));
+ const updater = setGlobalDefaults.mock.calls[0][0] as (
+ prev: Record
,
+ ) => Record;
+ expect(updater({ temperature: undefined })).toEqual({ temperature: -1 });
+ });
+
+ it.each([
+ ['desktop', false],
+ ['mobile', true],
+ ])('switches an Off %s fallback default back to following', (_layout, mobile) => {
+ mockIsMobile.mockReturnValue(mobile);
+ const setGlobalDefaults = vi.fn();
+ const props = makeProps({
+ resources: [],
+ columns: ['Temp °C'],
+ globalDefaults: { temperature: -1 },
+ globalDefaultFallbacks: {
+ temperature: { label: 'By type', title: 'Follows Disk temperature by type' },
+ },
+ setGlobalDefaults,
+ setHasUnsavedChanges: vi.fn(),
+ });
+ render(() => );
+
+ expect(screen.getByTestId('status-badge')).toHaveTextContent('Off');
+ fireEvent.click(screen.getByTestId('status-badge'));
+ const updater = setGlobalDefaults.mock.calls[0][0] as (
+ prev: Record,
+ ) => Record;
+ expect(updater({ temperature: -1 })).toEqual({ temperature: undefined });
+ });
+
+ it.each([
+ ['desktop', false],
+ ['mobile', true],
+ ])('reads an unset %s default Off when its fallback is off', (_layout, mobile) => {
+ mockIsMobile.mockReturnValue(mobile);
+ const setGlobalDefaults = vi.fn();
+ const props = makeProps({
+ resources: [],
+ columns: ['Temp °C'],
+ globalDefaults: { temperature: undefined },
+ globalDefaultFallbacks: {
+ temperature: { label: 'By type', title: 'Off by type', off: true, enableValue: 55 },
+ },
+ setGlobalDefaults,
+ setHasUnsavedChanges: vi.fn(),
+ });
+ render(() => );
+
+ expect(screen.getByTestId('status-badge')).toHaveTextContent('Off');
+ expect(screen.queryByPlaceholderText('By type')).toBeNull();
+
+ // Unset would still be off, so switching on stages an explicit value.
+ fireEvent.click(screen.getByTestId('status-badge'));
+ const updater = setGlobalDefaults.mock.calls[0][0] as (
+ prev: Record,
+ ) => Record;
+ expect(updater({ temperature: undefined })).toEqual({ temperature: 55 });
+ });
+
+ it('shows a set fallback global default as its number', () => {
+ const props = makeProps({
+ resources: [],
+ columns: ['Temp °C'],
+ globalDefaults: { temperature: 62 },
+ globalDefaultFallbacks: {
+ temperature: { label: 'By type', title: 'Follows Disk temperature by type' },
+ },
+ setGlobalDefaults: vi.fn(),
+ setHasUnsavedChanges: vi.fn(),
+ });
+ render(() => );
+
+ expect(screen.getByDisplayValue('62')).toBeInTheDocument();
+ expect(screen.queryByPlaceholderText('By type')).toBeNull();
+ });
+
+ it('follows global defaults that arrive after the table mounts', () => {
+ const [globalDefaults, setGlobalDefaults] = createSignal>({
+ temperature: undefined,
+ });
+ const props = makeProps({
+ resources: [],
+ columns: ['Temp °C'],
+ factoryDefaults: { temperature: undefined },
+ onResetDefaults: vi.fn(),
+ setGlobalDefaults: vi.fn(),
+ setHasUnsavedChanges: vi.fn(),
+ });
+ render(() => );
+
+ expect(screen.queryByText('Custom')).toBeNull();
+ // The saved config loads after mount.
+ setGlobalDefaults({ temperature: 62 });
+ expect(screen.getByText('Custom')).toBeInTheDocument();
+ });
+
+ it('uses a table column tooltip over the generic one', () => {
+ const props = makeProps({
+ resources: [],
+ columns: ['Temp °C'],
+ columnTooltips: { 'Temp °C': 'Disk temperature that raises an alert.' },
+ });
+ render(() => );
+
+ expect(screen.getByText('Temp °C', { selector: 'th' })).toHaveAttribute(
+ 'title',
+ 'Disk temperature that raises an alert.',
+ );
+ });
+
it('stages an explicit threshold from the mobile row editor', () => {
mockIsMobile.mockReturnValue(true);
const setEditingThresholds = vi.fn();
diff --git a/frontend-modern/src/components/Alerts/ResourceTable.tsx b/frontend-modern/src/components/Alerts/ResourceTable.tsx
index 284837fbd9..9f3ae7ba22 100644
--- a/frontend-modern/src/components/Alerts/ResourceTable.tsx
+++ b/frontend-modern/src/components/Alerts/ResourceTable.tsx
@@ -11,6 +11,7 @@ import { ActionIconButton } from '@/components/shared/Button';
import { useObservedElementWidth } from '@/hooks/useObservedElementWidth';
import { useAlertResourceTableState } from './useAlertResourceTableState';
import type { GroupHeaderMeta, Resource } from '@/features/alerts/thresholds/tableTypes';
+import type { AlertResourceGlobalDefaultFallback } from './alertResourceTableModel';
import { AlertResourceTableDesktop } from './AlertResourceTableDesktop';
import { AlertResourceTableMobile } from './AlertResourceTableMobile';
@@ -49,6 +50,10 @@ export interface ResourceTableProps {
formatMetricValue: (metric: string, value: number | undefined) => string;
hasActiveAlert: (resourceId: string, metric: string) => boolean;
globalDefaults?: Record;
+ /** Metrics whose unset global default follows another setting. */
+ globalDefaultFallbacks?: Record;
+ /** Header tooltips that replace the generic one for a column label. */
+ columnTooltips?: Record;
setGlobalDefaults?: (
value:
| Record
@@ -97,10 +102,20 @@ export function ResourceTable(props: ResourceTableProps) {
someSelected,
clearSelectedIds,
} = useAlertResourceTableState({
- resources: props.resources,
- groupedResources: props.groupedResources,
- globalDefaults: props.globalDefaults,
- factoryDefaults: props.factoryDefaults,
+ // Getters keep the state reactive: saved defaults arrive after mount, and
+ // the Custom badge and reset control must follow them.
+ get resources() {
+ return props.resources;
+ },
+ get groupedResources() {
+ return props.groupedResources;
+ },
+ get globalDefaults() {
+ return props.globalDefaults;
+ },
+ get factoryDefaults() {
+ return props.factoryDefaults;
+ },
});
const totalColumnCount = createMemo(
() =>
diff --git a/frontend-modern/src/components/Alerts/ThresholdsTableAgentsResourcesSection.tsx b/frontend-modern/src/components/Alerts/ThresholdsTableAgentsResourcesSection.tsx
index 5429e1e806..1fa712f1ee 100644
--- a/frontend-modern/src/components/Alerts/ThresholdsTableAgentsResourcesSection.tsx
+++ b/frontend-modern/src/components/Alerts/ThresholdsTableAgentsResourcesSection.tsx
@@ -82,9 +82,9 @@ export function ThresholdsTableAgentsResourcesSection(props: ThresholdsTableSect
Disk temperature by type
- Alert trigger in °C for each disk type. Warning colors start 5°C below the trigger.
- Setting a Disk Temp override on a host above replaces these for all of that host's
- disks.
+ Alert trigger in °C for each disk type, for agent and TrueNAS disks. Warning colors
+ start 5°C below the trigger. A Disk Temp override on a host above replaces these for
+ all of that host's disks, and a TrueNAS Disks value replaces them for TrueNAS disks.
diff --git a/frontend-modern/src/components/Alerts/ThresholdsTableTrueNASTab.tsx b/frontend-modern/src/components/Alerts/ThresholdsTableTrueNASTab.tsx
index 5b70598a3f..1ccf7e20f0 100644
--- a/frontend-modern/src/components/Alerts/ThresholdsTableTrueNASTab.tsx
+++ b/frontend-modern/src/components/Alerts/ThresholdsTableTrueNASTab.tsx
@@ -8,6 +8,9 @@ import { CollapsibleSection } from './Thresholds/sections/CollapsibleSection';
import { formatMetricValue } from '@/features/alerts/thresholds/helpers';
import type { ThresholdsTableSectionProps } from '@/features/alerts/thresholds/thresholdsTableSectionProps';
import type { Resource } from '@/features/alerts/thresholds/tableTypes';
+import { getDiskTemperatureByTypeItems } from '@/features/alerts/thresholds/trueNASDiskTemperature';
+import { FACTORY_AGENT_DEFAULTS } from '@/utils/alertThresholdDefaults';
+import type { AlertResourceGlobalDefaultFallback } from './alertResourceTableModel';
import { getAlertThresholdsDefaultsSummary } from '@/utils/alertThresholdsSectionPresentation';
const TRUENAS_SYSTEM_COLUMNS = [
@@ -22,6 +25,7 @@ const TRUENAS_SYSTEM_COLUMNS = [
];
const TRUENAS_STORAGE_COLUMNS = ['Usage %'];
const TRUENAS_DISK_COLUMNS = ['Temp °C'];
+const TRUENAS_DISK_COLUMN_TOOLTIPS = { 'Temp °C': 'Disk temperature that raises an alert.' };
function TrueNASResourceSection(
props: ThresholdsTableSectionProps & {
@@ -39,6 +43,10 @@ function TrueNASResourceSection(
| ((prev: Record) => Record),
) => void;
onResetDefaults?: () => void;
+ defaultsSummary?: string;
+ globalDefaultFallbacks?: Record;
+ columnTooltips?: Record;
+ note?: string;
},
) {
const { state, tableProps } = props;
@@ -48,7 +56,9 @@ function TrueNASResourceSection(
state.toggleSection(props.id)}
@@ -57,6 +67,9 @@ function TrueNASResourceSection(
emptyMessage="No TrueNAS alert targets match the current filters."
>