Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions agents/project/domain-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ matching reality, fixing it is a protocol change like any rule edit.
for it. `lib/security/url-credentials.ts` is the only place that knows how
to find it; both the log sanitizer and the monitor settings UI go through
it.
- Alarm state (`monitors/alarm/id:{id}/command:status`) comes from the motion
score alone, never from recording mode. A `Recording=Always` monitor with an
open `cause: Continuous` event still reports `0` (IDLE), verified against
1.39.18; it reports ALARM/ALERT on motion like any other monitor. `TAPE` (4)
existed only before its removal in 1.37 dev, where a continuous recorder sat
in it while quiet. So "always recording" never means "always alarming", and
`isAlarmingState` covers the legacy case already.
- Event Server v7.0.22 and later always sends a real `eid` in pushes. The
historical fake-eid bug (a `Date.now()` value where an event id belongs)
was app-side tray handling, not the ES.
Expand Down
2 changes: 1 addition & 1 deletion app/.lint-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"react-hooks/globals": 1,
"react-hooks/preserve-manual-memoization": 12,
"react-hooks/refs": 22,
"react-hooks/set-state-in-effect": 15,
"react-hooks/set-state-in-effect": 14,
"react-hooks/static-components": 9,
"react-refresh/only-export-components": 8,
"unused-eslint-disable-directive": 1
Expand Down
95 changes: 17 additions & 78 deletions app/src/components/live-activity/LiveActivitySettingsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,6 @@
* stays visible everywhere else. That is separate from the profile-wide
* monitor exclusion (Settings > hidden monitors), which hides a monitor
* everywhere.
*
* A monitor that records continuously is skipped by default, so its toggle
* drives a separate opt-in list instead of the ignore list: that keeps the
* automatic default distinguishable from a monitor the user turned off.
*/

import { useMemo, useState, type KeyboardEvent } from 'react';
Expand All @@ -21,8 +17,6 @@ import { Label } from '../ui/label';
import { Separator } from '../ui/separator';
import { Switch } from '../ui/switch';
import { useSettingsStore, mergeProfileSettings } from '../../stores/settings';
import { useAuthStore } from '../../stores/auth';
import { isContinuousRecording } from '../../lib/monitor/monitor-status';
import { LIVE_ACTIVITY } from '../../lib/zmninja-ng-constants';
import type { MonitorData } from '../../api/types';

Expand Down Expand Up @@ -133,7 +127,6 @@ export function LiveActivitySettingsDialog({
monitors,
}: LiveActivitySettingsDialogProps) {
const { t } = useTranslation();
const zmVersion = useAuthStore((s) => s.version);

const rawSettings = useSettingsStore(
useShallow((state) => state.profileSettings?.[profileId])
Expand All @@ -145,11 +138,6 @@ export function LiveActivitySettingsDialog({
[settings.liveActivityIgnoredMonitorIds]
);

const watchContinuousSet = useMemo(
() => new Set(settings.liveActivityWatchContinuousIds),
[settings.liveActivityWatchContinuousIds]
);

const pollField = useClampedNumberField(
settings.liveActivityPollSeconds,
LIVE_ACTIVITY.minPollSeconds,
Expand Down Expand Up @@ -184,32 +172,6 @@ export function LiveActivitySettingsDialog({
useSettingsStore.getState().updateProfileSettings(profileId, { liveActivityIgnoredMonitorIds: next });
};

// A continuous recorder is off by default, so its toggle drives the opt-in
// list instead: on means watched, which is the inverse of the ignore list.
//
// Turning one on also clears any ignore entry for it. Both lists exclude the
// monitor and the ignore list wins, so without this the switch would flip on
// while the page kept excluding it, and the row would be a dead control: it
// no longer writes the ignore list, so nothing left in the UI could clear
// that entry. Reachable in one click by anyone who ignored a continuous
// monitor before it started being skipped by default.
const handleContinuousToggle = (monitorId: string, watched: boolean) => {
const current = settings.liveActivityWatchContinuousIds;
const next = watched
? current.includes(monitorId)
? current
: [...current, monitorId]
: current.filter((id) => id !== monitorId);
useSettingsStore.getState().updateProfileSettings(profileId, {
liveActivityWatchContinuousIds: next,
...(watched && {
liveActivityIgnoredMonitorIds: settings.liveActivityIgnoredMonitorIds.filter(
(id) => id !== monitorId
),
}),
});
};

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent data-testid="live-activity-settings-dialog">
Expand Down Expand Up @@ -307,46 +269,23 @@ export function LiveActivitySettingsDialog({
<p className="text-xs text-muted-foreground">{t('live_activity.ignore_list_empty')}</p>
) : (
<div className="space-y-2 max-h-48 overflow-y-auto">
{monitors.map(({ Monitor }) => {
const continuous = isContinuousRecording(Monitor, zmVersion);
return (
<div key={Monitor.Id} className="flex items-center justify-between gap-2">
<div className="min-w-0">
<Label
htmlFor={`live-activity-ignore-${Monitor.Id}`}
className="text-sm font-normal truncate min-w-0 block"
title={Monitor.Name}
>
{Monitor.Name}
</Label>
{continuous && (
<p
className="text-xs text-muted-foreground"
data-testid={`live-activity-continuous-hint-${Monitor.Id}`}
>
{t('live_activity.continuous_hint')}
</p>
)}
</div>
<Switch
id={`live-activity-ignore-${Monitor.Id}`}
// Either list excluding it means the page excludes it,
// so the switch only reads as on when neither does.
checked={
continuous
? watchContinuousSet.has(Monitor.Id) && !ignoredSet.has(Monitor.Id)
: !ignoredSet.has(Monitor.Id)
}
onCheckedChange={(checked) =>
continuous
? handleContinuousToggle(Monitor.Id, checked)
: handleIgnoreToggle(Monitor.Id, checked)
}
data-testid={`live-activity-ignore-${Monitor.Id}`}
/>
</div>
);
})}
{monitors.map(({ Monitor }) => (
<div key={Monitor.Id} className="flex items-center justify-between gap-2">
<Label
htmlFor={`live-activity-ignore-${Monitor.Id}`}
className="text-sm font-normal truncate min-w-0"
title={Monitor.Name}
>
{Monitor.Name}
</Label>
<Switch
id={`live-activity-ignore-${Monitor.Id}`}
checked={!ignoredSet.has(Monitor.Id)}
onCheckedChange={(checked) => handleIgnoreToggle(Monitor.Id, checked)}
data-testid={`live-activity-ignore-${Monitor.Id}`}
/>
</div>
))}
</div>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,13 @@ import { describe, it, expect, beforeEach } from 'vitest';
import { render, screen, fireEvent, act } from '@testing-library/react';
import { LiveActivitySettingsDialog } from '../LiveActivitySettingsDialog';
import { useSettingsStore } from '../../../stores/settings';
import { useAuthStore } from '../../../stores/auth';

const MONITORS = [
{ Monitor: { Id: '3', Name: 'Front Door', Function: 'Modect' } },
{ Monitor: { Id: '4', Name: 'Backyard', Function: 'Modect' } },
];

// Mocord records continuously on the pre-1.38 schema the tests below run on.
// Mocord records continuously on the pre-1.38 schema.
const MONITORS_WITH_CONTINUOUS = [
...MONITORS,
{ Monitor: { Id: '5', Name: 'Driveway', Function: 'Mocord' } },
Expand All @@ -18,7 +17,6 @@ const MONITORS_WITH_CONTINUOUS = [
describe('LiveActivitySettingsDialog', () => {
beforeEach(() => {
useSettingsStore.setState({ profileSettings: {} });
useAuthStore.setState({ version: '1.36.33' });
});

it('persists a changed dwell value to the profile settings on blur', () => {
Expand Down Expand Up @@ -57,119 +55,21 @@ describe('LiveActivitySettingsDialog', () => {
).toEqual(['4']);
});

// A continuous recorder is skipped by default, so its toggle drives the
// opt-in list rather than the ignore list. Seeding the ignore list instead
// would make the automatic default indistinguishable from a user's choice.
describe('continuous-recording monitors', () => {
function renderDialog() {
return render(
<LiveActivitySettingsDialog
open
onOpenChange={() => {}}
profileId="p1"
monitors={MONITORS_WITH_CONTINUOUS as never}
/>
);
}

it('shows a continuous recorder as off, and says why, without ignoring it', () => {
renderDialog();

expect(screen.getByTestId('live-activity-ignore-5')).toHaveAttribute(
'data-state',
'unchecked'
);
expect(screen.getByTestId('live-activity-continuous-hint-5')).toBeInTheDocument();
expect(screen.queryByTestId('live-activity-continuous-hint-3')).not.toBeInTheDocument();
expect(
useSettingsStore.getState().getProfileSettings('p1').liveActivityIgnoredMonitorIds
).toEqual([]);
});

it('opts a continuous recorder in without touching the ignore list', () => {
renderDialog();

fireEvent.click(screen.getByTestId('live-activity-ignore-5'));

const settings = useSettingsStore.getState().getProfileSettings('p1');
expect(settings.liveActivityWatchContinuousIds).toEqual(['5']);
expect(settings.liveActivityIgnoredMonitorIds).toEqual([]);
});

it('drops a continuous recorder back out when it is toggled off again', () => {
useSettingsStore.getState().updateProfileSettings('p1', {
liveActivityWatchContinuousIds: ['5'],
});

renderDialog();
expect(screen.getByTestId('live-activity-ignore-5')).toHaveAttribute(
'data-state',
'checked'
);

fireEvent.click(screen.getByTestId('live-activity-ignore-5'));

expect(
useSettingsStore.getState().getProfileSettings('p1').liveActivityWatchContinuousIds
).toEqual([]);
});

// Reachable by anyone who ignored a continuous monitor before it started
// being skipped by default. Both lists exclude it and the ignore list
// wins, so a row that showed "on" while the page still excluded it, and
// that no longer wrote the ignore list, would be a control with no effect
// and no way back.
it('clears the ignore entry when an ignored continuous recorder is switched on', () => {
useSettingsStore.getState().updateProfileSettings('p1', {
liveActivityIgnoredMonitorIds: ['5'],
});

renderDialog();
expect(screen.getByTestId('live-activity-ignore-5')).toHaveAttribute(
'data-state',
'unchecked'
);

fireEvent.click(screen.getByTestId('live-activity-ignore-5'));

// Both lists agree the monitor is watched, so the page really shows it.
const settings = useSettingsStore.getState().getProfileSettings('p1');
expect(settings.liveActivityWatchContinuousIds).toEqual(['5']);
expect(settings.liveActivityIgnoredMonitorIds).toEqual([]);
expect(screen.getByTestId('live-activity-ignore-5')).toHaveAttribute('data-state', 'checked');
});

it('shows an ignored continuous recorder as off even when it is opted in', () => {
useSettingsStore.getState().updateProfileSettings('p1', {
liveActivityIgnoredMonitorIds: ['5'],
liveActivityWatchContinuousIds: ['5'],
});

renderDialog();

// The page excludes it, so the switch must not claim otherwise.
expect(screen.getByTestId('live-activity-ignore-5')).toHaveAttribute(
'data-state',
'unchecked'
);
});
// A continuous recorder is treated like any other monitor: recording mode
// says nothing about what is alarming, so the row carries no special hint
// and no separate opt-in list (#313).
it('shows a continuous recorder as watched, with no hint of its own', () => {
render(
<LiveActivitySettingsDialog
open
onOpenChange={() => {}}
profileId="p1"
monitors={MONITORS_WITH_CONTINUOUS as never}
/>
);

it('treats an alarm-only monitor normally on ZM 1.38+', () => {
useAuthStore.setState({ version: '1.38.0' });
render(
<LiveActivitySettingsDialog
open
onOpenChange={() => {}}
profileId="p1"
monitors={
[...MONITORS, { Monitor: { Id: '5', Name: 'Driveway', Function: 'Mocord', Recording: 'OnMotion' } }] as never
}
/>
);

expect(screen.queryByTestId('live-activity-continuous-hint-5')).not.toBeInTheDocument();
expect(screen.getByTestId('live-activity-ignore-5')).toHaveAttribute('data-state', 'checked');
});
expect(screen.getByTestId('live-activity-ignore-5')).toHaveAttribute('data-state', 'checked');
expect(screen.queryByText(/continuously/i)).not.toBeInTheDocument();
});

it('removes a monitor from the ignore list when it is toggled back on', () => {
Expand Down
44 changes: 0 additions & 44 deletions app/src/lib/monitor/__tests__/monitor-status.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { describe, it, expect } from 'vitest';
import {
getMonitorRunState,
isContinuousRecording,
isMonitorStreamable,
monitorDotColor,
} from '../monitor-status';
Expand Down Expand Up @@ -216,49 +215,6 @@ describe('getMonitorRunState', () => {
});
});

describe('isContinuousRecording', () => {
describe('ZM 1.38+ reads Recording', () => {
const zmVersion = '1.38.0';

it('is continuous when Recording is Always', () => {
expect(isContinuousRecording(makeMonitor({ Recording: 'Always' }), zmVersion)).toBe(true);
});

it.each(['OnMotion', 'None'])('is not continuous when Recording is %s', (Recording) => {
expect(isContinuousRecording(makeMonitor({ Recording }), zmVersion)).toBe(false);
});

it('ignores a pre-1.38 Function that a 1.38 server still reports', () => {
// A 1.38 upgrade leaves Function populated, but Recording is what the
// server actually acts on, so Function must not decide this here.
const monitor = makeMonitor({ Function: 'Mocord', Recording: 'OnMotion' });
expect(isContinuousRecording(monitor, zmVersion)).toBe(false);
});

it('is not continuous when a 1.38 server omits Recording', () => {
expect(isContinuousRecording(makeMonitor({ Recording: undefined }), zmVersion)).toBe(false);
});
});

describe('pre-1.38 reads Function', () => {
it.each(['Record', 'Mocord'])('is continuous when Function is %s', (Function) => {
expect(isContinuousRecording(makeMonitor({ Function }), '1.36.33')).toBe(true);
});

it.each(['Modect', 'Monitor', 'Nodect', 'None'])(
'is not continuous when Function is %s',
(Function) => {
expect(isContinuousRecording(makeMonitor({ Function }), '1.36.33')).toBe(false);
}
);

it('reads Function when zmVersion is unknown, ignoring Recording', () => {
const monitor = makeMonitor({ Function: 'Record', Recording: 'OnMotion' });
expect(isContinuousRecording(monitor, null)).toBe(true);
});
});
});

describe('isMonitorStreamable', () => {
it('returns true for live and warning', () => {
expect(isMonitorStreamable('live')).toBe(true);
Expand Down
Loading
Loading