-
Notifications
You must be signed in to change notification settings - Fork 0
feat: notify when a device is down, and restart the box from the phone #56
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "ftw-webapp": minor | ||
| --- | ||
|
|
||
| Notify when a device goes quiet or the house draws more than the fuse, and restart the box from this phone when something is stuck. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -174,7 +174,7 @@ | |
| {:else} | ||
| <p> | ||
| A few words on the lock screen when something at home matters: the car is | ||
| charged, your box updated itself, or it went quiet. Nothing is sent until | ||
| charged, a device goes quiet, or the box itself. Nothing is sent until | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Broken notifications intro copyMedium Severity The offline notifications teaser ends with Reviewed by Cursor Bugbot for commit 2554794. Configure here. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Extra toggles on older boxesMedium Severity The kinds list always renders every Additional Locations (1)Reviewed by Cursor Bugbot for commit 2554794. Configure here. |
||
| you turn this on. | ||
| </p> | ||
| <button | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,120 @@ | ||
| <!-- | ||
| Restart — ask this box to come back up, from the phone. | ||
|
|
||
| Not a setting. The Box screen is about the pairing, and this is a | ||
| recovery act on that same box: a device stuck after a blip, and the | ||
| owner is not at home. It lives here because this is the screen that | ||
| already names the box, and because a restart is not a preference to | ||
| hunt for. | ||
|
|
||
| Owner only, with a step-up, because the box prices POST /api/restart | ||
| as configure. A viewer is shown nothing. An old box without the | ||
| passthrough is shown nothing — the session cannot carry the route. | ||
|
|
||
| Confirm before sending. A stray tap that bounced the process would | ||
| drop every phone for a minute, and the confirm is the same shape as | ||
| signing out on this screen. | ||
| --> | ||
| <script lang="ts"> | ||
| import { callBox, BoxApiError } from '$lib/state/box-api' | ||
| import type { SiteStore } from '$lib/state/site.svelte' | ||
|
|
||
| interface Props { | ||
| site: SiteStore | ||
| } | ||
|
|
||
| let { site }: Props = $props() | ||
|
|
||
| /** From contract/registry.yaml. Absent means this box has no passthrough. */ | ||
| const CAP_PASSTHROUGH = 'api.passthrough' | ||
|
|
||
| const canAsk = $derived(site.canConfigure && site.session.caps.has(CAP_PASSTHROUGH)) | ||
|
|
||
| type Stage = 'idle' | 'confirming' | 'restarting' | ||
| let stage = $state<Stage>('idle') | ||
| let error = $state<string | null>(null) | ||
|
|
||
| async function restart(): Promise<void> { | ||
| if (stage === 'restarting') return | ||
| error = null | ||
| stage = 'restarting' | ||
| try { | ||
| await callBox<{ status?: unknown }>(site, { method: 'POST', path: '/api/restart' }) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
After the restart request returns 202, no success path ever changes Useful? React with 👍 / 👎. |
||
| } catch (err) { | ||
| stage = 'idle' | ||
| error = err instanceof BoxApiError ? err.help : "That didn't work. Nothing has changed." | ||
| } | ||
| } | ||
| </script> | ||
|
|
||
| {#if site.heardFromBox && canAsk} | ||
| <hr /> | ||
| {#if stage === 'confirming'} | ||
| <h2>Restart this box?</h2> | ||
| <p> | ||
| The software restarts. Devices keep running on their own until it comes | ||
| back, usually within a minute. This phone reconnects by itself. | ||
| </p> | ||
| <button class="danger" onclick={() => void restart()}>Restart now</button> | ||
| <button class="quiet" onclick={() => (stage = 'idle')}>Cancel</button> | ||
| {:else if stage === 'restarting'} | ||
| <h2>Restarting</h2> | ||
| <p>Your box is coming back on its own. This usually takes a minute.</p> | ||
| {:else} | ||
| <h2>Restart</h2> | ||
| <p> | ||
| Restarts the software on this box. Use it when a device is stuck and will | ||
| not come back on its own. | ||
| </p> | ||
| <button class="quiet outline" onclick={() => (stage = 'confirming')}>Restart this box</button> | ||
| {/if} | ||
| {#if error} | ||
| <p class="problem">{error}</p> | ||
| {/if} | ||
| {/if} | ||
|
|
||
| <style> | ||
| h2 { | ||
| font-size: 17px; | ||
| font-weight: 500; | ||
| letter-spacing: -0.01em; | ||
| margin-top: var(--space-2); | ||
| } | ||
|
|
||
| p { | ||
| color: var(--fg-dim); | ||
| max-width: 30rem; | ||
| } | ||
|
|
||
| .problem { | ||
| color: var(--fresh-stale); | ||
| } | ||
|
|
||
| .quiet { | ||
| color: var(--fg-dim); | ||
| font-size: 14px; | ||
| } | ||
|
|
||
| .outline { | ||
| border: 1px solid var(--line); | ||
| border-radius: var(--radius-sm); | ||
| padding: 0 var(--space-4); | ||
| min-height: 34px; | ||
| } | ||
|
|
||
| .danger { | ||
| border: 1px solid var(--fresh-stale); | ||
| color: var(--fresh-stale); | ||
| border-radius: var(--radius-sm); | ||
| padding: 0 var(--space-5); | ||
| font-weight: 500; | ||
| } | ||
|
|
||
| hr { | ||
| width: 100%; | ||
| height: 1px; | ||
| border: 0; | ||
| background: var(--line-soft); | ||
| margin: var(--space-3) 0 0; | ||
| } | ||
| </style> | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| /* Restart, from the tap to the box's own process. | ||
|
|
||
| * Real Session, real SimBox, real loopback carrier. The passkey ceremony is | ||
| * stubbed at the module the app calls, so the round trip that discovers the | ||
| * step-up still happens over the wire. A restart is configure: owner, with | ||
| * a ceremony, and a confirm before the process is asked to come back. | ||
| */ | ||
|
|
||
| import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' | ||
| import { render } from '@testing-library/svelte' | ||
| import Restart from './Restart.svelte' | ||
| import { SiteStore } from '$lib/state/site.svelte' | ||
| import { LoopbackCarrier } from '$lib/carrier/loopback' | ||
| import { SimBox } from '$lib/sim/box' | ||
| import { ROLE_VIEWER } from '$lib/protocol/messages' | ||
|
|
||
| vi.mock('$lib/identity/stepup', () => ({ | ||
| stepUp: vi.fn(async () => 'done'), | ||
| stepUpHelp: () => 'needs a ceremony', | ||
| })) | ||
|
|
||
| const NOON = new Date(2026, 6, 15, 12, 0, 0).getTime() | ||
|
|
||
| function open(role?: string) { | ||
| const box = new SimBox({ now: () => Date.now(), ...(role ? { role } : {}) }) | ||
| const site = new SiteStore('test') | ||
| site.connect(new LoopbackCarrier(box, { latencyMs: 0 })) | ||
| return { box, site } | ||
| } | ||
|
|
||
| function text(): string { | ||
| return (document.body.textContent ?? '').replace(/\s+/g, ' ') | ||
| } | ||
|
|
||
| function buttonSaying(pattern: RegExp): HTMLButtonElement | undefined { | ||
| return [...document.querySelectorAll('button')].find((b) => | ||
| pattern.test(b.textContent ?? '') | ||
| ) as HTMLButtonElement | undefined | ||
| } | ||
|
|
||
| describe('restart, from the phone', () => { | ||
| beforeEach(async () => { | ||
| const { stepUp } = await import('$lib/identity/stepup') | ||
| vi.mocked(stepUp).mockResolvedValue('done') | ||
| }) | ||
|
|
||
| afterEach(() => { | ||
| document.body.replaceChildren() | ||
| vi.useRealTimers() | ||
| vi.restoreAllMocks() | ||
| }) | ||
|
|
||
| it('asks first, then the box is told once, with one ceremony', async () => { | ||
| vi.useFakeTimers() | ||
| vi.setSystemTime(NOON) | ||
| const { box, site } = open() | ||
| const stepup = await import('$lib/identity/stepup') | ||
|
|
||
| render(Restart, { props: { site } }) | ||
| await vi.advanceTimersByTimeAsync(500) | ||
|
|
||
| expect(box.api.restarts, 'drew a control that fired on sight').toBe(0) | ||
| buttonSaying(/Restart this box/)!.click() | ||
| await vi.advanceTimersByTimeAsync(10) | ||
|
|
||
| expect(box.api.restarts, 'the confirm itself restarted the box').toBe(0) | ||
| expect(text()).toMatch(/Devices keep running on their own/) | ||
|
|
||
| vi.mocked(stepup.stepUp).mockClear() | ||
| buttonSaying(/Restart now/)!.click() | ||
| await vi.advanceTimersByTimeAsync(500) | ||
|
|
||
| expect(box.api.restarts).toBe(1) | ||
| expect(vi.mocked(stepup.stepUp), 'a restart cost more than one ceremony').toHaveBeenCalledOnce() | ||
| expect(text()).toMatch(/coming back on its own/) | ||
| }) | ||
|
|
||
| it('cancels without asking the box', async () => { | ||
| vi.useFakeTimers() | ||
| vi.setSystemTime(NOON) | ||
| const { box, site } = open() | ||
|
|
||
| render(Restart, { props: { site } }) | ||
| await vi.advanceTimersByTimeAsync(500) | ||
| buttonSaying(/Restart this box/)!.click() | ||
| await vi.advanceTimersByTimeAsync(10) | ||
| buttonSaying(/Cancel/)!.click() | ||
| await vi.advanceTimersByTimeAsync(10) | ||
|
|
||
| expect(box.api.restarts).toBe(0) | ||
| expect(buttonSaying(/Restart this box/)).toBeDefined() | ||
| }) | ||
|
|
||
| it('shows a viewer nothing, not a button their box would refuse', async () => { | ||
| vi.useFakeTimers() | ||
| vi.setSystemTime(NOON) | ||
| const { site } = open(ROLE_VIEWER) | ||
|
|
||
| render(Restart, { props: { site } }) | ||
| await vi.advanceTimersByTimeAsync(500) | ||
|
|
||
| expect(text()).not.toMatch(/Restart/i) | ||
| expect(document.querySelectorAll('button')).toHaveLength(0) | ||
| }) | ||
|
|
||
| it('draws nothing until the box has said something', async () => { | ||
| vi.useFakeTimers() | ||
| vi.setSystemTime(NOON) | ||
| const site = new SiteStore('test') | ||
|
|
||
| render(Restart, { props: { site } }) | ||
| await vi.advanceTimersByTimeAsync(200) | ||
|
|
||
| expect(document.querySelectorAll('button')).toHaveLength(0) | ||
| expect(text()).not.toMatch(/Restart/i) | ||
| }) | ||
| }) |


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
On an existing subscription connected to an older box whose rules document lacks these new kinds,
Notifications.sveltestill iterates every entry inRULE_KINDS, so both unsupported toggles are displayed. AlthoughNotifyStore.#writeRulescorrectly filters them out before sending, it then applies the returned document and silently resets the toggles to off after Save, leaving users with controls that can never take effect. Derive the displayed kinds from the types present in the loaded rules document.Useful? React with 👍 / 👎.