mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
Push receiver: history filter; the session-watch button hides when watching is impossible
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<NotificationScope>())).ReturnsAsync(false);
|
||||
Assert.False((await noChannel.Sut.GetAsync(HomeTenantId, SessionId, Admin)).Available);
|
||||
|
||||
var notEligible = new Harness();
|
||||
notEligible.Eligibility.Setup(e => e.IsEligibleAsync(It.IsAny<NotificationScope>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>())).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<NotificationScope>())).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()
|
||||
{
|
||||
|
||||
@@ -98,20 +98,42 @@ public class PushSessionWatchService : IPushSessionWatchNotifier
|
||||
ExpiresUtc = now + WatchLifetime,
|
||||
};
|
||||
await _repo.UpsertWatchAsync(watch).ConfigureAwait(false);
|
||||
return PushOpResult<SessionWatchResponse>.Success(new SessionWatchResponse { Watching = true, ExpiresUtc = watch.ExpiresUtc });
|
||||
return PushOpResult<SessionWatchResponse>.Success(new SessionWatchResponse { Watching = true, ExpiresUtc = watch.ExpiresUtc, Available = true });
|
||||
}
|
||||
|
||||
public async Task<SessionWatchResponse> 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<SessionWatchResponse> 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),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private async Task<bool> 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;
|
||||
}
|
||||
|
||||
/// <summary>Fail-soft: a watch push must never cost the ingest path anything.</summary>
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -135,10 +135,16 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public bool IsOwn { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>PUT sessions/{sessionId}/watch — "notify my devices when this session ends".</summary>
|
||||
/// <summary>GET/PUT/DELETE sessions/{sessionId}/watch — "notify my devices when this session ends".</summary>
|
||||
public class SessionWatchResponse : IApiResponse
|
||||
{
|
||||
public bool Watching { get; set; }
|
||||
public DateTime? ExpiresUtc { get; set; }
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public bool Available { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<HistoryEntry | null>(null);
|
||||
const [pull, setPull] = useState<PullState>({ 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<string | null>(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() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filterShown && entries && visible && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<label htmlFor="history-filter" className="sr-only">
|
||||
Filter alerts
|
||||
</label>
|
||||
<select
|
||||
id="history-filter"
|
||||
value={selected}
|
||||
onChange={(event) => chooseFilter(event.target.value)}
|
||||
className="flex-1 min-w-0 bg-white border border-gray-300 rounded-lg px-3 py-2 text-sm text-gray-900"
|
||||
>
|
||||
<option value={ALL_FILTER}>All alerts ({entries.length})</option>
|
||||
{groups.map((group) => (
|
||||
<optgroup key={group.group} label={group.label}>
|
||||
{group.options.map((option) => (
|
||||
<option key={option.key} value={option.key}>
|
||||
{option.label} ({option.count})
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
{selected !== ALL_FILTER && (
|
||||
<>
|
||||
<span className="text-xs text-gray-500 shrink-0">
|
||||
{visible.length} of {entries.length}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => chooseFilter(ALL_FILTER)}
|
||||
aria-label="Clear filter"
|
||||
title="Show all alerts"
|
||||
className="shrink-0 w-8 h-8 inline-flex items-center justify-center rounded-lg border border-gray-300 bg-white text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entries && visible && entries.length > 0 && visible.length === 0 && (
|
||||
<div className="bg-white rounded-lg shadow p-6 text-center">
|
||||
<p className="text-sm text-gray-600">No alerts match this filter.</p>
|
||||
<button type="button" onClick={() => chooseFilter(ALL_FILTER)} className="mt-2 text-sm font-medium text-green-700 hover:underline">
|
||||
Show all alerts
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isClient && entries !== null && entries.length === 0 && (
|
||||
<div className="bg-white rounded-lg shadow p-6 text-center">
|
||||
<p className="text-sm text-gray-600">No alerts yet.</p>
|
||||
@@ -223,9 +313,9 @@ export default function PushHistoryPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entries && entries.length > 0 && (
|
||||
{visible && visible.length > 0 && (
|
||||
<ul className="space-y-2">
|
||||
{entries.map((entry) => (
|
||||
{visible.map((entry) => (
|
||||
<EntryCard
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
|
||||
@@ -23,10 +23,12 @@ interface SessionWatchButtonProps {
|
||||
* "Notify me when done" (plan push-relay): registers a watch that pushes to the caller's own
|
||||
* paired devices when this session reaches a terminal status. Hidden once the session is
|
||||
* terminal; the host gates it to Admins/Operators (and Global Admins) like Report Session.
|
||||
* Whether the tenant has a Push channel is not known cheaply here, so the PUT's 409 explains.
|
||||
* The GET says whether watching is possible for this person at all (a Push channel in one of
|
||||
* their own scopes); without that the button stays hidden instead of letting a click fail.
|
||||
*/
|
||||
export default function SessionWatchButton({ sessionId, effectiveTenantId, sessionStatus, getAccessToken, addNotification }: SessionWatchButtonProps) {
|
||||
const [watching, setWatching] = useState<boolean | null>(null);
|
||||
const [available, setAvailable] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const active = sessionId.length > 0 && !!sessionStatus && !TERMINAL_STATUSES.has(sessionStatus);
|
||||
@@ -37,7 +39,9 @@ export default function SessionWatchButton({ sessionId, effectiveTenantId, sessi
|
||||
const run = async () => {
|
||||
try {
|
||||
const result = await getSessionWatch(sessionId, effectiveTenantId, getAccessToken);
|
||||
if (!cancelled) setWatching(result.watching);
|
||||
if (cancelled) return;
|
||||
setWatching(result.watching);
|
||||
setAvailable(result.available !== false);
|
||||
} catch {
|
||||
// Best effort: an unknown state renders as "not watching"; the click reports the real error.
|
||||
if (!cancelled) setWatching(false);
|
||||
@@ -49,7 +53,7 @@ export default function SessionWatchButton({ sessionId, effectiveTenantId, sessi
|
||||
};
|
||||
}, [active, sessionId, effectiveTenantId, getAccessToken]);
|
||||
|
||||
if (!active) return null;
|
||||
if (!active || !available) return null;
|
||||
|
||||
const toggle = async () => {
|
||||
if (busy || watching === null) return;
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ALL_FILTER,
|
||||
applyHistoryFilter,
|
||||
buildFilterOptions,
|
||||
classifyEntry,
|
||||
hasFilterOption,
|
||||
isFilterUseful,
|
||||
normalizeFilterKey,
|
||||
} from "../push/historyFilter";
|
||||
import type { HistoryEntry } from "../push/pushCore";
|
||||
|
||||
const entry = (id: string, type: string, scope = "tenant", facts: { name: string; value: string }[] = []): HistoryEntry => ({
|
||||
id,
|
||||
ts: "2026-10-08T12:00:00.000Z",
|
||||
title: "t",
|
||||
body: "b",
|
||||
type,
|
||||
severity: "info",
|
||||
facts,
|
||||
portalUrl: null,
|
||||
navigate: null,
|
||||
tag: null,
|
||||
scope,
|
||||
generic: false,
|
||||
});
|
||||
|
||||
const history: HistoryEntry[] = [
|
||||
entry("1", "enrollment_failed"),
|
||||
entry("2", "enrollment_failed"),
|
||||
entry("3", "sla_breach"),
|
||||
entry("4", "whats_new"),
|
||||
entry("5", "TenantSignup", "platform", [{ name: "Category", value: "Tenant" }]),
|
||||
entry("6", "CertRotation", "platform", [{ name: "category", value: "Security" }]),
|
||||
entry("7", "test", "platform"),
|
||||
entry("8", "push_test"),
|
||||
entry("9", "push_paired"),
|
||||
entry("10", "session_watch"),
|
||||
];
|
||||
|
||||
describe("classifyEntry", () => {
|
||||
it("puts the receiver's own messages, platform alerts (by Category fact) and tenant alerts into their groups", () => {
|
||||
expect(classifyEntry(entry("a", "push_paused"))).toEqual({ group: "device", kind: "push_paused", label: "Paused" });
|
||||
expect(classifyEntry(entry("b", "X", "platform", [{ name: "Category", value: "Consent" }]))).toEqual({ group: "platform", kind: "consent", label: "Consent" });
|
||||
expect(classifyEntry(entry("c", "test", "platform"))).toEqual({ group: "platform", kind: "other", label: "Other" });
|
||||
expect(classifyEntry(entry("d", "enrollment_succeeded"))).toEqual({ group: "enrollment", kind: "enrollment_succeeded", label: "Enrollment succeeded" });
|
||||
expect(classifyEntry(entry("e", "some_new_kind"))).toEqual({ group: "enrollment", kind: "some_new_kind", label: "Some new kind" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildFilterOptions", () => {
|
||||
it("offers only the groups and kinds the history holds, ordered Platform / Enrollment / This device, with counts", () => {
|
||||
const groups = buildFilterOptions(history);
|
||||
expect(groups.map((g) => g.label)).toEqual(["Platform", "Enrollment", "This device"]);
|
||||
expect(groups[0].options.map((o) => [o.label, o.count])).toEqual([
|
||||
["Other", 1],
|
||||
["Security", 1],
|
||||
["Tenant", 1],
|
||||
]);
|
||||
// Locale order: "Session watch" sorts before "SLA breach" (letters compare before case).
|
||||
expect(groups[1].options.map((o) => [o.label, o.count])).toEqual([
|
||||
["Enrollment failed", 2],
|
||||
["Session watch", 1],
|
||||
["SLA breach", 1],
|
||||
["What's new", 1],
|
||||
]);
|
||||
expect(groups[2].options.map((o) => o.key)).toEqual(["device:push_paired", "device:push_test"]);
|
||||
});
|
||||
|
||||
it("has no Platform group for a customer's device and hides the dropdown for a single kind", () => {
|
||||
const tenantOnly = buildFilterOptions([entry("1", "enrollment_failed"), entry("2", "sla_breach")]);
|
||||
expect(tenantOnly.map((g) => g.group)).toEqual(["enrollment"]);
|
||||
expect(isFilterUseful(tenantOnly)).toBe(true);
|
||||
expect(isFilterUseful(buildFilterOptions([entry("1", "enrollment_failed"), entry("2", "enrollment_failed")]))).toBe(false);
|
||||
expect(isFilterUseful(buildFilterOptions([]))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyHistoryFilter", () => {
|
||||
it("keeps the matching entries for a key, everything for all, and everything for a key the history no longer has", () => {
|
||||
expect(applyHistoryFilter(history, "enrollment:enrollment_failed").map((e) => e.id)).toEqual(["1", "2"]);
|
||||
expect(applyHistoryFilter(history, "platform:tenant").map((e) => e.id)).toEqual(["5"]);
|
||||
expect(applyHistoryFilter(history, "device:push_test").map((e) => e.id)).toEqual(["8"]);
|
||||
expect(applyHistoryFilter(history, ALL_FILTER)).toBe(history);
|
||||
expect(applyHistoryFilter(history, "platform:security-gone")).toBe(history);
|
||||
});
|
||||
|
||||
it("knows which keys the current history offers", () => {
|
||||
const groups = buildFilterOptions(history);
|
||||
expect(hasFilterOption(groups, ALL_FILTER)).toBe(true);
|
||||
expect(hasFilterOption(groups, "platform:security")).toBe(true);
|
||||
expect(hasFilterOption(groups, "platform:consent")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeFilterKey", () => {
|
||||
it("accepts well-formed stored keys and maps everything else to all", () => {
|
||||
expect(normalizeFilterKey("enrollment:sla_breach")).toBe("enrollment:sla_breach");
|
||||
expect(normalizeFilterKey(" platform:tenant ")).toBe("platform:tenant");
|
||||
for (const raw of [undefined, null, "", "all", "bogus", "platform:", "other:x", "platform:a:b", 7]) {
|
||||
expect(normalizeFilterKey(raw), String(raw)).toBe(ALL_FILTER);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { HistoryEntry } from "./pushCore";
|
||||
|
||||
/**
|
||||
* The history page's filter: one dropdown whose entries come from what the history holds, so a
|
||||
* customer's device (tenant pushes only) never sees a "Platform" group and an empty category is
|
||||
* never offered. Three groups: Platform (ops alerts, one entry per Category fact), Enrollment
|
||||
* (the tenant alerts, one entry per alert kind) and This device (the receiver's own messages).
|
||||
* Pure: the page applies the result and stores the chosen key on the device.
|
||||
*/
|
||||
|
||||
export type FilterGroup = "platform" | "enrollment" | "device";
|
||||
|
||||
/** The "show everything" key; any other key is `${group}:${kind}`. */
|
||||
export const ALL_FILTER = "all";
|
||||
|
||||
export interface FilterOption {
|
||||
key: string;
|
||||
label: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface FilterGroupOptions {
|
||||
group: FilterGroup;
|
||||
label: string;
|
||||
options: FilterOption[];
|
||||
}
|
||||
|
||||
const GROUP_ORDER: FilterGroup[] = ["platform", "enrollment", "device"];
|
||||
|
||||
const GROUP_LABELS: Record<FilterGroup, string> = {
|
||||
platform: "Platform",
|
||||
enrollment: "Enrollment",
|
||||
device: "This device",
|
||||
};
|
||||
|
||||
/** Tenant alert kinds as the backend names them (NotificationAlert.EventType). */
|
||||
const ENROLLMENT_LABELS: Record<string, string> = {
|
||||
enrollment_started: "Enrollment started",
|
||||
enrollment_succeeded: "Enrollment succeeded",
|
||||
enrollment_failed: "Enrollment failed",
|
||||
enrollment_complete: "Enrollment complete",
|
||||
enrollment_end: "Enrollment ended",
|
||||
session_stalled: "Session stalled",
|
||||
session_watch: "Session watch",
|
||||
consecutive_failures: "Consecutive failures",
|
||||
hardware_rejected: "Hardware rejected",
|
||||
sla_breach: "SLA breach",
|
||||
sla_resolved: "SLA resolved",
|
||||
analyze_rule_fired: "Rule findings",
|
||||
whats_new: "What's new",
|
||||
test: "Test",
|
||||
};
|
||||
|
||||
/** The receiver's own messages (PushAlertProjector.SystemMessage types). */
|
||||
const DEVICE_LABELS: Record<string, string> = {
|
||||
push_paired: "Paired",
|
||||
push_paused: "Paused",
|
||||
push_muted: "Muted",
|
||||
push_revoked: "Unpaired",
|
||||
push_test: "Test",
|
||||
};
|
||||
|
||||
const DEVICE_TYPE_PREFIX = "push_";
|
||||
|
||||
export interface EntryClass {
|
||||
group: FilterGroup;
|
||||
kind: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function humanize(value: string): string {
|
||||
const words = value.replace(/[_-]+/g, " ").trim();
|
||||
return words === "" ? "Other" : words.charAt(0).toUpperCase() + words.slice(1);
|
||||
}
|
||||
|
||||
function categoryOf(entry: HistoryEntry): string {
|
||||
const fact = entry.facts.find((f) => f.name.toLowerCase() === "category");
|
||||
return fact ? fact.value.trim() : "";
|
||||
}
|
||||
|
||||
export function classifyEntry(entry: HistoryEntry): EntryClass {
|
||||
if (entry.type.startsWith(DEVICE_TYPE_PREFIX)) {
|
||||
return { group: "device", kind: entry.type, label: DEVICE_LABELS[entry.type] ?? humanize(entry.type.slice(DEVICE_TYPE_PREFIX.length)) };
|
||||
}
|
||||
if (entry.scope === "platform") {
|
||||
const category = categoryOf(entry);
|
||||
return { group: "platform", kind: category === "" ? "other" : category.toLowerCase(), label: category === "" ? "Other" : category };
|
||||
}
|
||||
return { group: "enrollment", kind: entry.type, label: ENROLLMENT_LABELS[entry.type] ?? humanize(entry.type) };
|
||||
}
|
||||
|
||||
export function filterKeyOf(group: FilterGroup, kind: string): string {
|
||||
return `${group}:${kind}`;
|
||||
}
|
||||
|
||||
/** The groups and options the dropdown offers for this history, non-empty ones only, counts included. */
|
||||
export function buildFilterOptions(entries: HistoryEntry[]): FilterGroupOptions[] {
|
||||
const byGroup = new Map<FilterGroup, Map<string, FilterOption>>();
|
||||
for (const entry of entries) {
|
||||
const c = classifyEntry(entry);
|
||||
const key = filterKeyOf(c.group, c.kind);
|
||||
let options = byGroup.get(c.group);
|
||||
if (!options) {
|
||||
options = new Map();
|
||||
byGroup.set(c.group, options);
|
||||
}
|
||||
const existing = options.get(key);
|
||||
if (existing) existing.count += 1;
|
||||
else options.set(key, { key, label: c.label, count: 1 });
|
||||
}
|
||||
return GROUP_ORDER.filter((group) => byGroup.has(group)).map((group) => ({
|
||||
group,
|
||||
label: GROUP_LABELS[group],
|
||||
options: [...byGroup.get(group)!.values()].sort((a, b) => a.label.localeCompare(b.label)),
|
||||
}));
|
||||
}
|
||||
|
||||
export function hasFilterOption(groups: FilterGroupOptions[], key: string): boolean {
|
||||
return key === ALL_FILTER || groups.some((g) => g.options.some((o) => o.key === key));
|
||||
}
|
||||
|
||||
/** More than one option across the groups — otherwise there is nothing to filter and the dropdown stays hidden. */
|
||||
export function isFilterUseful(groups: FilterGroupOptions[]): boolean {
|
||||
return groups.reduce((n, g) => n + g.options.length, 0) > 1;
|
||||
}
|
||||
|
||||
/** The entries a key keeps; a key the history has no option for (stale preference) keeps everything. */
|
||||
export function applyHistoryFilter(entries: HistoryEntry[], key: string, groups = buildFilterOptions(entries)): HistoryEntry[] {
|
||||
if (key === ALL_FILTER || !hasFilterOption(groups, key)) return entries;
|
||||
return entries.filter((entry) => {
|
||||
const c = classifyEntry(entry);
|
||||
return filterKeyOf(c.group, c.kind) === key;
|
||||
});
|
||||
}
|
||||
|
||||
/** A stored preference, or "all" for anything that is not a well-formed key. */
|
||||
export function normalizeFilterKey(raw: unknown): string {
|
||||
if (typeof raw !== "string") return ALL_FILTER;
|
||||
const value = raw.trim();
|
||||
if (value === ALL_FILTER) return ALL_FILTER;
|
||||
return /^(platform|enrollment|device):[^\s:]{1,64}$/.test(value) ? value : ALL_FILTER;
|
||||
}
|
||||
@@ -64,6 +64,8 @@ export const META_KEYS = /** @type {const} */ ({
|
||||
apiBaseUrl: "apiBaseUrl",
|
||||
/** Days the history is kept (string of an integer 0–365; 0 = until the entry cap; absent = default). */
|
||||
historyRetentionDays: "historyRetentionDays",
|
||||
/** The history page's chosen filter key ("all" or "<group>:<kind>"); absent = all. */
|
||||
historyFilter: "historyFilter",
|
||||
});
|
||||
|
||||
/** @typedef {(typeof PLATFORMS)[number]} Platform */
|
||||
|
||||
@@ -20784,7 +20784,7 @@
|
||||
},
|
||||
"SessionWatchResponse": {
|
||||
"kind": "object",
|
||||
"doc": "PUT sessions/{sessionId}/watch — \"notify my devices when this session ends\".",
|
||||
"doc": "GET/PUT/DELETE sessions/{sessionId}/watch — \"notify my devices when this session ends\".",
|
||||
"fields": [
|
||||
{
|
||||
"name": "watching",
|
||||
@@ -20800,6 +20800,14 @@
|
||||
"name": "string"
|
||||
},
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"name": "available",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "boolean"
|
||||
},
|
||||
"doc": "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."
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user