From 0cd8baa85d12007d89ef59db6a464f8f396c95e2 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Thu, 8 Oct 2026 16:38:09 +0200 Subject: [PATCH] Push receiver: history filter; the session-watch button hides when watching is impossible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - History page: one dropdown built from what the history holds — Platform (ops alerts, one entry per Category fact), Enrollment (one per tenant alert kind) and This device (the receiver's own messages) — with counts, a "12 of 48" readout and a clear button while a filter is active, hidden while there is only one kind to show. The choice is stored on the device (meta key historyFilter); a deep link to a hidden entry shows everything. A customer's device holds tenant pushes only, so it never sees a Platform group. - SessionWatchResponse.Available: GET/DELETE sessions/{id}/watch say whether this person can watch at all (an enabled Push channel in one of their own scopes where they hold Admin or Operator); the portal's "Notify me when done" renders only then, instead of letting a tenant admin click into "enable a Push channel first", which only a Global Admin can do. An active watch stays available so it can be switched off. Co-Authored-By: Claude Fable 5.1 --- .../Push/PushSessionWatchServiceTests.cs | 36 +++++ .../Services/Push/PushSessionWatchService.cs | 28 +++- .../src/generated/wire-types.generated.ts | 4 +- .../Models/PushApiModels.cs | 8 +- .../autopilot-monitor-web/app/push/page.tsx | 94 +++++++++++- .../components/SessionWatchButton.tsx | 10 +- .../lib/__tests__/historyFilter.test.ts | 104 +++++++++++++ .../lib/push/historyFilter.ts | 142 ++++++++++++++++++ .../public/push/sw-core.js | 2 + .../utils/shared-manifests.json | 10 +- .../utils/wire-types.generated.ts | 4 +- 11 files changed, 430 insertions(+), 12 deletions(-) create mode 100644 src/Web/autopilot-monitor-web/lib/__tests__/historyFilter.test.ts create mode 100644 src/Web/autopilot-monitor-web/lib/push/historyFilter.ts diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushSessionWatchServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushSessionWatchServiceTests.cs index 08895b57..9ce44f75 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushSessionWatchServiceTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushSessionWatchServiceTests.cs @@ -200,6 +200,42 @@ public class PushSessionWatchServiceTests Assert.Null(status.ExpiresUtc); } + [Fact] + public async Task Get_says_watching_is_available_when_an_own_scope_has_a_channel_and_the_caller_is_eligible() + { + var h = new Harness(); + + var status = await h.Sut.GetAsync(HomeTenantId, SessionId, Admin); + + Assert.False(status.Watching); + Assert.True(status.Available); + } + + [Fact] + public async Task Get_says_watching_is_unavailable_without_a_push_channel_or_without_eligibility() + { + var noChannel = new Harness(); + noChannel.Eligibility.Setup(e => e.HasPushChannelAsync(It.IsAny())).ReturnsAsync(false); + Assert.False((await noChannel.Sut.GetAsync(HomeTenantId, SessionId, Admin)).Available); + + var notEligible = new Harness(); + notEligible.Eligibility.Setup(e => e.IsEligibleAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(false); + Assert.False((await notEligible.Sut.GetAsync(HomeTenantId, SessionId, Admin)).Available); + } + + [Fact] + public async Task Get_keeps_an_active_watch_available_so_it_can_still_be_switched_off() + { + var h = new Harness(); + h.AddWatch(HomeTenantId, SessionId, Admin, HomeTenantId); + h.Eligibility.Setup(e => e.HasPushChannelAsync(It.IsAny())).ReturnsAsync(false); + + var status = await h.Sut.GetAsync(HomeTenantId, SessionId, Admin); + + Assert.True(status.Watching); + Assert.True(status.Available); + } + [Fact] public async Task Unwatch_deletes_the_callers_row_only() { diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Push/PushSessionWatchService.cs b/src/Backend/AutopilotMonitor.Functions/Services/Push/PushSessionWatchService.cs index da65430c..f2fc516a 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Push/PushSessionWatchService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Push/PushSessionWatchService.cs @@ -98,20 +98,42 @@ public class PushSessionWatchService : IPushSessionWatchNotifier ExpiresUtc = now + WatchLifetime, }; await _repo.UpsertWatchAsync(watch).ConfigureAwait(false); - return PushOpResult.Success(new SessionWatchResponse { Watching = true, ExpiresUtc = watch.ExpiresUtc }); + return PushOpResult.Success(new SessionWatchResponse { Watching = true, ExpiresUtc = watch.ExpiresUtc, Available = true }); } public async Task UnwatchAsync(string tenantId, string sessionId, PushCaller caller) { await _repo.DeleteWatchAsync(tenantId, sessionId, caller.ObjectId).ConfigureAwait(false); - return new SessionWatchResponse { Watching = false }; + return new SessionWatchResponse { Watching = false, Available = await IsAvailableAsync(caller).ConfigureAwait(false) }; } public async Task GetAsync(string tenantId, string sessionId, PushCaller caller) { var watch = await _repo.GetWatchAsync(tenantId, sessionId, caller.ObjectId).ConfigureAwait(false); var active = watch != null && watch.ExpiresUtc >= DateTime.UtcNow; - return new SessionWatchResponse { Watching = active, ExpiresUtc = active ? watch!.ExpiresUtc : null }; + return new SessionWatchResponse + { + Watching = active, + ExpiresUtc = active ? watch!.ExpiresUtc : null, + Available = active || await IsAvailableAsync(caller).ConfigureAwait(false), + }; + } + + /// + /// Whether watching is possible for this person at all: an enabled Push channel in one of the + /// caller's own scopes where the caller is eligible. A device is not required here — the PUT + /// says "pair a device first" — so the portal can show the button as soon as the channel exists. + /// + private async Task IsAvailableAsync(PushCaller caller) + { + foreach (var scope in OwnScopes(caller)) + { + if (!await _eligibility.HasPushChannelAsync(scope).ConfigureAwait(false)) + continue; + if (await _eligibility.IsEligibleAsync(scope, caller.Upn, caller.ObjectId, caller.HomeTenantId).ConfigureAwait(false)) + return true; + } + return false; } /// Fail-soft: a watch push must never cost the ingest path anything. diff --git a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts index 4f5119fd..826a0a7e 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts @@ -4507,10 +4507,12 @@ export interface SessionTimeBreakdown { qualityFlags: TimeAttributionFlags; } -/** PUT sessions/{sessionId}/watch — "notify my devices when this session ends". */ +/** GET/PUT/DELETE sessions/{sessionId}/watch — "notify my devices when this session ends". */ export interface SessionWatchResponse { watching: boolean; expiresUtc?: string; + /** Whether this caller can watch at all: an enabled Push channel in one of the caller's own scopes (home tenant; the platform for a Global Admin) where the caller holds an Admin or Operator role. False hides the portal's button instead of letting a click fail. */ + available: boolean; } /** Response of PATCH global/mcp-users/{upn}/usage-plan: the UPN and the plan now in effect ("(inherit)" when cleared to the tenant default). */ diff --git a/src/Shared/AutopilotMonitor.Shared/Models/PushApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/PushApiModels.cs index 5d835ce4..a6789340 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/PushApiModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/PushApiModels.cs @@ -135,10 +135,16 @@ namespace AutopilotMonitor.Shared.Models public bool IsOwn { get; set; } } - /// PUT sessions/{sessionId}/watch — "notify my devices when this session ends". + /// GET/PUT/DELETE sessions/{sessionId}/watch — "notify my devices when this session ends". public class SessionWatchResponse : IApiResponse { public bool Watching { get; set; } public DateTime? ExpiresUtc { get; set; } + /// + /// Whether this caller can watch at all: an enabled Push channel in one of the caller's own + /// scopes (home tenant; the platform for a Global Admin) where the caller holds an Admin or + /// Operator role. False hides the portal's button instead of letting a click fail. + /// + public bool Available { get; set; } } } diff --git a/src/Web/autopilot-monitor-web/app/push/page.tsx b/src/Web/autopilot-monitor-web/app/push/page.tsx index f34ae61d..05e38f59 100644 --- a/src/Web/autopilot-monitor-web/app/push/page.tsx +++ b/src/Web/autopilot-monitor-web/app/push/page.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useState } from "react"; import { useLatest } from "@/hooks/useLatest"; +import { ALL_FILTER, applyHistoryFilter, buildFilterOptions, hasFilterOption, isFilterUseful, normalizeFilterKey } from "@/lib/push/historyFilter"; import { sameHistory } from "@/lib/push/historyList"; import { createLongPress } from "@/lib/push/longPress"; import { createPullToRefresh, type PullState } from "@/lib/push/pullToRefresh"; @@ -14,6 +15,8 @@ import { parseFragment, pruneStoredHistory, readHistory, + readMeta, + writeMeta, type HistoryEntry, } from "@/lib/push/pushCore"; import { readPairCookie, reconcileDevice, type ReconcileResult } from "@/lib/push/pushClient"; @@ -53,6 +56,7 @@ export default function PushHistoryPage() { const [now, setNow] = useState(() => 0); const [sheet, setSheet] = useState(null); const [pull, setPull] = useState({ pulling: false, distance: 0, armed: false, refreshing: false }); + const [filter, setFilter] = useState(ALL_FILTER); const target = parseFragment(hash); const focusId = target?.kind === "entry" ? target.id : null; @@ -132,6 +136,42 @@ export default function PushHistoryPage() { ); useEffect(() => () => puller.dispose(), [puller]); + // The filter is a per-device preference, stored next to the retention; a stale key keeps everything. + useEffect(() => { + const run = async () => { + try { + setFilter(normalizeFilterKey((await readMeta())[META_KEYS.historyFilter])); + } catch { + // No IndexedDB: the default "all" stands. + } + }; + void run(); + }, []); + const chooseFilter = (key: string) => { + setFilter(key); + writeMeta({ [META_KEYS.historyFilter]: key }).catch(() => {}); + }; + + const groups = buildFilterOptions(entries ?? []); + const filterShown = isFilterUseful(groups); + const selected = hasFilterOption(groups, filter) ? filter : ALL_FILTER; + const visible = entries ? applyHistoryFilter(entries, selected, groups) : null; + + // A deep link to an entry the filter hides shows everything (adjust-during-render, once per link). + const [filterAdjust, setFilterAdjust] = useState(null); + if ( + focusId && + filterAdjust !== focusId && + entries && + visible && + selected !== ALL_FILTER && + entries.some((e) => e.id === focusId) && + !visible.some((e) => e.id === focusId) + ) { + setFilterAdjust(focusId); + chooseFilter(ALL_FILTER); + } + // Reconcile with the server; an unpaired device holding a Safari hand-off cookie continues to pairing. useEffect(() => { let cancelled = false; @@ -207,6 +247,56 @@ export default function PushHistoryPage() { )} + {filterShown && entries && visible && ( +
+ + + {selected !== ALL_FILTER && ( + <> + + {visible.length} of {entries.length} + + + + )} +
+ )} + + {entries && visible && entries.length > 0 && visible.length === 0 && ( +
+

No alerts match this filter.

+ +
+ )} + {isClient && entries !== null && entries.length === 0 && (

No alerts yet.

@@ -223,9 +313,9 @@ export default function PushHistoryPage() {
)} - {entries && entries.length > 0 && ( + {visible && visible.length > 0 && (
    - {entries.map((entry) => ( + {visible.map((entry) => (