@camstack/ui-library 1.2.242 → 1.2.244
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/composites/camera-stream-player.d.ts +17 -1
- package/dist/composites/index.d.ts +15 -0
- package/dist/composites/peer-connection-config.d.ts +13 -0
- package/dist/composites/webrtc-diagnostics/EndpointTestCard.d.ts +15 -0
- package/dist/composites/webrtc-diagnostics/HubSessionTestCard.d.ts +18 -0
- package/dist/composites/webrtc-diagnostics/RunReport.d.ts +6 -0
- package/dist/composites/webrtc-diagnostics/endpoint-probe.d.ts +36 -0
- package/dist/composites/webrtc-diagnostics/endpoint-test.d.ts +54 -0
- package/dist/composites/webrtc-diagnostics/format.d.ts +11 -0
- package/dist/composites/webrtc-diagnostics/ice-candidates.d.ts +66 -0
- package/dist/composites/webrtc-diagnostics/nat-mapping.d.ts +44 -0
- package/dist/composites/webrtc-diagnostics/session-run.d.ts +125 -0
- package/dist/composites/webrtc-diagnostics/session-verdict.d.ts +29 -0
- package/dist/generated/system-hooks.d.ts +2 -0
- package/dist/index.cjs +2457 -875
- package/dist/index.js +2441 -876
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -8048,6 +8048,8 @@ var usePipelineOrchestratorSetCameraStepToggle = trpc.pipelineOrchestrator.setCa
|
|
|
8048
8048
|
var usePipelineOrchestratorGetCameraStepOverrides = trpc.pipelineOrchestrator.getCameraStepOverrides.useQuery;
|
|
8049
8049
|
/** Generated alias around `trpc.pipelineOrchestrator.setCameraStepOverride.useMutation`. */
|
|
8050
8050
|
var usePipelineOrchestratorSetCameraStepOverride = trpc.pipelineOrchestrator.setCameraStepOverride.useMutation;
|
|
8051
|
+
/** Generated alias around `trpc.pipelineOrchestrator.setCameraStepSettings.useMutation`. */
|
|
8052
|
+
var usePipelineOrchestratorSetCameraStepSettings = trpc.pipelineOrchestrator.setCameraStepSettings.useMutation;
|
|
8051
8053
|
/** Generated alias around `trpc.pipelineOrchestrator.setCameraPipelineForAgent.useMutation`. */
|
|
8052
8054
|
var usePipelineOrchestratorSetCameraPipelineForAgent = trpc.pipelineOrchestrator.setCameraPipelineForAgent.useMutation;
|
|
8053
8055
|
/** Generated alias around `trpc.pipelineOrchestrator.resolvePipeline.useQuery`. */
|
|
@@ -31635,6 +31637,27 @@ function nextClientSample(prev, cur, ctx) {
|
|
|
31635
31637
|
};
|
|
31636
31638
|
}
|
|
31637
31639
|
//#endregion
|
|
31640
|
+
//#region src/composites/peer-connection-config.ts
|
|
31641
|
+
/**
|
|
31642
|
+
* The player's `RTCConfiguration`, in one place for every connect path.
|
|
31643
|
+
*
|
|
31644
|
+
* The player never forces `iceTransportPolicy: 'relay'` on its own (werift's
|
|
31645
|
+
* relay forward is unreliable across two real TURN servers, and relay would
|
|
31646
|
+
* defeat the direct LAN/Tailscale path). The policy exists for the WebRTC
|
|
31647
|
+
* diagnostics only, which run the SAME player once with `all` and once with
|
|
31648
|
+
* `relay` to tell a reachable relay path from a dead one. Absent, the key is
|
|
31649
|
+
* not set at all, so a production connect is byte-for-byte what it was.
|
|
31650
|
+
*/
|
|
31651
|
+
/** Public STUN kept as a fallback beside whatever the hub's TURN provider returns. */
|
|
31652
|
+
var FALLBACK_STUN_SERVER = { urls: "stun:stun.l.google.com:19302" };
|
|
31653
|
+
function buildPeerConnectionConfig(iceServers, iceTransportPolicy) {
|
|
31654
|
+
const servers = [...iceServers ?? [], FALLBACK_STUN_SERVER];
|
|
31655
|
+
return iceTransportPolicy === void 0 ? { iceServers: servers } : {
|
|
31656
|
+
iceServers: servers,
|
|
31657
|
+
iceTransportPolicy
|
|
31658
|
+
};
|
|
31659
|
+
}
|
|
31660
|
+
//#endregion
|
|
31638
31661
|
//#region src/composites/stream-debug/webrtc-debug-stats.ts
|
|
31639
31662
|
/** Beyond this the log is a scroll, not a signal. */
|
|
31640
31663
|
var RESOLUTION_LOG_MAX = 8;
|
|
@@ -32358,7 +32381,7 @@ function mergePlayerHints(autoHints, override, downlinkMbps, coldStart) {
|
|
|
32358
32381
|
return coldStart ? stripDownlinkForColdStart(merged) : merged;
|
|
32359
32382
|
}
|
|
32360
32383
|
var FIRST_FRAME_TIMEOUT_MS = 8e3;
|
|
32361
|
-
function CameraStreamPlayer({ serverUrl, streamKey, label, autoPlay = true, muted: initialMuted = true, showControls = true, showStats = false, onDebugStats, onPlaybackStats, onClientNetworkSample, probeAuthToken, onConnectTiming, className = "", onStateChange, onError, onReconnectAttempt, overlay, createSession, sendAnswer, handleOffer, getIceServers, addIceCandidate, getIceCandidates, closeSession, getSessionState, getSessionDebug, applyDownlinkProbe, reoffer, posterUrl, hintsOverride, onAdaptiveHints, reconnectSignal, onControlChannel, onVideoElement }) {
|
|
32384
|
+
function CameraStreamPlayer({ serverUrl, streamKey, label, autoPlay = true, muted: initialMuted = true, showControls = true, showStats = false, onDebugStats, onPlaybackStats, onClientNetworkSample, probeAuthToken, onConnectTiming, className = "", onStateChange, onError, onReconnectAttempt, overlay, createSession, sendAnswer, handleOffer, getIceServers, addIceCandidate, getIceCandidates, closeSession, getSessionState, getSessionDebug, applyDownlinkProbe, reoffer, posterUrl, hintsOverride, onAdaptiveHints, reconnectSignal, onControlChannel, onVideoElement, iceTransportPolicy, onPeerConnection }) {
|
|
32362
32385
|
const videoRef = (0, react$1.useRef)(null);
|
|
32363
32386
|
const containerRef = (0, react$1.useRef)(null);
|
|
32364
32387
|
const pcRef = (0, react$1.useRef)(null);
|
|
@@ -32374,6 +32397,12 @@ function CameraStreamPlayer({ serverUrl, streamKey, label, autoPlay = true, mute
|
|
|
32374
32397
|
* consumer, without re-creating the connect callback on every render. */
|
|
32375
32398
|
const onControlChannelRef = (0, react$1.useRef)(onControlChannel);
|
|
32376
32399
|
onControlChannelRef.current = onControlChannel;
|
|
32400
|
+
/** Read at connect time through refs so neither diagnostics prop re-creates
|
|
32401
|
+
* the connect callbacks (and so neither forces a reconnect when it changes). */
|
|
32402
|
+
const iceTransportPolicyRef = (0, react$1.useRef)(iceTransportPolicy);
|
|
32403
|
+
iceTransportPolicyRef.current = iceTransportPolicy;
|
|
32404
|
+
const onPeerConnectionRef = (0, react$1.useRef)(onPeerConnection);
|
|
32405
|
+
onPeerConnectionRef.current = onPeerConnection;
|
|
32377
32406
|
/** Same pattern for the video-element handle: delivered once on mount, null
|
|
32378
32407
|
* on unmount — the ref keeps the latest consumer without re-running. */
|
|
32379
32408
|
const onVideoElementRef = (0, react$1.useRef)(onVideoElement);
|
|
@@ -32583,7 +32612,8 @@ function CameraStreamPlayer({ serverUrl, streamKey, label, autoPlay = true, mute
|
|
|
32583
32612
|
sessionIdsRef.current.delete(sessionId);
|
|
32584
32613
|
return;
|
|
32585
32614
|
}
|
|
32586
|
-
const pc = new RTCPeerConnection(
|
|
32615
|
+
const pc = new RTCPeerConnection(buildPeerConnectionConfig(iceServers, iceTransportPolicyRef.current));
|
|
32616
|
+
onPeerConnectionRef.current?.(pc);
|
|
32587
32617
|
if (!seamless) pcRef.current = pc;
|
|
32588
32618
|
const combinedStream = new MediaStream();
|
|
32589
32619
|
if (!withDownlink) probeAfterThisStreamsFirstFrame({
|
|
@@ -32784,7 +32814,8 @@ function CameraStreamPlayer({ serverUrl, streamKey, label, autoPlay = true, mute
|
|
|
32784
32814
|
const iceServers = await getIceServers?.();
|
|
32785
32815
|
timing.iceServersMs = ms();
|
|
32786
32816
|
if (!mountedRef.current || abort.signal.aborted) return;
|
|
32787
|
-
const pc = new RTCPeerConnection(
|
|
32817
|
+
const pc = new RTCPeerConnection(buildPeerConnectionConfig(iceServers, iceTransportPolicyRef.current));
|
|
32818
|
+
onPeerConnectionRef.current?.(pc);
|
|
32788
32819
|
if (!seamless) pcRef.current = pc;
|
|
32789
32820
|
const combinedStream = new MediaStream();
|
|
32790
32821
|
/** This connect's server session — known once `handleOffer` answers,
|
|
@@ -32990,7 +33021,8 @@ function CameraStreamPlayer({ serverUrl, streamKey, label, autoPlay = true, mute
|
|
|
32990
33021
|
try {
|
|
32991
33022
|
const iceServers = await getIceServers?.();
|
|
32992
33023
|
if (!mountedRef.current) return;
|
|
32993
|
-
const pc = new RTCPeerConnection(
|
|
33024
|
+
const pc = new RTCPeerConnection(buildPeerConnectionConfig(iceServers, iceTransportPolicyRef.current));
|
|
33025
|
+
onPeerConnectionRef.current?.(pc);
|
|
32994
33026
|
const newStream = new MediaStream();
|
|
32995
33027
|
let swappedIn = false;
|
|
32996
33028
|
let warmupStarted = false;
|
|
@@ -33830,281 +33862,6 @@ async function waitForIceGathering(pc) {
|
|
|
33830
33862
|
});
|
|
33831
33863
|
}
|
|
33832
33864
|
//#endregion
|
|
33833
|
-
//#region src/composites/cap-settings/scene-monitor-copy.ts
|
|
33834
|
-
/** The lighting variants an operator is OFFERED. The resolver can also report
|
|
33835
|
-
* `night`, `dawn` and `dusk`; those are shown when present but never demanded,
|
|
33836
|
-
* because most installs only ever see day and IR. */
|
|
33837
|
-
var SCENE_CAPTURE_VARIANTS = ["day", "ir"];
|
|
33838
|
-
var VARIANT_LABEL = {
|
|
33839
|
-
day: "Daylight",
|
|
33840
|
-
ir: "Night (IR)",
|
|
33841
|
-
night: "Night",
|
|
33842
|
-
dawn: "Dawn",
|
|
33843
|
-
dusk: "Dusk"
|
|
33844
|
-
};
|
|
33845
|
-
function variantLabel(condition) {
|
|
33846
|
-
return VARIANT_LABEL[condition] ?? condition;
|
|
33847
|
-
}
|
|
33848
|
-
/**
|
|
33849
|
-
* "Daylight ✓ · Night (IR) ✗ — optional; checks in night (ir) are skipped."
|
|
33850
|
-
*
|
|
33851
|
-
* Converts the silent cross-condition fallback this engine used to have into an
|
|
33852
|
-
* operator-visible fact. Without it, a scene with only a daylight reference
|
|
33853
|
-
* simply stops answering after sunset and nothing anywhere says why.
|
|
33854
|
-
*
|
|
33855
|
-
* It used to end *"reports 'can't tell' in night (ir)"*, which was accurate and
|
|
33856
|
-
* read as a defect: the operator saw a ✗ and a complaint for a scene that was
|
|
33857
|
-
* configured exactly as they meant it. The missing half is now named as what it
|
|
33858
|
-
* is — the part you do not have to do — and the sentence says what actually
|
|
33859
|
-
* happens instead, which differs by the scene's own policy.
|
|
33860
|
-
*/
|
|
33861
|
-
function coverageLine(monitor) {
|
|
33862
|
-
const covered = new Set(monitor.coveredConditions);
|
|
33863
|
-
const parts = SCENE_CAPTURE_VARIANTS.map((v) => `${variantLabel(v)} ${covered.has(v) ? "✓" : "✗"}`);
|
|
33864
|
-
const extra = [...covered].filter((c) => !SCENE_CAPTURE_VARIANTS.some((v) => v === c));
|
|
33865
|
-
for (const c of extra) parts.push(`${variantLabel(c)} ✓`);
|
|
33866
|
-
const missing = SCENE_CAPTURE_VARIANTS.filter((v) => !covered.has(v));
|
|
33867
|
-
if (missing.length === 0) return parts.join(" · ");
|
|
33868
|
-
if (covered.size === 0) return `${parts.join(" · ")} — nothing captured yet, so this scene is not watching.`;
|
|
33869
|
-
const names = missing.map((v) => variantLabel(v).toLowerCase()).join(" and ");
|
|
33870
|
-
return monitor.onUncoveredCondition === "judge-anyway" ? `${parts.join(" · ")} — ${names} judged against the references you do have.` : `${parts.join(" · ")} — optional; checks in ${names} are skipped.`;
|
|
33871
|
-
}
|
|
33872
|
-
/**
|
|
33873
|
-
* The calm sentence for a scene that is sitting the current light out — `null`
|
|
33874
|
-
* whenever it is checking normally.
|
|
33875
|
-
*
|
|
33876
|
-
* Separate from {@link statusLine} because it is not a verdict and must not
|
|
33877
|
-
* read like one. A paused scene has not failed, has not lost its flag, and
|
|
33878
|
-
* needs nothing from the operator; the only thing worth saying is which light
|
|
33879
|
-
* it is waiting out and that it will pick itself back up.
|
|
33880
|
-
*/
|
|
33881
|
-
function suspensionLine(monitor) {
|
|
33882
|
-
if (monitor.suspendedCondition === null) return null;
|
|
33883
|
-
const light = variantLabel(monitor.suspendedCondition).toLowerCase();
|
|
33884
|
-
return `Paused — nothing was captured in ${light}, so checks are on hold until the light changes back. Capture ${light} if you want it watched around the clock.`;
|
|
33885
|
-
}
|
|
33886
|
-
var UNAVAILABLE_SENTENCE = {
|
|
33887
|
-
"no-reference-for-condition": "can’t tell — nothing has been captured for this scene yet. Press “Capture daylight” to photograph what normal looks like.",
|
|
33888
|
-
"view-shifted": "can’t tell — the camera view has moved since the reference was captured. Re-capture it.",
|
|
33889
|
-
"no-vision-profile": "can’t tell — the vision model is unreachable, so the change is on hold.",
|
|
33890
|
-
"encoder-model-changed": "can’t tell — the embedding model changed, so the stored references are unreadable. Re-capture them.",
|
|
33891
|
-
"no-snapshot": "can’t tell — this camera did not return a frame."
|
|
33892
|
-
};
|
|
33893
|
-
/**
|
|
33894
|
-
* The one line under a scene's name. It answers, in order: can it judge, what
|
|
33895
|
-
* does it say, and how long has it said it.
|
|
33896
|
-
*/
|
|
33897
|
-
function statusLine(monitor, now) {
|
|
33898
|
-
if (monitor.states.length === 0) return "Not armed yet — capture the initial state to start watching.";
|
|
33899
|
-
if (monitor.verdict === "unknown") return monitor.unavailable !== null ? UNAVAILABLE_SENTENCE[monitor.unavailable] : "can’t tell right now.";
|
|
33900
|
-
const since = monitor.verdict === "diverged" ? monitor.divergedAt : monitor.armedAt;
|
|
33901
|
-
const held = since !== null ? ` for ${humanDuration(Math.max(0, now - since))}` : "";
|
|
33902
|
-
if (monitor.latched) return monitor.verdict === "diverged" ? `CHANGED${held} — press Reset when you have dealt with it.` : `Back to normal, but still flagged as changed${held}. Press Reset to clear it.`;
|
|
33903
|
-
const paused = suspensionLine(monitor);
|
|
33904
|
-
if (paused !== null) return paused;
|
|
33905
|
-
return monitor.verdict === "diverged" ? `CHANGED${held}` : `Normal${held}`;
|
|
33906
|
-
}
|
|
33907
|
-
/** The badge word. `latched` wins over `verdict`, because the latch is the
|
|
33908
|
-
* thing the operator asked to be told and it outlives the live state.
|
|
33909
|
-
*
|
|
33910
|
-
* A scene sitting out an uncovered light reads `Paused`, in the NEUTRAL tone —
|
|
33911
|
-
* never `alarm`, and never the `danger` variant the page maps that to. A
|
|
33912
|
-
* missing IR reference must not be able to paint a red badge on a scene that
|
|
33913
|
-
* is doing exactly what it was configured to do. */
|
|
33914
|
-
function badgeFor(monitor) {
|
|
33915
|
-
if (monitor.states.length === 0) return {
|
|
33916
|
-
text: "Not armed",
|
|
33917
|
-
tone: "unknown"
|
|
33918
|
-
};
|
|
33919
|
-
if (monitor.latched) return {
|
|
33920
|
-
text: "Changed",
|
|
33921
|
-
tone: "alarm"
|
|
33922
|
-
};
|
|
33923
|
-
if (monitor.suspendedCondition !== null) return {
|
|
33924
|
-
text: "Paused",
|
|
33925
|
-
tone: "unknown"
|
|
33926
|
-
};
|
|
33927
|
-
if (monitor.verdict === "unknown") return {
|
|
33928
|
-
text: "Can’t tell",
|
|
33929
|
-
tone: "unknown"
|
|
33930
|
-
};
|
|
33931
|
-
return monitor.verdict === "diverged" ? {
|
|
33932
|
-
text: "Changed",
|
|
33933
|
-
tone: "alarm"
|
|
33934
|
-
} : {
|
|
33935
|
-
text: "Normal",
|
|
33936
|
-
tone: "ok"
|
|
33937
|
-
};
|
|
33938
|
-
}
|
|
33939
|
-
/** A rect covering most of the frame is dominated by weather, sky and parked
|
|
33940
|
-
* cars: the bin is 2 % of the pixels and CLIP will not notice it leaving. The
|
|
33941
|
-
* failure is silent INSENSITIVITY, which is the worst kind — hence a warning
|
|
33942
|
-
* rather than a refusal. */
|
|
33943
|
-
var ROI_AREA_WARN_FRACTION = .4;
|
|
33944
|
-
function roiTooLarge(roi) {
|
|
33945
|
-
return roi.width * roi.height > ROI_AREA_WARN_FRACTION;
|
|
33946
|
-
}
|
|
33947
|
-
function humanDuration(ms) {
|
|
33948
|
-
const s = Math.floor(ms / 1e3);
|
|
33949
|
-
if (s < 60) return `${s}s`;
|
|
33950
|
-
const m = Math.floor(s / 60);
|
|
33951
|
-
if (m < 60) return `${m}m`;
|
|
33952
|
-
const h = Math.floor(m / 60);
|
|
33953
|
-
if (h < 48) return `${h}h`;
|
|
33954
|
-
return `${Math.floor(h / 24)}d`;
|
|
33955
|
-
}
|
|
33956
|
-
//#endregion
|
|
33957
|
-
//#region src/composites/code-block.tsx
|
|
33958
|
-
function CodeBlock({ children, maxHeight = 300, className }) {
|
|
33959
|
-
const [copied, setCopied] = (0, react$1.useState)(false);
|
|
33960
|
-
const handleCopy = (0, react$1.useCallback)(() => {
|
|
33961
|
-
navigator.clipboard.writeText(children).then(() => {
|
|
33962
|
-
setCopied(true);
|
|
33963
|
-
setTimeout(() => setCopied(false), 2e3);
|
|
33964
|
-
});
|
|
33965
|
-
}, [children]);
|
|
33966
|
-
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
33967
|
-
className: cn("relative group", className),
|
|
33968
|
-
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(ScrollArea, {
|
|
33969
|
-
style: { maxHeight },
|
|
33970
|
-
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("pre", {
|
|
33971
|
-
className: "font-mono text-xs bg-surface p-3 rounded-md border border-border-subtle",
|
|
33972
|
-
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", { children })
|
|
33973
|
-
})
|
|
33974
|
-
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
33975
|
-
className: "absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity",
|
|
33976
|
-
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconButton, {
|
|
33977
|
-
icon: copied ? Check : Copy,
|
|
33978
|
-
"aria-label": "Copy code",
|
|
33979
|
-
variant: "ghost",
|
|
33980
|
-
size: "sm",
|
|
33981
|
-
onClick: handleCopy
|
|
33982
|
-
})
|
|
33983
|
-
})]
|
|
33984
|
-
});
|
|
33985
|
-
}
|
|
33986
|
-
//#endregion
|
|
33987
|
-
//#region src/composites/confirm-action-button.tsx
|
|
33988
|
-
/**
|
|
33989
|
-
* ConfirmActionButton — destructive-action button with a modal confirm.
|
|
33990
|
-
*
|
|
33991
|
-
* Two-step UX for any operation operators shouldn't trigger by accident:
|
|
33992
|
-
* reboot device, restart addon, regenerate token, etc. The trigger is a
|
|
33993
|
-
* normal Button that opens a Dialog; confirming runs the async action,
|
|
33994
|
-
* the trigger spinner-disables itself for the duration, and the dialog
|
|
33995
|
-
* closes on success. Errors surface inline at the dialog's bottom.
|
|
33996
|
-
*
|
|
33997
|
-
* Generic on `TResult` so callers don't have to discard the return
|
|
33998
|
-
* value. Pass an `icon` to render it inside the trigger (e.g. RotateCw
|
|
33999
|
-
* for reboot, RefreshCw for restart).
|
|
34000
|
-
*/
|
|
34001
|
-
function ConfirmActionButton({ label, icon: Icon, title = "Confirm action", description, confirmLabel, triggerVariant = "outline", confirmVariant = "danger", size = "sm", disabled, className, action, onSuccess }) {
|
|
34002
|
-
const [open, setOpen] = (0, react$1.useState)(false);
|
|
34003
|
-
const [running, setRunning] = (0, react$1.useState)(false);
|
|
34004
|
-
const [error, setError] = (0, react$1.useState)(null);
|
|
34005
|
-
const handleConfirm = async () => {
|
|
34006
|
-
setError(null);
|
|
34007
|
-
setRunning(true);
|
|
34008
|
-
try {
|
|
34009
|
-
const result = await action();
|
|
34010
|
-
onSuccess?.(result);
|
|
34011
|
-
setOpen(false);
|
|
34012
|
-
} catch (err) {
|
|
34013
|
-
setError(err instanceof Error ? err.message : String(err));
|
|
34014
|
-
} finally {
|
|
34015
|
-
setRunning(false);
|
|
34016
|
-
}
|
|
34017
|
-
};
|
|
34018
|
-
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Button, {
|
|
34019
|
-
type: "button",
|
|
34020
|
-
variant: triggerVariant,
|
|
34021
|
-
size,
|
|
34022
|
-
disabled,
|
|
34023
|
-
onClick: () => {
|
|
34024
|
-
setError(null);
|
|
34025
|
-
setOpen(true);
|
|
34026
|
-
},
|
|
34027
|
-
className,
|
|
34028
|
-
children: [Icon && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon, { className: "h-3.5 w-3.5" }), label]
|
|
34029
|
-
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Dialog, {
|
|
34030
|
-
open,
|
|
34031
|
-
onOpenChange: (next) => !running && setOpen(next),
|
|
34032
|
-
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(DialogContent, { children: [
|
|
34033
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(DialogHeader, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(DialogTitle, {
|
|
34034
|
-
className: "flex items-center gap-2",
|
|
34035
|
-
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(TriangleAlert, { className: "h-4 w-4 text-amber-400" }), title]
|
|
34036
|
-
}), typeof description === "string" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DialogDescription, { children: description }) : description] }),
|
|
34037
|
-
error && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
34038
|
-
className: cn("mt-2 px-3 py-2 rounded border border-danger/40 bg-danger/10", "text-xs text-danger"),
|
|
34039
|
-
children: error
|
|
34040
|
-
}),
|
|
34041
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(DialogFooter, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
|
|
34042
|
-
type: "button",
|
|
34043
|
-
variant: "ghost",
|
|
34044
|
-
size,
|
|
34045
|
-
disabled: running,
|
|
34046
|
-
onClick: () => setOpen(false),
|
|
34047
|
-
children: "Cancel"
|
|
34048
|
-
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Button, {
|
|
34049
|
-
type: "button",
|
|
34050
|
-
variant: confirmVariant,
|
|
34051
|
-
size,
|
|
34052
|
-
disabled: running,
|
|
34053
|
-
onClick: () => {
|
|
34054
|
-
handleConfirm();
|
|
34055
|
-
},
|
|
34056
|
-
children: [running && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LoaderCircle, { className: "h-3.5 w-3.5 animate-spin" }), confirmLabel ?? label]
|
|
34057
|
-
})] })
|
|
34058
|
-
] })
|
|
34059
|
-
})] });
|
|
34060
|
-
}
|
|
34061
|
-
//#endregion
|
|
34062
|
-
//#region src/composites/copy-button.tsx
|
|
34063
|
-
/**
|
|
34064
|
-
* `CopyButton` — a compact button that copies a string `value` to the
|
|
34065
|
-
* clipboard via `navigator.clipboard.writeText`, showing a brief
|
|
34066
|
-
* "copied" check state. Addon-agnostic; used wherever a secret / URL
|
|
34067
|
-
* needs a one-click copy affordance (export setup panels, etc.).
|
|
34068
|
-
*/
|
|
34069
|
-
var COPIED_RESET_MS = 2e3;
|
|
34070
|
-
function CopyButton({ value, label, srLabel, className, disabled }) {
|
|
34071
|
-
const [copied, setCopied] = (0, react$1.useState)(false);
|
|
34072
|
-
const handleCopy = (0, react$1.useCallback)(() => {
|
|
34073
|
-
if (!value) return;
|
|
34074
|
-
navigator.clipboard.writeText(value).then(() => {
|
|
34075
|
-
setCopied(true);
|
|
34076
|
-
setTimeout(() => setCopied(false), COPIED_RESET_MS);
|
|
34077
|
-
});
|
|
34078
|
-
}, [value]);
|
|
34079
|
-
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Button, {
|
|
34080
|
-
size: "sm",
|
|
34081
|
-
variant: "ghost",
|
|
34082
|
-
type: "button",
|
|
34083
|
-
disabled: disabled || value.length === 0,
|
|
34084
|
-
onClick: handleCopy,
|
|
34085
|
-
className: cn(className),
|
|
34086
|
-
"aria-label": copied ? "Copied" : `Copy ${srLabel ?? label ?? "value"}`,
|
|
34087
|
-
children: [copied ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Check, { className: "h-3.5 w-3.5 text-success" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Copy, { className: "h-3.5 w-3.5" }), label ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
34088
|
-
className: "ml-1",
|
|
34089
|
-
children: copied ? "Copied" : label
|
|
34090
|
-
}) : null]
|
|
34091
|
-
});
|
|
34092
|
-
}
|
|
34093
|
-
//#endregion
|
|
34094
|
-
//#region src/composites/data-table-layout.ts
|
|
34095
|
-
/**
|
|
34096
|
-
* At this many columns and above, `auto` switches a narrow viewport to cards.
|
|
34097
|
-
* Three columns still fit a phone at a readable size; four do not.
|
|
34098
|
-
*/
|
|
34099
|
-
var CARD_MODE_MIN_COLUMNS = 4;
|
|
34100
|
-
function resolveTableLayout({ mode, columnCount, isNarrow }) {
|
|
34101
|
-
if (!isNarrow) return "table";
|
|
34102
|
-
if (mode === "scroll") return "table";
|
|
34103
|
-
if (mode === "cards") return "cards";
|
|
34104
|
-
if (columnCount === 0) return "table";
|
|
34105
|
-
return columnCount >= 4 ? "cards" : "table";
|
|
34106
|
-
}
|
|
34107
|
-
//#endregion
|
|
34108
33865
|
//#region src/lib/responsive.ts
|
|
34109
33866
|
/**
|
|
34110
33867
|
* Responsive design tokens — three-tier system.
|
|
@@ -34216,6 +33973,20 @@ var CHIP_BASE = "inline-flex items-center gap-1 rounded-full border px-1.5 py-0.
|
|
|
34216
33973
|
var CHIP_ACTIVE = "border-primary/50 bg-primary/15 text-primary";
|
|
34217
33974
|
var CHIP_INACTIVE = "border-border bg-surface text-foreground-subtle hover:bg-surface-hover";
|
|
34218
33975
|
//#endregion
|
|
33976
|
+
//#region src/composites/data-table-layout.ts
|
|
33977
|
+
/**
|
|
33978
|
+
* At this many columns and above, `auto` switches a narrow viewport to cards.
|
|
33979
|
+
* Three columns still fit a phone at a readable size; four do not.
|
|
33980
|
+
*/
|
|
33981
|
+
var CARD_MODE_MIN_COLUMNS = 4;
|
|
33982
|
+
function resolveTableLayout({ mode, columnCount, isNarrow }) {
|
|
33983
|
+
if (!isNarrow) return "table";
|
|
33984
|
+
if (mode === "scroll") return "table";
|
|
33985
|
+
if (mode === "cards") return "cards";
|
|
33986
|
+
if (columnCount === 0) return "table";
|
|
33987
|
+
return columnCount >= 4 ? "cards" : "table";
|
|
33988
|
+
}
|
|
33989
|
+
//#endregion
|
|
34219
33990
|
//#region src/composites/setting-row.tsx
|
|
34220
33991
|
function SettingRow({ label, children, actions, elements = "plain", className, valueClassName }) {
|
|
34221
33992
|
const LabelTag = elements === "description" ? "dt" : "span";
|
|
@@ -34251,263 +34022,2418 @@ function SortHeaderButton({ label, active, dir, onSort }) {
|
|
|
34251
34022
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: label }), active ? dir === "asc" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronUp, { className: "h-3 w-3 flex-shrink-0" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronDown, { className: "h-3 w-3 flex-shrink-0" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronUp, { className: cn("h-3 w-3 flex-shrink-0 opacity-0 transition-opacity", "group-hover/sort:opacity-30") })]
|
|
34252
34023
|
});
|
|
34253
34024
|
}
|
|
34254
|
-
//#endregion
|
|
34255
|
-
//#region src/composites/table-sort.ts
|
|
34025
|
+
//#endregion
|
|
34026
|
+
//#region src/composites/table-sort.ts
|
|
34027
|
+
/**
|
|
34028
|
+
* The toggle rule, and the only copy of it.
|
|
34029
|
+
*
|
|
34030
|
+
* none → asc → desc → none, and a click on a DIFFERENT column starts that
|
|
34031
|
+
* column fresh at `asc`. `sameColumn` says whether the clicked column is the
|
|
34032
|
+
* one currently sorted; `currentDir` is its direction (`null` when nothing is
|
|
34033
|
+
* sorted). Returns the next direction, or `null` for "unsorted".
|
|
34034
|
+
*
|
|
34035
|
+
* Split out from {@link nextTableSort} so the device list — whose sort state
|
|
34036
|
+
* keys columns by `columnId`, not `columnKey` — can reuse the rule without a
|
|
34037
|
+
* cast and without a second copy of it.
|
|
34038
|
+
*/
|
|
34039
|
+
function nextSortDirection(sameColumn, currentDir) {
|
|
34040
|
+
if (!sameColumn || currentDir === null) return "asc";
|
|
34041
|
+
return currentDir === "asc" ? "desc" : null;
|
|
34042
|
+
}
|
|
34043
|
+
/**
|
|
34044
|
+
* Toggle a column's sort on header click: none → asc → desc → none.
|
|
34045
|
+
* Clicking a DIFFERENT column starts it fresh at `asc`.
|
|
34046
|
+
*/
|
|
34047
|
+
function nextTableSort(current, columnKey) {
|
|
34048
|
+
const dir = nextSortDirection(current !== null && current.columnKey === columnKey, current === null ? null : current.dir);
|
|
34049
|
+
return dir === null ? null : {
|
|
34050
|
+
columnKey,
|
|
34051
|
+
dir
|
|
34052
|
+
};
|
|
34053
|
+
}
|
|
34054
|
+
/**
|
|
34055
|
+
* Ascending comparison of two cell values.
|
|
34056
|
+
*
|
|
34057
|
+
* Numbers compare numerically and strings compare with `localeCompare` — a
|
|
34058
|
+
* numeric column must never be ordered as text ("100" before "9"), and a text
|
|
34059
|
+
* column must respect the locale's collation. A mixed column (one row a
|
|
34060
|
+
* number, another a string) falls back to string comparison of both, which is
|
|
34061
|
+
* the only total order available; it is not a shape any caller should build on
|
|
34062
|
+
* purpose.
|
|
34063
|
+
*
|
|
34064
|
+
* `null` is NOT handled here — {@link sortRowsByColumn} pulls it out first,
|
|
34065
|
+
* because its placement is direction-independent and a comparator that is
|
|
34066
|
+
* multiplied by the direction factor cannot express that.
|
|
34067
|
+
*/
|
|
34068
|
+
function compareValues(a, b) {
|
|
34069
|
+
if (typeof a === "number" && typeof b === "number") return a - b;
|
|
34070
|
+
return String(a).localeCompare(String(b));
|
|
34071
|
+
}
|
|
34072
|
+
/**
|
|
34073
|
+
* Return a NEW array of `rows` ordered by `sort`.
|
|
34074
|
+
*
|
|
34075
|
+
* `valueOf` is the column's `sortValue` — it is called once per row per
|
|
34076
|
+
* comparison, so it must be cheap (read a field, do not format a string).
|
|
34077
|
+
* `sort === null` ⇒ a fresh shallow copy in the incoming order.
|
|
34078
|
+
*/
|
|
34079
|
+
function sortRowsByColumn(rows, sort, valueOf) {
|
|
34080
|
+
if (sort === null) return [...rows];
|
|
34081
|
+
const factor = sort.dir === "asc" ? 1 : -1;
|
|
34082
|
+
return rows.toSorted((a, b) => {
|
|
34083
|
+
const av = valueOf(a);
|
|
34084
|
+
const bv = valueOf(b);
|
|
34085
|
+
if (av === null || bv === null) {
|
|
34086
|
+
if (av === bv) return 0;
|
|
34087
|
+
return av === null ? 1 : -1;
|
|
34088
|
+
}
|
|
34089
|
+
return factor * compareValues(av, bv);
|
|
34090
|
+
});
|
|
34091
|
+
}
|
|
34092
|
+
/** Derive the `<th>` `aria-sort` for `columnKey` from the table's sort state. */
|
|
34093
|
+
function ariaSortForColumn(sort, columnKey) {
|
|
34094
|
+
if (sort === null || sort.columnKey !== columnKey) return "none";
|
|
34095
|
+
return sort.dir === "asc" ? "ascending" : "descending";
|
|
34096
|
+
}
|
|
34097
|
+
//#endregion
|
|
34098
|
+
//#region src/composites/data-table.tsx
|
|
34099
|
+
/**
|
|
34100
|
+
* DataTable — shared responsive table for admin pages.
|
|
34101
|
+
*
|
|
34102
|
+
* Recipes baked in:
|
|
34103
|
+
* - `overflow-x-auto` wrapper so narrow viewports don't clip the
|
|
34104
|
+
* right edge (the rounded border survives the scroll).
|
|
34105
|
+
* - `min-w-[Npx]` on the inner table so columns don't squash below
|
|
34106
|
+
* readable width before the scroll kicks in.
|
|
34107
|
+
* - `whitespace-nowrap` on headers — column titles never wrap to
|
|
34108
|
+
* two lines (`AUTO-KILL` was the regression that triggered this).
|
|
34109
|
+
* - Themed `bg-surface` + `border-border` so contrast works in both
|
|
34110
|
+
* light and dark themes.
|
|
34111
|
+
*
|
|
34112
|
+
* Column model: caller supplies `columns: DataColumn<T>[]` and
|
|
34113
|
+
* `rows: T[]`. Each column declares `header`, optional `align`,
|
|
34114
|
+
* optional `nowrap`, and a `render(row) => ReactNode` that produces
|
|
34115
|
+
* the cell content.
|
|
34116
|
+
*
|
|
34117
|
+
* <DataTable
|
|
34118
|
+
* columns={[
|
|
34119
|
+
* { key: 'username', header: 'Username', render: (u) => u.username },
|
|
34120
|
+
* { key: 'role', header: 'Role', render: (u) => <RoleBadge .../> },
|
|
34121
|
+
* { key: 'actions', header: '', align: 'right', render: (u) => <Actions .../> },
|
|
34122
|
+
* ]}
|
|
34123
|
+
* rows={users}
|
|
34124
|
+
* minWidthPx={560}
|
|
34125
|
+
* />
|
|
34126
|
+
*
|
|
34127
|
+
* If finer header markup is needed, pass `headerRender` instead of
|
|
34128
|
+
* `header` (e.g. for a sortable click affordance).
|
|
34129
|
+
*
|
|
34130
|
+
* ── Narrow viewports ──────────────────────────────────────────────────────
|
|
34131
|
+
* The table stays a table. The wrapper is `overflow-x-auto`, so a phone
|
|
34132
|
+
* scrolls the columns sideways INSIDE the table — the page body never moves
|
|
34133
|
+
* sideways, and the cells stay one line (`whitespace-nowrap`). That is the
|
|
34134
|
+
* default (`mobileMode="scroll"`). Cards are opt-in via `mobileMode="cards"`;
|
|
34135
|
+
* `auto` still applies the old column-count rule for a surface that wants it.
|
|
34136
|
+
* See `resolveTableLayout`.
|
|
34137
|
+
*
|
|
34138
|
+
* ── Scrolling the BODY, not the page ──────────────────────────────────────
|
|
34139
|
+
* `maxBodyHeight` caps the table and scrolls its rows INSIDE the container
|
|
34140
|
+
* with the column header pinned. It is ONE prop, not two, because "this list
|
|
34141
|
+
* scrolls internally" and "its header stays visible" are the same decision: a
|
|
34142
|
+
* capped table whose header scrolls away is a list of unlabelled cells.
|
|
34143
|
+
*
|
|
34144
|
+
* Use it wherever the row count is open-ended (rules, targets, delivery
|
|
34145
|
+
* history) so the page keeps its own header, filters and actions in view while
|
|
34146
|
+
* the operator moves through the rows.
|
|
34147
|
+
*/
|
|
34148
|
+
/**
|
|
34149
|
+
* The default cap for an internally-scrolling table.
|
|
34150
|
+
*
|
|
34151
|
+
* `60vh` leaves the page header, its filters and its actions on screen at
|
|
34152
|
+
* every viewport while still showing ~10 rows — and it is a fraction, so it
|
|
34153
|
+
* does not need a breakpoint of its own.
|
|
34154
|
+
*/
|
|
34155
|
+
var TABLE_SCROLL_MAX_H = "max-h-[60vh]";
|
|
34156
|
+
var ALIGN_CLASS = {
|
|
34157
|
+
left: "text-left",
|
|
34158
|
+
right: "text-right",
|
|
34159
|
+
center: "text-center"
|
|
34160
|
+
};
|
|
34161
|
+
function DataTable({ columns, rows, rowKey, onRowClick, minWidthPx = 480, emptyMessage, className, bordered = true, rowClassName, mobileMode = "scroll", maxBodyHeight, defaultSort }) {
|
|
34162
|
+
const isNarrow = useIsMobile();
|
|
34163
|
+
const [sort, setSort] = (0, react$1.useState)(defaultSort ?? null);
|
|
34164
|
+
const layout = resolveTableLayout({
|
|
34165
|
+
mode: mobileMode,
|
|
34166
|
+
columnCount: columns.length,
|
|
34167
|
+
isNarrow
|
|
34168
|
+
});
|
|
34169
|
+
const scrolls = maxBodyHeight !== void 0;
|
|
34170
|
+
const sortedColumn = (0, react$1.useMemo)(() => sort === null ? void 0 : columns.find((col) => col.key === sort.columnKey && col.sortValue !== void 0), [columns, sort]);
|
|
34171
|
+
const sortValue = sortedColumn?.sortValue;
|
|
34172
|
+
const displayRows = (0, react$1.useMemo)(() => sortValue === void 0 ? rows : sortRowsByColumn(rows, sort, sortValue), [
|
|
34173
|
+
rows,
|
|
34174
|
+
sort,
|
|
34175
|
+
sortValue
|
|
34176
|
+
]);
|
|
34177
|
+
const activeSort = sortedColumn === void 0 ? null : sort;
|
|
34178
|
+
if (rows.length === 0 && emptyMessage) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
34179
|
+
className: `rounded-lg ${bordered ? "border border-border" : ""} bg-surface px-3 py-4 text-xs text-foreground-subtle text-center ${className ?? ""}`,
|
|
34180
|
+
children: emptyMessage
|
|
34181
|
+
});
|
|
34182
|
+
if (rows.length === 0) return null;
|
|
34183
|
+
if (layout === "cards") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
34184
|
+
className: `space-y-2 ${scrolls ? `${maxBodyHeight} overflow-y-auto` : ""} ${className ?? ""}`,
|
|
34185
|
+
children: displayRows.map((row, rowIndex) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DataTableCard, {
|
|
34186
|
+
row,
|
|
34187
|
+
rowIndex,
|
|
34188
|
+
columns,
|
|
34189
|
+
bordered,
|
|
34190
|
+
onRowClick,
|
|
34191
|
+
rowClassName
|
|
34192
|
+
}, rowKey ? rowKey(row, rowIndex) : rowIndex))
|
|
34193
|
+
});
|
|
34194
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
34195
|
+
className: [
|
|
34196
|
+
"rounded-lg",
|
|
34197
|
+
bordered ? "border border-border" : "",
|
|
34198
|
+
"bg-surface overflow-x-auto",
|
|
34199
|
+
scrolls ? `${maxBodyHeight} overflow-y-auto` : "",
|
|
34200
|
+
className ?? ""
|
|
34201
|
+
].filter(Boolean).join(" "),
|
|
34202
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("table", {
|
|
34203
|
+
className: `w-full text-xs ${scrolls ? "border-separate border-spacing-0" : ""}`,
|
|
34204
|
+
style: { minWidth: `${minWidthPx}px` },
|
|
34205
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("thead", {
|
|
34206
|
+
className: "bg-surface",
|
|
34207
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tr", { children: columns.map((col) => {
|
|
34208
|
+
const align = col.align ?? "left";
|
|
34209
|
+
const nowrap = col.nowrap ?? true;
|
|
34210
|
+
const sortable = col.sortValue !== void 0;
|
|
34211
|
+
const isSorted = activeSort !== null && activeSort.columnKey === col.key;
|
|
34212
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", {
|
|
34213
|
+
"aria-sort": sortable ? ariaSortForColumn(activeSort, col.key) : void 0,
|
|
34214
|
+
title: sortable ? void 0 : col.notSortableReason,
|
|
34215
|
+
className: [
|
|
34216
|
+
"px-3 py-2 text-foreground-subtle font-medium bg-surface border-b border-border",
|
|
34217
|
+
scrolls ? "sticky top-0 z-10" : "",
|
|
34218
|
+
ALIGN_CLASS[align],
|
|
34219
|
+
nowrap ? "whitespace-nowrap" : "",
|
|
34220
|
+
col.width ?? ""
|
|
34221
|
+
].filter(Boolean).join(" "),
|
|
34222
|
+
children: sortable ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SortHeaderButton, {
|
|
34223
|
+
label: col.headerRender ? col.headerRender() : col.header,
|
|
34224
|
+
active: isSorted,
|
|
34225
|
+
dir: isSorted && activeSort !== null ? activeSort.dir : "asc",
|
|
34226
|
+
onSort: () => setSort((prev) => nextTableSort(prev, col.key))
|
|
34227
|
+
}) : col.headerRender ? col.headerRender() : col.header
|
|
34228
|
+
}, col.key);
|
|
34229
|
+
}) })
|
|
34230
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tbody", { children: displayRows.map((row, rowIndex) => {
|
|
34231
|
+
const key = rowKey ? rowKey(row, rowIndex) : rowIndex;
|
|
34232
|
+
const extra = rowClassName?.(row, rowIndex) ?? "";
|
|
34233
|
+
const interactive = onRowClick !== void 0;
|
|
34234
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tr", {
|
|
34235
|
+
onClick: interactive ? () => onRowClick(row, rowIndex) : void 0,
|
|
34236
|
+
className: [interactive ? "cursor-pointer hover:bg-primary/5" : "hover:bg-primary/5", extra].filter(Boolean).join(" "),
|
|
34237
|
+
children: columns.map((col) => {
|
|
34238
|
+
const align = col.align ?? "left";
|
|
34239
|
+
const nowrap = col.nowrap ?? true;
|
|
34240
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
|
|
34241
|
+
className: [
|
|
34242
|
+
"px-3 py-2 text-foreground border-b border-border",
|
|
34243
|
+
ALIGN_CLASS[align],
|
|
34244
|
+
nowrap ? "whitespace-nowrap" : ""
|
|
34245
|
+
].filter(Boolean).join(" "),
|
|
34246
|
+
children: col.render(row, rowIndex)
|
|
34247
|
+
}, col.key);
|
|
34248
|
+
})
|
|
34249
|
+
}, key);
|
|
34250
|
+
}) })]
|
|
34251
|
+
})
|
|
34252
|
+
});
|
|
34253
|
+
}
|
|
34254
|
+
/**
|
|
34255
|
+
* One row as a card. Columns that carry a plain-text `header` become labelled
|
|
34256
|
+
* `SettingRow`s — the label is the column title and the value gets the full
|
|
34257
|
+
* card width. Columns with no text header (an actions column, a
|
|
34258
|
+
* `headerRender`-only column) have no label to show, so they render as a
|
|
34259
|
+
* full-width strip at the foot of the card.
|
|
34260
|
+
*/
|
|
34261
|
+
function DataTableCard({ row, rowIndex, columns, bordered, onRowClick, rowClassName }) {
|
|
34262
|
+
const labelled = columns.filter((col) => col.header !== void 0 && col.header !== "");
|
|
34263
|
+
const unlabelled = columns.filter((col) => col.header === void 0 || col.header === "");
|
|
34264
|
+
const interactive = onRowClick !== void 0;
|
|
34265
|
+
const extra = rowClassName?.(row, rowIndex) ?? "";
|
|
34266
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
34267
|
+
"data-data-table-card": "",
|
|
34268
|
+
onClick: interactive ? () => onRowClick(row, rowIndex) : void 0,
|
|
34269
|
+
className: [
|
|
34270
|
+
"rounded-lg bg-surface px-3 py-1.5 divide-y divide-border-subtle",
|
|
34271
|
+
bordered ? "border border-border" : "",
|
|
34272
|
+
interactive ? "cursor-pointer hover:bg-primary/5" : "",
|
|
34273
|
+
extra
|
|
34274
|
+
].filter(Boolean).join(" "),
|
|
34275
|
+
children: [labelled.map((col) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SettingRow, {
|
|
34276
|
+
label: col.header,
|
|
34277
|
+
children: col.render(row, rowIndex)
|
|
34278
|
+
}, col.key)), unlabelled.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
34279
|
+
className: "flex flex-wrap items-center justify-end gap-2 py-1.5",
|
|
34280
|
+
children: unlabelled.map((col) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: col.render(row, rowIndex) }, col.key))
|
|
34281
|
+
})]
|
|
34282
|
+
});
|
|
34283
|
+
}
|
|
34284
|
+
//#endregion
|
|
34285
|
+
//#region src/composites/key-value-list.tsx
|
|
34286
|
+
/**
|
|
34287
|
+
* A `<dl>` of label/value rows. Layout (including the L1 stacking) is owned by
|
|
34288
|
+
* `SettingRow`; this composite only supplies the description-list semantics.
|
|
34289
|
+
*
|
|
34290
|
+
* The rows used to be `flex items-center h-7` with a `w-1/3` term, so a label
|
|
34291
|
+
* could neither wrap nor stack: on a phone it took a third of the width and
|
|
34292
|
+
* the value took whatever was left.
|
|
34293
|
+
*/
|
|
34294
|
+
function KeyValueList({ items, className }) {
|
|
34295
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("dl", {
|
|
34296
|
+
className: cn("flex flex-col", className),
|
|
34297
|
+
children: items.map((item) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SettingRow, {
|
|
34298
|
+
elements: "description",
|
|
34299
|
+
label: item.key,
|
|
34300
|
+
children: item.value
|
|
34301
|
+
}, item.key))
|
|
34302
|
+
});
|
|
34303
|
+
}
|
|
34304
|
+
//#endregion
|
|
34305
|
+
//#region src/composites/section-card.tsx
|
|
34306
|
+
function SectionCard({ headingId, title, icon: Icon, actions, testId, children }) {
|
|
34307
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
|
|
34308
|
+
"aria-labelledby": headingId,
|
|
34309
|
+
className: SECTION_CARD,
|
|
34310
|
+
"data-testid": testId,
|
|
34311
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("header", {
|
|
34312
|
+
className: `${SECTION_HEADER} flex min-h-9 flex-wrap items-center gap-2`,
|
|
34313
|
+
children: [
|
|
34314
|
+
Icon !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon, {
|
|
34315
|
+
className: "h-3.5 w-3.5 shrink-0 text-foreground-subtle",
|
|
34316
|
+
"aria-hidden": true
|
|
34317
|
+
}),
|
|
34318
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", {
|
|
34319
|
+
id: headingId,
|
|
34320
|
+
className: `${TEXT_SECTION_LABEL} min-w-0 flex-1`,
|
|
34321
|
+
children: title
|
|
34322
|
+
}),
|
|
34323
|
+
actions
|
|
34324
|
+
]
|
|
34325
|
+
}), children]
|
|
34326
|
+
});
|
|
34327
|
+
}
|
|
34328
|
+
//#endregion
|
|
34329
|
+
//#region src/composites/webrtc-diagnostics/ice-candidates.ts
|
|
34330
|
+
var CANDIDATE_TYPES = [
|
|
34331
|
+
"host",
|
|
34332
|
+
"srflx",
|
|
34333
|
+
"prflx",
|
|
34334
|
+
"relay"
|
|
34335
|
+
];
|
|
34336
|
+
function intOrNull(token) {
|
|
34337
|
+
if (token === void 0 || !/^\d+$/.test(token)) return null;
|
|
34338
|
+
const n = Number(token);
|
|
34339
|
+
return Number.isSafeInteger(n) ? n : null;
|
|
34340
|
+
}
|
|
34341
|
+
function familyOf(address) {
|
|
34342
|
+
if (address.length === 0) return "unknown";
|
|
34343
|
+
if (address.toLowerCase().endsWith(".local")) return "mdns";
|
|
34344
|
+
if (address.includes(":")) return "ipv6";
|
|
34345
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(address)) return "ipv4";
|
|
34346
|
+
return "unknown";
|
|
34347
|
+
}
|
|
34348
|
+
function ipv4Octets(address) {
|
|
34349
|
+
const parts = address.split(".");
|
|
34350
|
+
if (parts.length !== 4) return null;
|
|
34351
|
+
const octets = parts.map((p) => Number(p));
|
|
34352
|
+
return octets.every((o) => Number.isInteger(o) && o >= 0 && o <= 255) ? octets : null;
|
|
34353
|
+
}
|
|
34354
|
+
function ipv4Scope(address) {
|
|
34355
|
+
const o = ipv4Octets(address);
|
|
34356
|
+
if (o === null) return "unknown";
|
|
34357
|
+
const [a = -1, b = -1] = o;
|
|
34358
|
+
if (a === 127) return "loopback";
|
|
34359
|
+
if (a === 169 && b === 254) return "link-local";
|
|
34360
|
+
if (a === 10 || a === 192 && b === 168 || a === 172 && b >= 16 && b <= 31) return "lan";
|
|
34361
|
+
if (a === 100 && b >= 64 && b <= 127) return "tailscale";
|
|
34362
|
+
return "public";
|
|
34363
|
+
}
|
|
34364
|
+
function ipv6Scope(address) {
|
|
34365
|
+
const lower = address.toLowerCase();
|
|
34366
|
+
if (lower === "::1") return "loopback";
|
|
34367
|
+
if (/^fe[89ab]/.test(lower)) return "link-local";
|
|
34368
|
+
if (lower.startsWith("fd7a:115c:a1e0:")) return "tailscale";
|
|
34369
|
+
if (/^f[cd]/.test(lower)) return "lan";
|
|
34370
|
+
return "public";
|
|
34371
|
+
}
|
|
34372
|
+
function classifyAddressScope(address) {
|
|
34373
|
+
switch (familyOf(address)) {
|
|
34374
|
+
case "mdns": return "mdns";
|
|
34375
|
+
case "ipv4": return ipv4Scope(address);
|
|
34376
|
+
case "ipv6": return ipv6Scope(address);
|
|
34377
|
+
default: return "unknown";
|
|
34378
|
+
}
|
|
34379
|
+
}
|
|
34380
|
+
/** Parse one candidate attribute (`candidate:…`, with or without `a=`). */
|
|
34381
|
+
function parseIceCandidate(line) {
|
|
34382
|
+
const raw = line.trim().replace(/^a=/, "");
|
|
34383
|
+
if (!raw.startsWith("candidate:")) return null;
|
|
34384
|
+
const parts = raw.slice(10).split(/\s+/);
|
|
34385
|
+
const address = parts[4] ?? "";
|
|
34386
|
+
const typIdx = parts.indexOf("typ");
|
|
34387
|
+
const typeToken = typIdx >= 0 ? parts[typIdx + 1] : void 0;
|
|
34388
|
+
const type = CANDIDATE_TYPES.find((t) => t === typeToken) ?? "unknown";
|
|
34389
|
+
const raddrIdx = parts.indexOf("raddr");
|
|
34390
|
+
const rportIdx = parts.indexOf("rport");
|
|
34391
|
+
const protocol = parts[2]?.toLowerCase();
|
|
34392
|
+
return {
|
|
34393
|
+
type,
|
|
34394
|
+
protocol: protocol !== void 0 && protocol.length > 0 ? protocol : null,
|
|
34395
|
+
address: address.length > 0 ? address : null,
|
|
34396
|
+
port: intOrNull(parts[5]),
|
|
34397
|
+
relatedAddress: raddrIdx >= 0 ? parts[raddrIdx + 1] ?? null : null,
|
|
34398
|
+
relatedPort: rportIdx >= 0 ? intOrNull(parts[rportIdx + 1]) : null,
|
|
34399
|
+
family: familyOf(address),
|
|
34400
|
+
scope: classifyAddressScope(address),
|
|
34401
|
+
raw
|
|
34402
|
+
};
|
|
34403
|
+
}
|
|
34404
|
+
/** Every `a=candidate` line of an SDP, in order. */
|
|
34405
|
+
function candidateLinesFromSdp(sdp) {
|
|
34406
|
+
return sdp.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.startsWith("a=candidate:"));
|
|
34407
|
+
}
|
|
34408
|
+
function candidateKey(c) {
|
|
34409
|
+
return `${c.type}|${c.protocol ?? "?"}|${c.address ?? "?"}|${String(c.port ?? "?")}`;
|
|
34410
|
+
}
|
|
34411
|
+
var PRIVATE_SCOPES = new Set([
|
|
34412
|
+
"loopback",
|
|
34413
|
+
"link-local",
|
|
34414
|
+
"lan",
|
|
34415
|
+
"tailscale",
|
|
34416
|
+
"mdns"
|
|
34417
|
+
]);
|
|
34418
|
+
function emptyCounts() {
|
|
34419
|
+
return {
|
|
34420
|
+
host: 0,
|
|
34421
|
+
srflx: 0,
|
|
34422
|
+
prflx: 0,
|
|
34423
|
+
relay: 0,
|
|
34424
|
+
unknown: 0
|
|
34425
|
+
};
|
|
34426
|
+
}
|
|
34427
|
+
function countCandidateTypes(candidates) {
|
|
34428
|
+
const counts = emptyCounts();
|
|
34429
|
+
for (const c of candidates) counts[c.type] += 1;
|
|
34430
|
+
return counts;
|
|
34431
|
+
}
|
|
34432
|
+
function reachabilityFlag(answered, candidates) {
|
|
34433
|
+
if (!answered && candidates.length === 0) return "unknown";
|
|
34434
|
+
if (candidates.length === 0) return "none";
|
|
34435
|
+
if (candidates.some((c) => c.type === "srflx" || c.type === "relay" || c.type === "host" && c.scope === "public")) return "public-path";
|
|
34436
|
+
return candidates.every((c) => PRIVATE_SCOPES.has(c.scope)) ? "private-only" : "public-path";
|
|
34437
|
+
}
|
|
34438
|
+
/** The hub's offered candidates: answer SDP plus trickle, deduplicated. */
|
|
34439
|
+
function summarizeHubCandidates(input) {
|
|
34440
|
+
const seen = /* @__PURE__ */ new Set();
|
|
34441
|
+
const candidates = [];
|
|
34442
|
+
const add = (line) => {
|
|
34443
|
+
const c = parseIceCandidate(line);
|
|
34444
|
+
if (c === null) return false;
|
|
34445
|
+
const key = candidateKey(c);
|
|
34446
|
+
if (seen.has(key)) return false;
|
|
34447
|
+
seen.add(key);
|
|
34448
|
+
candidates.push(c);
|
|
34449
|
+
return true;
|
|
34450
|
+
};
|
|
34451
|
+
let fromSdp = 0;
|
|
34452
|
+
for (const line of candidateLinesFromSdp(input.sdpAnswer ?? "")) if (add(line)) fromSdp += 1;
|
|
34453
|
+
let fromTrickle = 0;
|
|
34454
|
+
for (const line of input.trickled) if (add(line)) fromTrickle += 1;
|
|
34455
|
+
return {
|
|
34456
|
+
candidates,
|
|
34457
|
+
fromSdp,
|
|
34458
|
+
fromTrickle,
|
|
34459
|
+
countByType: countCandidateTypes(candidates),
|
|
34460
|
+
hasSrflx: candidates.some((c) => c.type === "srflx"),
|
|
34461
|
+
hasRelay: candidates.some((c) => c.type === "relay"),
|
|
34462
|
+
hasPublicHost: candidates.some((c) => c.type === "host" && c.scope === "public"),
|
|
34463
|
+
flag: reachabilityFlag(input.sdpAnswer !== null, candidates),
|
|
34464
|
+
trickleDone: input.trickleDone
|
|
34465
|
+
};
|
|
34466
|
+
}
|
|
34467
|
+
//#endregion
|
|
34468
|
+
//#region src/composites/webrtc-diagnostics/endpoint-probe.ts
|
|
34469
|
+
/**
|
|
34470
|
+
* Browser-side probes behind "Test each endpoint" and the NAT mapping check.
|
|
34471
|
+
*
|
|
34472
|
+
* Everything runs in the operator's browser against real `RTCPeerConnection`s;
|
|
34473
|
+
* the only server round-trip is fetching the ICE server list.
|
|
34474
|
+
*
|
|
34475
|
+
* - `gatherCandidates` — one peer connection, a given server set: which
|
|
34476
|
+
* candidate types it produces, which server URL produced each (the ICE
|
|
34477
|
+
* event's `url`) and, for a relay, the transport to the TURN server
|
|
34478
|
+
* (`relayProtocol`: udp / tcp / tls).
|
|
34479
|
+
* - `relayLoopback` — two peer connections in this page, both forced to
|
|
34480
|
+
* `relay`: the ONLY way they connect is through the TURN server, so a
|
|
34481
|
+
* connect proves allocation and forwarding work, and the selected pair
|
|
34482
|
+
* gives the relay RTT and the local relay protocol from `getStats()`.
|
|
34483
|
+
* (A same-page loopback with policy `all` proves nothing — the two peers
|
|
34484
|
+
* connect host↔host — which is why it is not offered.)
|
|
34485
|
+
*
|
|
34486
|
+
* Credentials are attached to `turn:`/`turns:` entries only and never leave
|
|
34487
|
+
* this module: nothing here logs or returns them.
|
|
34488
|
+
*/
|
|
34489
|
+
function toRtcIceServers(servers) {
|
|
34490
|
+
return servers.map((s) => {
|
|
34491
|
+
if (/^turns?:/i.test(s.url) && s.username !== void 0 && s.credential !== void 0) return {
|
|
34492
|
+
urls: s.url,
|
|
34493
|
+
username: s.username,
|
|
34494
|
+
credential: s.credential
|
|
34495
|
+
};
|
|
34496
|
+
return { urls: s.url };
|
|
34497
|
+
});
|
|
34498
|
+
}
|
|
34499
|
+
/** `RTCPeerConnectionIceEvent.url` — a real field missing from this lib.dom. */
|
|
34500
|
+
function iceEventUrl(e) {
|
|
34501
|
+
if ("url" in e && typeof e.url === "string" && e.url.length > 0) return e.url;
|
|
34502
|
+
return null;
|
|
34503
|
+
}
|
|
34504
|
+
/** `RTCIceCandidate.relayProtocol` — Chromium and WebKit report it; lib.dom does not type it. */
|
|
34505
|
+
function candidateRelayProtocol(c) {
|
|
34506
|
+
if ("relayProtocol" in c && typeof c.relayProtocol === "string" && c.relayProtocol.length > 0) return c.relayProtocol;
|
|
34507
|
+
return null;
|
|
34508
|
+
}
|
|
34509
|
+
/** One local candidate from an ICE event, or null for the end-of-candidates event. */
|
|
34510
|
+
function probedCandidateFromEvent(e, tMs) {
|
|
34511
|
+
const raw = e.candidate?.candidate;
|
|
34512
|
+
if (e.candidate === null || raw === void 0 || raw.length === 0) return null;
|
|
34513
|
+
const candidate = parseIceCandidate(raw);
|
|
34514
|
+
if (candidate === null) return null;
|
|
34515
|
+
return {
|
|
34516
|
+
candidate,
|
|
34517
|
+
url: iceEventUrl(e),
|
|
34518
|
+
relayProtocol: candidateRelayProtocol(e.candidate),
|
|
34519
|
+
tMs
|
|
34520
|
+
};
|
|
34521
|
+
}
|
|
34522
|
+
function errorText$1(err) {
|
|
34523
|
+
return err instanceof Error ? err.message : String(err);
|
|
34524
|
+
}
|
|
34525
|
+
async function gatherCandidates(servers, policy, timeoutMs = 8e3) {
|
|
34526
|
+
const start = performance.now();
|
|
34527
|
+
const candidates = [];
|
|
34528
|
+
const pc = new RTCPeerConnection({
|
|
34529
|
+
iceServers: toRtcIceServers(servers),
|
|
34530
|
+
iceTransportPolicy: policy
|
|
34531
|
+
});
|
|
34532
|
+
try {
|
|
34533
|
+
pc.createDataChannel("probe");
|
|
34534
|
+
const done = new Promise((resolve) => {
|
|
34535
|
+
const timer = setTimeout(() => resolve(true), timeoutMs);
|
|
34536
|
+
pc.addEventListener("icecandidate", (e) => {
|
|
34537
|
+
if (e.candidate === null) {
|
|
34538
|
+
clearTimeout(timer);
|
|
34539
|
+
resolve(false);
|
|
34540
|
+
return;
|
|
34541
|
+
}
|
|
34542
|
+
const probed = probedCandidateFromEvent(e, performance.now() - start);
|
|
34543
|
+
if (probed !== null) candidates.push(probed);
|
|
34544
|
+
});
|
|
34545
|
+
});
|
|
34546
|
+
await pc.setLocalDescription(await pc.createOffer());
|
|
34547
|
+
const timedOut = await done;
|
|
34548
|
+
return {
|
|
34549
|
+
candidates,
|
|
34550
|
+
durationMs: performance.now() - start,
|
|
34551
|
+
timedOut,
|
|
34552
|
+
error: null
|
|
34553
|
+
};
|
|
34554
|
+
} catch (err) {
|
|
34555
|
+
return {
|
|
34556
|
+
candidates,
|
|
34557
|
+
durationMs: performance.now() - start,
|
|
34558
|
+
timedOut: false,
|
|
34559
|
+
error: errorText$1(err)
|
|
34560
|
+
};
|
|
34561
|
+
} finally {
|
|
34562
|
+
pc.close();
|
|
34563
|
+
}
|
|
34564
|
+
}
|
|
34565
|
+
function selectedPairFacts(report) {
|
|
34566
|
+
const byId = /* @__PURE__ */ new Map();
|
|
34567
|
+
const pairs = [];
|
|
34568
|
+
const selected = { id: null };
|
|
34569
|
+
report.forEach((raw) => {
|
|
34570
|
+
const s = raw;
|
|
34571
|
+
if (s.id !== void 0) byId.set(s.id, s);
|
|
34572
|
+
if (s.type === "candidate-pair") pairs.push(s);
|
|
34573
|
+
if (s.type === "transport" && s.selectedCandidatePairId !== void 0) selected.id = s.selectedCandidatePairId;
|
|
34574
|
+
});
|
|
34575
|
+
const pair = pairs.find((p) => selected.id !== null && p.id === selected.id) ?? pairs.find((p) => p.selected === true) ?? pairs.find((p) => p.nominated === true && p.state === "succeeded") ?? null;
|
|
34576
|
+
if (pair === null) return {
|
|
34577
|
+
rttMs: null,
|
|
34578
|
+
relayProtocol: null
|
|
34579
|
+
};
|
|
34580
|
+
const local = pair.localCandidateId !== void 0 ? byId.get(pair.localCandidateId) : void 0;
|
|
34581
|
+
return {
|
|
34582
|
+
rttMs: typeof pair.currentRoundTripTime === "number" ? Math.round(pair.currentRoundTripTime * 1e3) : null,
|
|
34583
|
+
relayProtocol: typeof local?.relayProtocol === "string" ? local.relayProtocol : null
|
|
34584
|
+
};
|
|
34585
|
+
}
|
|
34586
|
+
async function relayLoopback(servers, timeoutMs = 1e4) {
|
|
34587
|
+
const start = performance.now();
|
|
34588
|
+
const config = {
|
|
34589
|
+
iceServers: toRtcIceServers(servers),
|
|
34590
|
+
iceTransportPolicy: "relay"
|
|
34591
|
+
};
|
|
34592
|
+
const pcA = new RTCPeerConnection(config);
|
|
34593
|
+
const pcB = new RTCPeerConnection(config);
|
|
34594
|
+
try {
|
|
34595
|
+
pcA.createDataChannel("probe");
|
|
34596
|
+
pcA.addEventListener("icecandidate", (e) => {
|
|
34597
|
+
if (e.candidate) pcB.addIceCandidate(e.candidate).catch(() => void 0);
|
|
34598
|
+
});
|
|
34599
|
+
pcB.addEventListener("icecandidate", (e) => {
|
|
34600
|
+
if (e.candidate) pcA.addIceCandidate(e.candidate).catch(() => void 0);
|
|
34601
|
+
});
|
|
34602
|
+
const connected = new Promise((resolve) => {
|
|
34603
|
+
const timer = setTimeout(() => resolve(false), timeoutMs);
|
|
34604
|
+
pcA.addEventListener("iceconnectionstatechange", () => {
|
|
34605
|
+
const st = pcA.iceConnectionState;
|
|
34606
|
+
if (st === "connected" || st === "completed") {
|
|
34607
|
+
clearTimeout(timer);
|
|
34608
|
+
resolve(true);
|
|
34609
|
+
} else if (st === "failed" || st === "closed") {
|
|
34610
|
+
clearTimeout(timer);
|
|
34611
|
+
resolve(false);
|
|
34612
|
+
}
|
|
34613
|
+
});
|
|
34614
|
+
});
|
|
34615
|
+
const offer = await pcA.createOffer();
|
|
34616
|
+
await pcA.setLocalDescription(offer);
|
|
34617
|
+
await pcB.setRemoteDescription(offer);
|
|
34618
|
+
const answer = await pcB.createAnswer();
|
|
34619
|
+
await pcB.setLocalDescription(answer);
|
|
34620
|
+
await pcA.setRemoteDescription(answer);
|
|
34621
|
+
const ok = await connected;
|
|
34622
|
+
const facts = ok ? selectedPairFacts(await pcA.getStats()) : {
|
|
34623
|
+
rttMs: null,
|
|
34624
|
+
relayProtocol: null
|
|
34625
|
+
};
|
|
34626
|
+
return {
|
|
34627
|
+
connected: ok,
|
|
34628
|
+
state: pcA.iceConnectionState,
|
|
34629
|
+
rttMs: facts.rttMs,
|
|
34630
|
+
relayProtocol: facts.relayProtocol,
|
|
34631
|
+
durationMs: performance.now() - start,
|
|
34632
|
+
error: null
|
|
34633
|
+
};
|
|
34634
|
+
} catch (err) {
|
|
34635
|
+
return {
|
|
34636
|
+
connected: false,
|
|
34637
|
+
state: pcA.iceConnectionState,
|
|
34638
|
+
rttMs: null,
|
|
34639
|
+
relayProtocol: null,
|
|
34640
|
+
durationMs: performance.now() - start,
|
|
34641
|
+
error: errorText$1(err)
|
|
34642
|
+
};
|
|
34643
|
+
} finally {
|
|
34644
|
+
pcA.close();
|
|
34645
|
+
pcB.close();
|
|
34646
|
+
}
|
|
34647
|
+
}
|
|
34648
|
+
//#endregion
|
|
34649
|
+
//#region src/composites/webrtc-diagnostics/nat-mapping.ts
|
|
34650
|
+
function isHiddenBase(o) {
|
|
34651
|
+
return o.relatedAddress === null || o.relatedAddress === "0.0.0.0" || o.relatedAddress === "::" || o.relatedPort === null || o.relatedPort === 0;
|
|
34652
|
+
}
|
|
34653
|
+
function baseKey(o) {
|
|
34654
|
+
return isHiddenBase(o) ? `public:${o.address}` : `base:${o.relatedAddress ?? "?"}:${String(o.relatedPort ?? "?")}`;
|
|
34655
|
+
}
|
|
34656
|
+
function unknown(reason, mappings = []) {
|
|
34657
|
+
return {
|
|
34658
|
+
kind: "unknown",
|
|
34659
|
+
confidence: "none",
|
|
34660
|
+
reason,
|
|
34661
|
+
mappings
|
|
34662
|
+
};
|
|
34663
|
+
}
|
|
34664
|
+
/**
|
|
34665
|
+
* The distinct STUN destinations (`stun:host:port`) among the given URLs —
|
|
34666
|
+
* the number the check needs to be at least two. TURN URLs are excluded:
|
|
34667
|
+
* this check is about plain STUN binding.
|
|
34668
|
+
*/
|
|
34669
|
+
function distinctStunDestinations(urls) {
|
|
34670
|
+
const out = [];
|
|
34671
|
+
for (const url of urls) {
|
|
34672
|
+
const m = /^stuns?:([^?]+)/i.exec(url.trim());
|
|
34673
|
+
if (m === null) continue;
|
|
34674
|
+
const dest = `stun:${(m[1] ?? "").toLowerCase()}`;
|
|
34675
|
+
if (!out.includes(dest)) out.push(dest);
|
|
34676
|
+
}
|
|
34677
|
+
return out;
|
|
34678
|
+
}
|
|
34679
|
+
function compareSrflxMappings(observations, stunDestinationsQueried) {
|
|
34680
|
+
const udp = observations.filter((o) => o.protocol.toLowerCase() === "udp");
|
|
34681
|
+
if (stunDestinationsQueried < 2) return unknown("needs two STUN destinations to compare; only one was queried");
|
|
34682
|
+
if (udp.length === 0) return unknown("no server-reflexive candidate: STUN unreachable or blocked from this browser");
|
|
34683
|
+
const groups = /* @__PURE__ */ new Map();
|
|
34684
|
+
for (const o of udp) {
|
|
34685
|
+
const key = baseKey(o);
|
|
34686
|
+
groups.set(key, [...groups.get(key) ?? [], o]);
|
|
34687
|
+
}
|
|
34688
|
+
const mappings = [...new Set(udp.map((o) => `${o.address}:${String(o.port)}`))];
|
|
34689
|
+
for (const group of groups.values()) {
|
|
34690
|
+
const distinct = new Set(group.map((o) => `${o.address}:${String(o.port)}`));
|
|
34691
|
+
if (distinct.size > 1) return {
|
|
34692
|
+
kind: "endpoint-dependent",
|
|
34693
|
+
confidence: "confirmed",
|
|
34694
|
+
reason: `one local socket got ${String(distinct.size)} different public mappings — symmetric NAT: only a TURN relay reaches this browser`,
|
|
34695
|
+
mappings
|
|
34696
|
+
};
|
|
34697
|
+
}
|
|
34698
|
+
const confirmed = [...groups.values()].some((group) => new Set(group.map((o) => o.url).filter((u) => u !== null)).size >= 2);
|
|
34699
|
+
return {
|
|
34700
|
+
kind: "endpoint-independent",
|
|
34701
|
+
confidence: confirmed ? "confirmed" : "likely",
|
|
34702
|
+
reason: confirmed ? "two STUN destinations saw the same public mapping" : "one public mapping across the STUN destinations (the browser drops an identical second candidate, so the second answer cannot be told from no answer)",
|
|
34703
|
+
mappings
|
|
34704
|
+
};
|
|
34705
|
+
}
|
|
34706
|
+
//#endregion
|
|
34707
|
+
//#region src/composites/webrtc-diagnostics/endpoint-test.ts
|
|
34708
|
+
/**
|
|
34709
|
+
* "Test each endpoint" and the NAT mapping check — the orchestration around
|
|
34710
|
+
* the browser probes, with the row derivation kept pure so it is tested
|
|
34711
|
+
* without a browser.
|
|
34712
|
+
*/
|
|
34713
|
+
/** Queried in addition to the configured STUN servers until two distinct destinations exist. */
|
|
34714
|
+
var FALLBACK_STUN_URLS = ["stun:stun.l.google.com:19302", "stun:stun1.l.google.com:19302"];
|
|
34715
|
+
function schemeOf(url) {
|
|
34716
|
+
const lower = url.toLowerCase();
|
|
34717
|
+
if (lower.startsWith("turns:")) return "turns";
|
|
34718
|
+
if (lower.startsWith("turn:")) return "turn";
|
|
34719
|
+
if (lower.startsWith("stun:") || lower.startsWith("stuns:")) return "stun";
|
|
34720
|
+
return "other";
|
|
34721
|
+
}
|
|
34722
|
+
function transportOf(url) {
|
|
34723
|
+
return /[?&]transport=([a-z]+)/i.exec(url)?.[1]?.toLowerCase() ?? null;
|
|
34724
|
+
}
|
|
34725
|
+
/** One row per URL: an entry's `urls` may be a string or a list. */
|
|
34726
|
+
function flattenIceServers(servers) {
|
|
34727
|
+
const out = [];
|
|
34728
|
+
for (const s of servers) {
|
|
34729
|
+
const urls = typeof s.urls === "string" ? [s.urls] : s.urls;
|
|
34730
|
+
for (const url of urls) {
|
|
34731
|
+
if (out.some((e) => e.id === url)) continue;
|
|
34732
|
+
out.push({
|
|
34733
|
+
id: url,
|
|
34734
|
+
url,
|
|
34735
|
+
...s.username !== void 0 ? { username: s.username } : {},
|
|
34736
|
+
...s.credential !== void 0 ? { credential: s.credential } : {},
|
|
34737
|
+
scheme: schemeOf(url),
|
|
34738
|
+
transport: transportOf(url),
|
|
34739
|
+
hasCredentials: s.credential !== void 0
|
|
34740
|
+
});
|
|
34741
|
+
}
|
|
34742
|
+
}
|
|
34743
|
+
return out;
|
|
34744
|
+
}
|
|
34745
|
+
function endpointRowFromProbe(endpoint, gather, loopback) {
|
|
34746
|
+
const srflx = gather.candidates.find((c) => c.candidate.type === "srflx");
|
|
34747
|
+
const relay = gather.candidates.find((c) => c.candidate.type === "relay");
|
|
34748
|
+
const mapped = srflx !== void 0 && srflx.candidate.address !== null && srflx.candidate.port !== null ? `${srflx.candidate.address}:${String(srflx.candidate.port)}` : null;
|
|
34749
|
+
return {
|
|
34750
|
+
id: endpoint.id,
|
|
34751
|
+
url: endpoint.url,
|
|
34752
|
+
scheme: endpoint.scheme,
|
|
34753
|
+
transport: endpoint.transport,
|
|
34754
|
+
srflx: mapped,
|
|
34755
|
+
relay: relay !== void 0,
|
|
34756
|
+
relayProtocol: loopback?.relayProtocol ?? relay?.relayProtocol ?? null,
|
|
34757
|
+
eventUrl: relay?.url ?? srflx?.url ?? null,
|
|
34758
|
+
relayConnected: loopback?.connected ?? null,
|
|
34759
|
+
rttMs: loopback?.rttMs ?? null,
|
|
34760
|
+
timedOut: gather.timedOut,
|
|
34761
|
+
error: gather.error ?? loopback?.error ?? null
|
|
34762
|
+
};
|
|
34763
|
+
}
|
|
34764
|
+
var GATHER_TIMEOUT_MS = 6e3;
|
|
34765
|
+
var LOOPBACK_TIMEOUT_MS = 8e3;
|
|
34766
|
+
function configOf(e) {
|
|
34767
|
+
return [{
|
|
34768
|
+
url: e.url,
|
|
34769
|
+
...e.username !== void 0 ? { username: e.username } : {},
|
|
34770
|
+
...e.credential !== void 0 ? { credential: e.credential } : {}
|
|
34771
|
+
}];
|
|
34772
|
+
}
|
|
34773
|
+
/** Gather against ONE endpoint; for a TURN endpoint, also prove the relay forwards. */
|
|
34774
|
+
async function testEndpoint(endpoint) {
|
|
34775
|
+
const cfg = configOf(endpoint);
|
|
34776
|
+
return endpointRowFromProbe(endpoint, await gatherCandidates(cfg, "all", GATHER_TIMEOUT_MS), endpoint.scheme === "turn" || endpoint.scheme === "turns" ? await relayLoopback(cfg, LOOPBACK_TIMEOUT_MS) : null);
|
|
34777
|
+
}
|
|
34778
|
+
/** The STUN destinations the NAT check queries: configured first, public fallbacks to reach two. */
|
|
34779
|
+
function natCheckDestinations(endpoints) {
|
|
34780
|
+
const out = [...distinctStunDestinations(endpoints.map((e) => e.url))];
|
|
34781
|
+
for (const url of distinctStunDestinations(FALLBACK_STUN_URLS)) {
|
|
34782
|
+
if (out.length >= 2) break;
|
|
34783
|
+
if (!out.includes(url)) out.push(url);
|
|
34784
|
+
}
|
|
34785
|
+
return out;
|
|
34786
|
+
}
|
|
34787
|
+
function srflxObservations(gather) {
|
|
34788
|
+
const out = [];
|
|
34789
|
+
for (const c of gather.candidates) {
|
|
34790
|
+
const p = c.candidate;
|
|
34791
|
+
if (p.type !== "srflx" || p.address === null || p.port === null || p.protocol === null) continue;
|
|
34792
|
+
out.push({
|
|
34793
|
+
address: p.address,
|
|
34794
|
+
port: p.port,
|
|
34795
|
+
protocol: p.protocol,
|
|
34796
|
+
relatedAddress: p.relatedAddress,
|
|
34797
|
+
relatedPort: p.relatedPort,
|
|
34798
|
+
url: c.url
|
|
34799
|
+
});
|
|
34800
|
+
}
|
|
34801
|
+
return out;
|
|
34802
|
+
}
|
|
34803
|
+
async function runNatCheck(endpoints) {
|
|
34804
|
+
const destinations = natCheckDestinations(endpoints);
|
|
34805
|
+
const gather = await gatherCandidates(destinations.map((url) => ({ url })), "all", GATHER_TIMEOUT_MS);
|
|
34806
|
+
return {
|
|
34807
|
+
destinations,
|
|
34808
|
+
verdict: compareSrflxMappings(srflxObservations(gather), destinations.length),
|
|
34809
|
+
error: gather.error
|
|
34810
|
+
};
|
|
34811
|
+
}
|
|
34812
|
+
//#endregion
|
|
34813
|
+
//#region src/composites/webrtc-diagnostics/format.ts
|
|
34814
|
+
/**
|
|
34815
|
+
* Rendering helpers for the WebRTC diagnostics: an unmeasured value is the
|
|
34816
|
+
* word "unknown", never a dash that reads as zero and never a 0 (D393).
|
|
34817
|
+
*/
|
|
34818
|
+
var UNKNOWN_TEXT = "unknown";
|
|
34819
|
+
function formatMaybe(value, render) {
|
|
34820
|
+
return value === null ? UNKNOWN_TEXT : render(value);
|
|
34821
|
+
}
|
|
34822
|
+
var fmtMs = (v) => formatMaybe(v, (n) => `${String(Math.round(n))} ms`);
|
|
34823
|
+
var fmtCount = (v) => formatMaybe(v, (n) => n.toLocaleString());
|
|
34824
|
+
var fmtBytes = (v) => formatMaybe(v, (n) => {
|
|
34825
|
+
if (n < 1024) return `${String(n)} B`;
|
|
34826
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KiB`;
|
|
34827
|
+
return `${(n / (1024 * 1024)).toFixed(2)} MiB`;
|
|
34828
|
+
});
|
|
34829
|
+
var fmtPercent = (v) => formatMaybe(v, (n) => `${n.toFixed(1)} %`);
|
|
34830
|
+
var fmtText = (v) => formatMaybe(v, (s) => s);
|
|
34831
|
+
//#endregion
|
|
34832
|
+
//#region src/composites/webrtc-diagnostics/EndpointTestCard.tsx
|
|
34833
|
+
/**
|
|
34834
|
+
* "ICE servers" — the live ICE server list (`turnProvider.getTurnServers`, the
|
|
34835
|
+
* same union every viewer is handed) probed endpoint by endpoint from THIS
|
|
34836
|
+
* browser, plus the NAT mapping check.
|
|
34837
|
+
*
|
|
34838
|
+
* Owns no hub data beyond the list it is given through `loadServers`.
|
|
34839
|
+
* Credentials are passed to the probes and never rendered: a row shows `auth`.
|
|
34840
|
+
*/
|
|
34841
|
+
var SCHEME_VARIANT = {
|
|
34842
|
+
stun: "info",
|
|
34843
|
+
turn: "warning",
|
|
34844
|
+
turns: "success",
|
|
34845
|
+
other: "default"
|
|
34846
|
+
};
|
|
34847
|
+
function errorText(err) {
|
|
34848
|
+
return err instanceof Error ? err.message : String(err);
|
|
34849
|
+
}
|
|
34850
|
+
function yesNo(value, yes, no) {
|
|
34851
|
+
if (value === null) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
34852
|
+
className: "text-foreground-subtle",
|
|
34853
|
+
children: UNKNOWN_TEXT
|
|
34854
|
+
});
|
|
34855
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
34856
|
+
className: value ? "text-success" : "text-danger",
|
|
34857
|
+
children: value ? yes : no
|
|
34858
|
+
});
|
|
34859
|
+
}
|
|
34860
|
+
var RESULT_COLUMNS = [
|
|
34861
|
+
{
|
|
34862
|
+
key: "endpoint",
|
|
34863
|
+
header: "Endpoint",
|
|
34864
|
+
nowrap: false,
|
|
34865
|
+
render: (r) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
34866
|
+
className: "break-all font-mono text-[11px]",
|
|
34867
|
+
children: r.url
|
|
34868
|
+
})
|
|
34869
|
+
},
|
|
34870
|
+
{
|
|
34871
|
+
key: "srflx",
|
|
34872
|
+
header: "Mapped (srflx)",
|
|
34873
|
+
render: (r) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
34874
|
+
className: "font-mono text-[11px]",
|
|
34875
|
+
children: r.srflx ?? "none"
|
|
34876
|
+
})
|
|
34877
|
+
},
|
|
34878
|
+
{
|
|
34879
|
+
key: "relay",
|
|
34880
|
+
header: "Relay",
|
|
34881
|
+
render: (r) => r.scheme === "stun" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
34882
|
+
className: "text-foreground-subtle",
|
|
34883
|
+
children: "n/a"
|
|
34884
|
+
}) : yesNo(r.relay, "allocated", "none")
|
|
34885
|
+
},
|
|
34886
|
+
{
|
|
34887
|
+
key: "proto",
|
|
34888
|
+
header: "Relay protocol",
|
|
34889
|
+
render: (r) => r.scheme === "stun" ? "n/a" : fmtText(r.relayProtocol)
|
|
34890
|
+
},
|
|
34891
|
+
{
|
|
34892
|
+
key: "connect",
|
|
34893
|
+
header: "Relay connect",
|
|
34894
|
+
render: (r) => r.relayConnected === null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
34895
|
+
className: "text-foreground-subtle",
|
|
34896
|
+
children: "n/a"
|
|
34897
|
+
}) : yesNo(r.relayConnected, "connected", "failed")
|
|
34898
|
+
},
|
|
34899
|
+
{
|
|
34900
|
+
key: "rtt",
|
|
34901
|
+
header: "RTT",
|
|
34902
|
+
align: "right",
|
|
34903
|
+
render: (r) => fmtMs(r.rttMs)
|
|
34904
|
+
},
|
|
34905
|
+
{
|
|
34906
|
+
key: "url",
|
|
34907
|
+
header: "Event url",
|
|
34908
|
+
nowrap: false,
|
|
34909
|
+
render: (r) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
34910
|
+
className: "break-all font-mono text-[10px]",
|
|
34911
|
+
children: fmtText(r.eventUrl)
|
|
34912
|
+
})
|
|
34913
|
+
},
|
|
34914
|
+
{
|
|
34915
|
+
key: "note",
|
|
34916
|
+
header: "Note",
|
|
34917
|
+
nowrap: false,
|
|
34918
|
+
render: (r) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
34919
|
+
className: TEXT_HINT,
|
|
34920
|
+
children: r.error ?? (r.timedOut ? "gathering timed out" : "")
|
|
34921
|
+
})
|
|
34922
|
+
}
|
|
34923
|
+
];
|
|
34924
|
+
var NAT_LABEL = {
|
|
34925
|
+
"endpoint-independent": "Endpoint-independent mapping",
|
|
34926
|
+
"endpoint-dependent": "Symmetric NAT (endpoint-dependent)",
|
|
34927
|
+
unknown: "Unknown"
|
|
34928
|
+
};
|
|
34929
|
+
function NatResult({ result }) {
|
|
34930
|
+
const { verdict } = result;
|
|
34931
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
34932
|
+
className: SECTION_BODY,
|
|
34933
|
+
"data-testid": "nat-check-result",
|
|
34934
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(KeyValueList, { items: [
|
|
34935
|
+
{
|
|
34936
|
+
key: "NAT mapping",
|
|
34937
|
+
value: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
34938
|
+
className: "flex flex-wrap items-center gap-1.5",
|
|
34939
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Badge, {
|
|
34940
|
+
variant: verdict.kind === "endpoint-independent" ? "success" : verdict.kind === "endpoint-dependent" ? "danger" : "default",
|
|
34941
|
+
children: NAT_LABEL[verdict.kind]
|
|
34942
|
+
}), verdict.confidence !== "none" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
34943
|
+
className: "text-[10px] text-foreground-subtle",
|
|
34944
|
+
children: verdict.confidence
|
|
34945
|
+
})]
|
|
34946
|
+
})
|
|
34947
|
+
},
|
|
34948
|
+
{
|
|
34949
|
+
key: "Why",
|
|
34950
|
+
value: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
34951
|
+
className: "text-xs",
|
|
34952
|
+
children: verdict.reason
|
|
34953
|
+
})
|
|
34954
|
+
},
|
|
34955
|
+
{
|
|
34956
|
+
key: "Mappings seen",
|
|
34957
|
+
value: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
34958
|
+
className: "font-mono text-[11px]",
|
|
34959
|
+
children: verdict.mappings.length > 0 ? verdict.mappings.join(", ") : "none"
|
|
34960
|
+
})
|
|
34961
|
+
},
|
|
34962
|
+
{
|
|
34963
|
+
key: "STUN queried",
|
|
34964
|
+
value: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
34965
|
+
className: "break-all font-mono text-[11px]",
|
|
34966
|
+
children: result.destinations.join(", ")
|
|
34967
|
+
})
|
|
34968
|
+
},
|
|
34969
|
+
...result.error !== null ? [{
|
|
34970
|
+
key: "Error",
|
|
34971
|
+
value: result.error
|
|
34972
|
+
}] : []
|
|
34973
|
+
] })
|
|
34974
|
+
});
|
|
34975
|
+
}
|
|
34976
|
+
/** The per-endpoint result table — one row per tested ICE URL. */
|
|
34977
|
+
function EndpointResultsCard({ rows }) {
|
|
34978
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SectionCard, {
|
|
34979
|
+
headingId: "webrtc-diag-endpoint-results",
|
|
34980
|
+
title: "Per-endpoint results",
|
|
34981
|
+
icon: Play,
|
|
34982
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DataTable, {
|
|
34983
|
+
columns: RESULT_COLUMNS,
|
|
34984
|
+
rows,
|
|
34985
|
+
rowKey: (r) => r.id,
|
|
34986
|
+
bordered: false,
|
|
34987
|
+
minWidthPx: 760,
|
|
34988
|
+
mobileMode: "cards"
|
|
34989
|
+
})
|
|
34990
|
+
});
|
|
34991
|
+
}
|
|
34992
|
+
/** The NAT mapping verdict for this browser. */
|
|
34993
|
+
function NatResultCard({ result }) {
|
|
34994
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SectionCard, {
|
|
34995
|
+
headingId: "webrtc-diag-nat",
|
|
34996
|
+
title: "NAT mapping (this browser)",
|
|
34997
|
+
icon: Route,
|
|
34998
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(NatResult, { result })
|
|
34999
|
+
});
|
|
35000
|
+
}
|
|
35001
|
+
function EndpointRow({ endpoint, checked, onToggle }) {
|
|
35002
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
35003
|
+
className: `${LIST_ROW} flex cursor-pointer items-center gap-2 hover:bg-surface-hover/40`,
|
|
35004
|
+
children: [
|
|
35005
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Checkbox, {
|
|
35006
|
+
checked,
|
|
35007
|
+
onChange: () => onToggle(endpoint.id)
|
|
35008
|
+
}),
|
|
35009
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Badge, {
|
|
35010
|
+
variant: SCHEME_VARIANT[endpoint.scheme],
|
|
35011
|
+
className: "shrink-0 uppercase text-[10px]",
|
|
35012
|
+
children: endpoint.scheme
|
|
35013
|
+
}),
|
|
35014
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
35015
|
+
className: "min-w-0 flex-1 break-all font-mono text-[11px] text-foreground",
|
|
35016
|
+
children: endpoint.url
|
|
35017
|
+
}),
|
|
35018
|
+
endpoint.transport !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
35019
|
+
className: `text-[10px] text-foreground-subtle uppercase`,
|
|
35020
|
+
children: endpoint.transport
|
|
35021
|
+
}),
|
|
35022
|
+
endpoint.hasCredentials && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
35023
|
+
className: `text-[10px] text-foreground-subtle text-success`,
|
|
35024
|
+
children: "auth"
|
|
35025
|
+
})
|
|
35026
|
+
]
|
|
35027
|
+
});
|
|
35028
|
+
}
|
|
35029
|
+
function EndpointTestCard({ loadServers }) {
|
|
35030
|
+
const [endpoints, setEndpoints] = (0, react$1.useState)(null);
|
|
35031
|
+
const [loadError, setLoadError] = (0, react$1.useState)(null);
|
|
35032
|
+
const [loading, setLoading] = (0, react$1.useState)(false);
|
|
35033
|
+
const [enabled, setEnabled] = (0, react$1.useState)(/* @__PURE__ */ new Set());
|
|
35034
|
+
const [running, setRunning] = (0, react$1.useState)(null);
|
|
35035
|
+
const [rows, setRows] = (0, react$1.useState)([]);
|
|
35036
|
+
const [nat, setNat] = (0, react$1.useState)(null);
|
|
35037
|
+
const reload = (0, react$1.useCallback)(async () => {
|
|
35038
|
+
setLoading(true);
|
|
35039
|
+
setLoadError(null);
|
|
35040
|
+
try {
|
|
35041
|
+
const flat = flattenIceServers(await loadServers());
|
|
35042
|
+
setEndpoints(flat);
|
|
35043
|
+
setEnabled(new Set(flat.map((e) => e.id)));
|
|
35044
|
+
} catch (err) {
|
|
35045
|
+
setLoadError(errorText(err));
|
|
35046
|
+
} finally {
|
|
35047
|
+
setLoading(false);
|
|
35048
|
+
}
|
|
35049
|
+
}, [loadServers]);
|
|
35050
|
+
(0, react$1.useEffect)(() => {
|
|
35051
|
+
reload();
|
|
35052
|
+
}, [reload]);
|
|
35053
|
+
const selected = (0, react$1.useMemo)(() => (endpoints ?? []).filter((e) => enabled.has(e.id)), [endpoints, enabled]);
|
|
35054
|
+
const toggle = (0, react$1.useCallback)((id) => {
|
|
35055
|
+
setEnabled((prev) => {
|
|
35056
|
+
const next = new Set(prev);
|
|
35057
|
+
if (next.has(id)) next.delete(id);
|
|
35058
|
+
else next.add(id);
|
|
35059
|
+
return next;
|
|
35060
|
+
});
|
|
35061
|
+
}, []);
|
|
35062
|
+
const runEndpoints = (0, react$1.useCallback)(async () => {
|
|
35063
|
+
setRunning("endpoints");
|
|
35064
|
+
setRows([]);
|
|
35065
|
+
const out = [];
|
|
35066
|
+
for (const e of selected) {
|
|
35067
|
+
out.push(await testEndpoint(e));
|
|
35068
|
+
setRows([...out]);
|
|
35069
|
+
}
|
|
35070
|
+
setRunning(null);
|
|
35071
|
+
}, [selected]);
|
|
35072
|
+
const runNat = (0, react$1.useCallback)(async () => {
|
|
35073
|
+
setRunning("nat");
|
|
35074
|
+
setNat(null);
|
|
35075
|
+
try {
|
|
35076
|
+
setNat(await runNatCheck(endpoints ?? []));
|
|
35077
|
+
} finally {
|
|
35078
|
+
setRunning(null);
|
|
35079
|
+
}
|
|
35080
|
+
}, [endpoints]);
|
|
35081
|
+
const busy = running !== null;
|
|
35082
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
35083
|
+
className: "space-y-3 sm:space-y-4",
|
|
35084
|
+
children: [
|
|
35085
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(SectionCard, {
|
|
35086
|
+
headingId: "webrtc-diag-ice-servers",
|
|
35087
|
+
title: `ICE servers${endpoints !== null ? ` (${String(endpoints.length)})` : ""}`,
|
|
35088
|
+
icon: Network,
|
|
35089
|
+
testId: "webrtc-diag-ice-servers",
|
|
35090
|
+
actions: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
35091
|
+
className: "flex flex-wrap items-center gap-1.5",
|
|
35092
|
+
children: [
|
|
35093
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Button, {
|
|
35094
|
+
variant: "ghost",
|
|
35095
|
+
onClick: () => void reload(),
|
|
35096
|
+
disabled: loading || busy,
|
|
35097
|
+
children: [loading ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LoaderCircle, { className: "h-3 w-3 animate-spin" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RefreshCw, { className: "h-3 w-3" }), "Reload"]
|
|
35098
|
+
}),
|
|
35099
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Button, {
|
|
35100
|
+
variant: "outline",
|
|
35101
|
+
onClick: () => void runNat(),
|
|
35102
|
+
disabled: busy || endpoints === null,
|
|
35103
|
+
children: [running === "nat" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LoaderCircle, { className: "h-3 w-3 animate-spin" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Route, { className: "h-3 w-3" }), "Check NAT"]
|
|
35104
|
+
}),
|
|
35105
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Button, {
|
|
35106
|
+
onClick: () => void runEndpoints(),
|
|
35107
|
+
disabled: busy || selected.length === 0,
|
|
35108
|
+
children: [running === "endpoints" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LoaderCircle, { className: "h-3 w-3 animate-spin" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Play, { className: "h-3 w-3" }), "Test each endpoint"]
|
|
35109
|
+
})
|
|
35110
|
+
]
|
|
35111
|
+
}),
|
|
35112
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
35113
|
+
className: "divide-y divide-border/50",
|
|
35114
|
+
children: [
|
|
35115
|
+
loadError !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
35116
|
+
className: `px-3 sm:px-4 py-1.5 sm:py-2 text-xs text-danger`,
|
|
35117
|
+
children: ["Failed to load ICE servers: ", loadError]
|
|
35118
|
+
}),
|
|
35119
|
+
loadError === null && endpoints === null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
35120
|
+
className: `px-3 sm:px-4 py-1.5 sm:py-2 text-[10px] text-foreground-subtle`,
|
|
35121
|
+
children: "Loading…"
|
|
35122
|
+
}),
|
|
35123
|
+
endpoints !== null && endpoints.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
35124
|
+
className: `px-3 sm:px-4 py-1.5 sm:py-2 text-[10px] text-foreground-subtle`,
|
|
35125
|
+
children: "No ICE servers configured. Add a TURN provider under TURN Servers."
|
|
35126
|
+
}),
|
|
35127
|
+
(endpoints ?? []).map((e) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(EndpointRow, {
|
|
35128
|
+
endpoint: e,
|
|
35129
|
+
checked: enabled.has(e.id),
|
|
35130
|
+
onToggle: toggle
|
|
35131
|
+
}, e.id))
|
|
35132
|
+
]
|
|
35133
|
+
})
|
|
35134
|
+
}),
|
|
35135
|
+
rows.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(EndpointResultsCard, { rows }),
|
|
35136
|
+
nat !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(NatResultCard, { result: nat })
|
|
35137
|
+
]
|
|
35138
|
+
});
|
|
35139
|
+
}
|
|
35140
|
+
//#endregion
|
|
35141
|
+
//#region src/hooks/turn-server-cache.ts
|
|
35142
|
+
/** Short enough that rotated credentials are picked up promptly. */
|
|
35143
|
+
var DEFAULT_FRESH_TTL_MS = 5 * 6e4;
|
|
35144
|
+
/** Half of the shortest provider credential lifetime in the fleet (24 h). */
|
|
35145
|
+
var DEFAULT_STALE_CAP_MS = 720 * 6e4;
|
|
35146
|
+
var DEFAULT_STORAGE_KEY = "camstack:turn-servers:v1";
|
|
35147
|
+
function defaultStorage() {
|
|
35148
|
+
const g = globalThis;
|
|
35149
|
+
try {
|
|
35150
|
+
return g.localStorage ?? null;
|
|
35151
|
+
} catch {
|
|
35152
|
+
return null;
|
|
35153
|
+
}
|
|
35154
|
+
}
|
|
35155
|
+
/** Type guard for a persisted entry — storage content is external data. */
|
|
35156
|
+
function isCacheEntry(value) {
|
|
35157
|
+
if (typeof value !== "object" || value === null) return false;
|
|
35158
|
+
const v = value;
|
|
35159
|
+
if (typeof v.fetchedAt !== "number" || !Array.isArray(v.servers)) return false;
|
|
35160
|
+
return v.servers.every((s) => {
|
|
35161
|
+
if (typeof s !== "object" || s === null) return false;
|
|
35162
|
+
const srv = s;
|
|
35163
|
+
if (!(typeof srv.urls === "string" || Array.isArray(srv.urls) && srv.urls.every((u) => typeof u === "string"))) return false;
|
|
35164
|
+
if (srv.username !== void 0 && typeof srv.username !== "string") return false;
|
|
35165
|
+
if (srv.credential !== void 0 && typeof srv.credential !== "string") return false;
|
|
35166
|
+
return true;
|
|
35167
|
+
});
|
|
35168
|
+
}
|
|
35169
|
+
var TurnServerCache = class {
|
|
35170
|
+
freshTtlMs;
|
|
35171
|
+
staleCapMs;
|
|
35172
|
+
storage;
|
|
35173
|
+
now;
|
|
35174
|
+
storageKey;
|
|
35175
|
+
/** Keyed on the caller's tRPC client object (stable per connected system). */
|
|
35176
|
+
memory = /* @__PURE__ */ new WeakMap();
|
|
35177
|
+
/** In-flight fetch per key — concurrent callers share one round trip. */
|
|
35178
|
+
inflight = /* @__PURE__ */ new WeakMap();
|
|
35179
|
+
constructor(options = {}) {
|
|
35180
|
+
this.freshTtlMs = options.freshTtlMs ?? DEFAULT_FRESH_TTL_MS;
|
|
35181
|
+
this.staleCapMs = options.staleCapMs ?? DEFAULT_STALE_CAP_MS;
|
|
35182
|
+
this.storage = "storage" in options ? options.storage ?? null : defaultStorage();
|
|
35183
|
+
this.now = options.now ?? (() => Date.now());
|
|
35184
|
+
this.storageKey = options.storageKey ?? DEFAULT_STORAGE_KEY;
|
|
35185
|
+
}
|
|
35186
|
+
/**
|
|
35187
|
+
* Resolve the ICE servers for `key`, fetching via `fetch` only when no
|
|
35188
|
+
* fresh-enough entry exists. Never rejects: a failed fetch resolves to the
|
|
35189
|
+
* stale entry when one exists, else `undefined`.
|
|
35190
|
+
*/
|
|
35191
|
+
async getOrFetch(key, fetch) {
|
|
35192
|
+
const at = this.now();
|
|
35193
|
+
const entry = this.memory.get(key) ?? this.readStorage();
|
|
35194
|
+
if (entry) {
|
|
35195
|
+
const age = at - entry.fetchedAt;
|
|
35196
|
+
if (age < this.freshTtlMs) return entry.servers;
|
|
35197
|
+
if (age < this.staleCapMs) {
|
|
35198
|
+
this.startFetch(key, fetch, entry);
|
|
35199
|
+
return entry.servers;
|
|
35200
|
+
}
|
|
35201
|
+
}
|
|
35202
|
+
return this.startFetch(key, fetch, entry);
|
|
35203
|
+
}
|
|
35204
|
+
startFetch(key, fetch, stale) {
|
|
35205
|
+
const pending = this.inflight.get(key);
|
|
35206
|
+
if (pending) return pending;
|
|
35207
|
+
const run = (async () => {
|
|
35208
|
+
try {
|
|
35209
|
+
const servers = await fetch();
|
|
35210
|
+
if (servers.length > 0) {
|
|
35211
|
+
const entry = {
|
|
35212
|
+
servers,
|
|
35213
|
+
fetchedAt: this.now()
|
|
35214
|
+
};
|
|
35215
|
+
this.memory.set(key, entry);
|
|
35216
|
+
this.writeStorage(entry);
|
|
35217
|
+
}
|
|
35218
|
+
return servers;
|
|
35219
|
+
} catch {
|
|
35220
|
+
return stale?.servers;
|
|
35221
|
+
}
|
|
35222
|
+
})().finally(() => {
|
|
35223
|
+
this.inflight.delete(key);
|
|
35224
|
+
});
|
|
35225
|
+
this.inflight.set(key, run);
|
|
35226
|
+
return run;
|
|
35227
|
+
}
|
|
35228
|
+
readStorage() {
|
|
35229
|
+
if (!this.storage) return void 0;
|
|
35230
|
+
try {
|
|
35231
|
+
const raw = this.storage.getItem(this.storageKey);
|
|
35232
|
+
if (raw === null) return void 0;
|
|
35233
|
+
const parsed = JSON.parse(raw);
|
|
35234
|
+
return isCacheEntry(parsed) ? parsed : void 0;
|
|
35235
|
+
} catch {
|
|
35236
|
+
return;
|
|
35237
|
+
}
|
|
35238
|
+
}
|
|
35239
|
+
writeStorage(entry) {
|
|
35240
|
+
if (!this.storage) return;
|
|
35241
|
+
try {
|
|
35242
|
+
this.storage.setItem(this.storageKey, JSON.stringify(entry));
|
|
35243
|
+
} catch {}
|
|
35244
|
+
}
|
|
35245
|
+
};
|
|
35246
|
+
//#endregion
|
|
35247
|
+
//#region src/hooks/use-device-webrtc.ts
|
|
35248
|
+
/**
|
|
35249
|
+
* useDeviceWebrtc — WebRTC signaling hook for device-scoped streaming.
|
|
35250
|
+
*
|
|
35251
|
+
* Provides `createSession`, `sendAnswer`, `closeSession` callbacks and
|
|
35252
|
+
* a `streams` list (Adaptive + High/Mid/Low) for a given device.
|
|
35253
|
+
*
|
|
35254
|
+
* Works in both admin-ui (BackendClient.trpc) and addon pages
|
|
35255
|
+
* (AddonPageProps.trpc) — parametrized by a minimal tRPC proxy shape.
|
|
35256
|
+
*/
|
|
35257
|
+
/**
|
|
35258
|
+
* Hook that provides WebRTC signaling callbacks and stream choices for a device.
|
|
35259
|
+
*
|
|
35260
|
+
* @param trpc - tRPC proxy (admin-ui BackendClient.trpc or addon page trpc)
|
|
35261
|
+
* @param deviceId - numeric device ID (null = disabled)
|
|
35262
|
+
* @param pollIntervalMs - how often to refresh profile slots (default: 5000)
|
|
35263
|
+
*/
|
|
35264
|
+
/** Module-level TURN/STUN credential cache. Keyed on the caller's trpc client
|
|
35265
|
+
* object (stable per connected system — NOT on `trpc.turnProvider`, which is
|
|
35266
|
+
* a proxy minted fresh on every property access), so two admin tabs pointed
|
|
35267
|
+
* at different hubs never cross-serve credentials from the memory layer; the
|
|
35268
|
+
* storage layer is per-origin, and the page's origin IS the hub. Coalesces
|
|
35269
|
+
* concurrent fetches (the mount prefetch and the connect's own call used to
|
|
35270
|
+
* race into TWO round trips) and persists across page loads so a fresh embed
|
|
35271
|
+
* page — one per camera open on native — finds warm credentials instead of
|
|
35272
|
+
* re-paying the measured 0.2–3.6 s serialized fetch. See turn-server-cache.ts. */
|
|
35273
|
+
var turnServersCache = new TurnServerCache();
|
|
35274
|
+
function useDeviceWebrtc(trpc, deviceId, pollIntervalMs = 5e3) {
|
|
35275
|
+
const [remoteStreams, setRemoteStreams] = (0, react$1.useState)([]);
|
|
35276
|
+
(0, react$1.useEffect)(() => {
|
|
35277
|
+
if (deviceId === null) return;
|
|
35278
|
+
let cancelled = false;
|
|
35279
|
+
const fetch = () => {
|
|
35280
|
+
trpc.webrtcSession.listStreams.query({ deviceId }).then((rows) => {
|
|
35281
|
+
if (!cancelled) setRemoteStreams(rows);
|
|
35282
|
+
}).catch(() => {});
|
|
35283
|
+
};
|
|
35284
|
+
fetch();
|
|
35285
|
+
const iv = setInterval(fetch, pollIntervalMs);
|
|
35286
|
+
return () => {
|
|
35287
|
+
cancelled = true;
|
|
35288
|
+
clearInterval(iv);
|
|
35289
|
+
};
|
|
35290
|
+
}, [
|
|
35291
|
+
trpc,
|
|
35292
|
+
deviceId,
|
|
35293
|
+
pollIntervalMs
|
|
35294
|
+
]);
|
|
35295
|
+
const [pipelineMetrics, setPipelineMetrics] = (0, react$1.useState)(null);
|
|
35296
|
+
(0, react$1.useEffect)(() => {
|
|
35297
|
+
if (deviceId === null) return;
|
|
35298
|
+
let cancelled = false;
|
|
35299
|
+
if (trpc.pipelineOrchestrator) trpc.pipelineOrchestrator.getCameraMetrics.query({ deviceId }).then((m) => {
|
|
35300
|
+
if (cancelled || !m) return;
|
|
35301
|
+
setPipelineMetrics({
|
|
35302
|
+
phase: m.phase,
|
|
35303
|
+
detectionFps: m.actualFps,
|
|
35304
|
+
avgInferenceMs: m.avgInferenceTimeMs,
|
|
35305
|
+
droppedFrames: m.droppedFrames
|
|
35306
|
+
});
|
|
35307
|
+
}).catch(() => {});
|
|
35308
|
+
return () => {
|
|
35309
|
+
cancelled = true;
|
|
35310
|
+
};
|
|
35311
|
+
}, [trpc, deviceId]);
|
|
35312
|
+
useLiveEvent("pipeline.camera-metrics-snapshot", (evt) => {
|
|
35313
|
+
if (deviceId === null) return;
|
|
35314
|
+
const payload = evt.data;
|
|
35315
|
+
if (payload.deviceId !== deviceId || !payload.metrics) return;
|
|
35316
|
+
setPipelineMetrics({
|
|
35317
|
+
phase: payload.metrics.phase,
|
|
35318
|
+
detectionFps: payload.metrics.actualFps,
|
|
35319
|
+
avgInferenceMs: payload.metrics.avgInferenceTimeMs,
|
|
35320
|
+
droppedFrames: payload.metrics.droppedFrames
|
|
35321
|
+
});
|
|
35322
|
+
});
|
|
35323
|
+
const { streams, hasWebrtc } = (0, react$1.useMemo)(() => {
|
|
35324
|
+
if (deviceId === null || remoteStreams.length === 0) return {
|
|
35325
|
+
streams: [],
|
|
35326
|
+
hasWebrtc: false
|
|
35327
|
+
};
|
|
35328
|
+
return {
|
|
35329
|
+
streams: remoteStreams.map((r) => ({
|
|
35330
|
+
id: r.id,
|
|
35331
|
+
label: r.label,
|
|
35332
|
+
target: r.target,
|
|
35333
|
+
status: r.status,
|
|
35334
|
+
inputFps: r.inputFps,
|
|
35335
|
+
decodeFps: r.decodeFps,
|
|
35336
|
+
bitrateKbps: r.bitrateKbps
|
|
35337
|
+
})),
|
|
35338
|
+
hasWebrtc: true
|
|
35339
|
+
};
|
|
35340
|
+
}, [deviceId, remoteStreams]);
|
|
35341
|
+
const getIceServers = (0, react$1.useCallback)(async () => {
|
|
35342
|
+
const provider = trpc.turnProvider;
|
|
35343
|
+
if (!provider) return void 0;
|
|
35344
|
+
return turnServersCache.getOrFetch(trpc, async () => {
|
|
35345
|
+
return (await provider.getTurnServers.query()).map((s) => {
|
|
35346
|
+
return {
|
|
35347
|
+
urls: typeof s.urls === "string" ? s.urls : [...s.urls],
|
|
35348
|
+
...s.username !== void 0 ? { username: s.username } : {},
|
|
35349
|
+
...s.credential !== void 0 ? { credential: s.credential } : {}
|
|
35350
|
+
};
|
|
35351
|
+
});
|
|
35352
|
+
});
|
|
35353
|
+
}, [trpc]);
|
|
35354
|
+
(0, react$1.useEffect)(() => {
|
|
35355
|
+
getIceServers();
|
|
35356
|
+
}, [getIceServers]);
|
|
35357
|
+
const createSession = (0, react$1.useCallback)(async (target, hints) => {
|
|
35358
|
+
if (deviceId === null) throw new Error("No device selected");
|
|
35359
|
+
const res = await trpc.webrtcSession.createSession.mutate({
|
|
35360
|
+
deviceId,
|
|
35361
|
+
target,
|
|
35362
|
+
hints
|
|
35363
|
+
});
|
|
35364
|
+
const iceServers = await getIceServers();
|
|
35365
|
+
return {
|
|
35366
|
+
sessionId: res.sessionId,
|
|
35367
|
+
sdpOffer: res.sdpOffer,
|
|
35368
|
+
...iceServers ? { iceServers } : {}
|
|
35369
|
+
};
|
|
35370
|
+
}, [
|
|
35371
|
+
trpc,
|
|
35372
|
+
deviceId,
|
|
35373
|
+
getIceServers
|
|
35374
|
+
]);
|
|
35375
|
+
const handleOffer = (0, react$1.useCallback)(async (target, sdpOffer, sessionId, hints) => {
|
|
35376
|
+
if (deviceId === null) throw new Error("No device selected");
|
|
35377
|
+
return trpc.webrtcSession.handleOffer.mutate({
|
|
35378
|
+
deviceId,
|
|
35379
|
+
target,
|
|
35380
|
+
sdpOffer,
|
|
35381
|
+
...sessionId !== void 0 ? { sessionId } : {},
|
|
35382
|
+
...hints !== void 0 ? { hints } : {}
|
|
35383
|
+
});
|
|
35384
|
+
}, [trpc, deviceId]);
|
|
35385
|
+
const sendAnswer = (0, react$1.useCallback)(async (sessionId, sdpAnswer) => {
|
|
35386
|
+
if (deviceId === null) throw new Error("No device selected");
|
|
35387
|
+
await trpc.webrtcSession.handleAnswer.mutate({
|
|
35388
|
+
deviceId,
|
|
35389
|
+
sessionId,
|
|
35390
|
+
sdpAnswer
|
|
35391
|
+
});
|
|
35392
|
+
}, [trpc, deviceId]);
|
|
35393
|
+
return {
|
|
35394
|
+
streams,
|
|
35395
|
+
hasWebrtc,
|
|
35396
|
+
pipelineMetrics,
|
|
35397
|
+
createSession,
|
|
35398
|
+
handleOffer,
|
|
35399
|
+
getIceServers,
|
|
35400
|
+
addIceCandidate: (0, react$1.useCallback)(async (sessionId, candidate) => {
|
|
35401
|
+
if (deviceId === null) return;
|
|
35402
|
+
await trpc.webrtcSession.addIceCandidate.mutate({
|
|
35403
|
+
deviceId,
|
|
35404
|
+
sessionId,
|
|
35405
|
+
candidate: candidate.candidate ?? "",
|
|
35406
|
+
sdpMid: candidate.sdpMid ?? null,
|
|
35407
|
+
sdpMLineIndex: candidate.sdpMLineIndex ?? null
|
|
35408
|
+
}).catch(() => {});
|
|
35409
|
+
}, [trpc, deviceId]),
|
|
35410
|
+
getIceCandidates: (0, react$1.useCallback)(async (sessionId) => {
|
|
35411
|
+
if (deviceId === null) return {
|
|
35412
|
+
candidates: [],
|
|
35413
|
+
done: true
|
|
35414
|
+
};
|
|
35415
|
+
return trpc.webrtcSession.getIceCandidates.query({
|
|
35416
|
+
deviceId,
|
|
35417
|
+
sessionId
|
|
35418
|
+
});
|
|
35419
|
+
}, [trpc, deviceId]),
|
|
35420
|
+
sendAnswer,
|
|
35421
|
+
closeSession: (0, react$1.useCallback)(async (sessionId) => {
|
|
35422
|
+
if (deviceId === null) return;
|
|
35423
|
+
await trpc.webrtcSession.closeSession.mutate({
|
|
35424
|
+
deviceId,
|
|
35425
|
+
sessionId
|
|
35426
|
+
}).catch(() => {});
|
|
35427
|
+
}, [trpc, deviceId]),
|
|
35428
|
+
getSessionState: (0, react$1.useCallback)(async (sessionId) => {
|
|
35429
|
+
if (deviceId === null) return { pendingRenegotiation: null };
|
|
35430
|
+
try {
|
|
35431
|
+
return await trpc.webrtcSession.getSessionState.query({
|
|
35432
|
+
deviceId,
|
|
35433
|
+
sessionId
|
|
35434
|
+
});
|
|
35435
|
+
} catch {
|
|
35436
|
+
return { pendingRenegotiation: null };
|
|
35437
|
+
}
|
|
35438
|
+
}, [trpc, deviceId]),
|
|
35439
|
+
getSessionDebug: (0, react$1.useCallback)(async (sessionId) => {
|
|
35440
|
+
if (deviceId === null) throw new Error("getSessionDebug: no device bound to this player");
|
|
35441
|
+
return trpc.webrtcSession.getSessionDebug.query({
|
|
35442
|
+
deviceId,
|
|
35443
|
+
sessionId
|
|
35444
|
+
});
|
|
35445
|
+
}, [trpc, deviceId]),
|
|
35446
|
+
applyDownlinkProbe: (0, react$1.useCallback)(async (sessionId, downlinkMbps) => {
|
|
35447
|
+
const mutate = trpc.webrtcSession.applyDownlinkProbe;
|
|
35448
|
+
if (deviceId === null || mutate === void 0) throw new Error("applyDownlinkProbe: not available for this player");
|
|
35449
|
+
return mutate.mutate({
|
|
35450
|
+
deviceId,
|
|
35451
|
+
sessionId,
|
|
35452
|
+
downlinkMbps
|
|
35453
|
+
});
|
|
35454
|
+
}, [trpc, deviceId])
|
|
35455
|
+
};
|
|
35456
|
+
}
|
|
35457
|
+
//#endregion
|
|
35458
|
+
//#region src/composites/webrtc-diagnostics/RunReport.tsx
|
|
35459
|
+
var VERDICT_VARIANT = {
|
|
35460
|
+
healthy: "success",
|
|
35461
|
+
"loss-keyframe": "warning",
|
|
35462
|
+
transport: "danger",
|
|
35463
|
+
"hub-dtls": "danger",
|
|
35464
|
+
"media-black-hole": "danger",
|
|
35465
|
+
"hub-not-sending": "danger",
|
|
35466
|
+
unknown: "default"
|
|
35467
|
+
};
|
|
35468
|
+
var FLAG_TEXT = {
|
|
35469
|
+
"public-path": "has a public path (srflx / relay / public host)",
|
|
35470
|
+
"private-only": "LAN/Tailscale only, no srflx/relay",
|
|
35471
|
+
none: "answered with no candidate",
|
|
35472
|
+
unknown: "no answer received"
|
|
35473
|
+
};
|
|
35474
|
+
function candidateText(c) {
|
|
35475
|
+
const where = c.address === null ? "unknown" : `${c.address}:${c.port === null ? "?" : String(c.port)}`;
|
|
35476
|
+
return `${c.type} ${c.protocol ?? "?"} ${where} · ${c.scope}`;
|
|
35477
|
+
}
|
|
35478
|
+
function typeCounts(counts) {
|
|
35479
|
+
const parts = Object.entries(counts).filter(([, n]) => n > 0).map(([t, n]) => `${String(n)} ${t}`);
|
|
35480
|
+
return parts.length > 0 ? parts.join(" · ") : "none";
|
|
35481
|
+
}
|
|
35482
|
+
function Group({ title, children }) {
|
|
35483
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
35484
|
+
className: "space-y-0.5",
|
|
35485
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
35486
|
+
className: TEXT_SECTION_LABEL,
|
|
35487
|
+
children: title
|
|
35488
|
+
}), children]
|
|
35489
|
+
});
|
|
35490
|
+
}
|
|
35491
|
+
function RunReport({ report, title }) {
|
|
35492
|
+
const { hub, timeline, path, media, verdict, local } = report;
|
|
35493
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
|
|
35494
|
+
className: SECTION_CARD,
|
|
35495
|
+
"data-testid": `webrtc-diag-run-${report.policy}`,
|
|
35496
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("header", {
|
|
35497
|
+
className: `${SECTION_HEADER} flex flex-wrap items-center gap-2`,
|
|
35498
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
|
|
35499
|
+
className: `${TEXT_SECTION_LABEL} min-w-0 flex-1`,
|
|
35500
|
+
children: title
|
|
35501
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Badge, {
|
|
35502
|
+
variant: VERDICT_VARIANT[verdict.layer],
|
|
35503
|
+
children: verdict.title
|
|
35504
|
+
})]
|
|
35505
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
35506
|
+
className: `${SECTION_BODY} space-y-3`,
|
|
35507
|
+
children: [
|
|
35508
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
35509
|
+
className: "text-xs text-foreground",
|
|
35510
|
+
children: verdict.detail
|
|
35511
|
+
}),
|
|
35512
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Group, {
|
|
35513
|
+
title: "Hub candidates",
|
|
35514
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(KeyValueList, { items: [{
|
|
35515
|
+
key: "Offered",
|
|
35516
|
+
value: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
35517
|
+
className: "flex flex-wrap items-center gap-1.5 text-xs",
|
|
35518
|
+
children: [typeCounts(hub.countByType), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Badge, {
|
|
35519
|
+
variant: hub.flag === "private-only" ? "warning" : hub.flag === "public-path" ? "success" : "default",
|
|
35520
|
+
children: FLAG_TEXT[hub.flag]
|
|
35521
|
+
})]
|
|
35522
|
+
})
|
|
35523
|
+
}, {
|
|
35524
|
+
key: "Source",
|
|
35525
|
+
value: `${String(hub.fromSdp)} in the answer SDP · ${String(hub.fromTrickle)} trickled${hub.trickleDone === true ? " · gathering done" : ""}`
|
|
35526
|
+
}] }), hub.candidates.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
|
|
35527
|
+
className: "mt-1 space-y-0.5 font-mono text-[10px] text-foreground-subtle",
|
|
35528
|
+
children: hub.candidates.map((c) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", {
|
|
35529
|
+
className: "break-all",
|
|
35530
|
+
children: candidateText(c)
|
|
35531
|
+
}, c.raw))
|
|
35532
|
+
})]
|
|
35533
|
+
}),
|
|
35534
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Group, {
|
|
35535
|
+
title: "Timeline",
|
|
35536
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(KeyValueList, { items: [
|
|
35537
|
+
{
|
|
35538
|
+
key: "ICE connected",
|
|
35539
|
+
value: fmtMs(timeline.iceConnectedMs)
|
|
35540
|
+
},
|
|
35541
|
+
{
|
|
35542
|
+
key: "DTLS connected",
|
|
35543
|
+
value: fmtMs(timeline.dtlsConnectedMs)
|
|
35544
|
+
},
|
|
35545
|
+
{
|
|
35546
|
+
key: "First frame",
|
|
35547
|
+
value: fmtMs(timeline.firstFrameMs)
|
|
35548
|
+
},
|
|
35549
|
+
{
|
|
35550
|
+
key: "ICE · DTLS final",
|
|
35551
|
+
value: `${fmtText(timeline.iceFinalState)} · ${fmtText(timeline.dtlsFinalState)}`
|
|
35552
|
+
}
|
|
35553
|
+
] })
|
|
35554
|
+
}),
|
|
35555
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Group, {
|
|
35556
|
+
title: "Path",
|
|
35557
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(KeyValueList, { items: [
|
|
35558
|
+
{
|
|
35559
|
+
key: "Selected pair",
|
|
35560
|
+
value: path.pairType === null ? fmtText(null) : `${path.pairType} (local/remote)`
|
|
35561
|
+
},
|
|
35562
|
+
{
|
|
35563
|
+
key: "Protocol · RTT",
|
|
35564
|
+
value: `${fmtText(path.protocol)} · ${fmtMs(path.rttMs)}`
|
|
35565
|
+
},
|
|
35566
|
+
{
|
|
35567
|
+
key: "Browser offered",
|
|
35568
|
+
value: `${typeCounts(local.countByType)}${local.relayProtocols.length > 0 ? ` · relay over ${local.relayProtocols.join("/")}` : ""}`
|
|
35569
|
+
}
|
|
35570
|
+
] })
|
|
35571
|
+
}),
|
|
35572
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Group, {
|
|
35573
|
+
title: "Media",
|
|
35574
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(KeyValueList, { items: [
|
|
35575
|
+
{
|
|
35576
|
+
key: "Hub sent",
|
|
35577
|
+
value: `${fmtCount(media.serverPacketsSent)} RTP packets${media.servedTier !== null ? ` · ${media.servedTier}` : ""}${media.egressLeg !== null ? ` · ${media.egressLeg}` : ""}`
|
|
35578
|
+
},
|
|
35579
|
+
{
|
|
35580
|
+
key: "Browser got",
|
|
35581
|
+
value: `${fmtCount(media.clientPacketsReceived)} packets · ${fmtBytes(media.clientBytesReceived)}`
|
|
35582
|
+
},
|
|
35583
|
+
{
|
|
35584
|
+
key: "Frames decoded",
|
|
35585
|
+
value: fmtCount(media.framesDecoded)
|
|
35586
|
+
},
|
|
35587
|
+
{
|
|
35588
|
+
key: "Loss · freezes",
|
|
35589
|
+
value: `${fmtPercent(media.lossPercent)} · ${fmtCount(media.freezeCount)} (${fmtMs(media.totalFreezeMs)})`
|
|
35590
|
+
},
|
|
35591
|
+
{
|
|
35592
|
+
key: "PLI · NACK",
|
|
35593
|
+
value: `${fmtCount(media.pliCount)} · ${fmtCount(media.nackCount)}`
|
|
35594
|
+
}
|
|
35595
|
+
] })
|
|
35596
|
+
}),
|
|
35597
|
+
(report.reconnects > 0 || report.errors.length > 0 || report.serverError !== null) && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
35598
|
+
className: "text-[10px] text-foreground-subtle",
|
|
35599
|
+
children: [
|
|
35600
|
+
report.reconnects > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: ["Player reconnects: ", String(report.reconnects)] }),
|
|
35601
|
+
report.errors.map((e, i) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: ["Player error: ", e] }, `${String(i)}-${e}`)),
|
|
35602
|
+
report.serverError !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: ["Server debug: ", report.serverError] })
|
|
35603
|
+
]
|
|
35604
|
+
}),
|
|
35605
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
35606
|
+
className: TEXT_HINT,
|
|
35607
|
+
children: [
|
|
35608
|
+
"Sessions:",
|
|
35609
|
+
" ",
|
|
35610
|
+
report.sessionIds.length > 0 ? report.sessionIds.map((s) => s.slice(0, 8)).join(", ") : "none",
|
|
35611
|
+
" ",
|
|
35612
|
+
"· ",
|
|
35613
|
+
String(Math.round(report.durationMs / 1e3)),
|
|
35614
|
+
" s"
|
|
35615
|
+
]
|
|
35616
|
+
})
|
|
35617
|
+
]
|
|
35618
|
+
})]
|
|
35619
|
+
});
|
|
35620
|
+
}
|
|
35621
|
+
function transportDetail(input) {
|
|
35622
|
+
const state = input.iceFinalState ?? "unknown";
|
|
35623
|
+
if (input.hubFlag === "unknown") return `ICE ${state}; the hub never answered the offer, so no hub candidate was seen`;
|
|
35624
|
+
if (input.hubFlag === "none") return `ICE ${state}; the hub answered but offered no candidate`;
|
|
35625
|
+
if (input.policy === "relay" && input.hubFlag === "private-only") return `ICE ${state}; this browser was limited to TURN relay candidates and the hub offered only LAN/Tailscale addresses (no srflx/relay) — a relayed client cannot reach it`;
|
|
35626
|
+
if (input.hubFlag === "private-only") return `ICE ${state}; the hub offered only LAN/Tailscale addresses (no srflx/relay) — reachable only from inside those networks`;
|
|
35627
|
+
return `ICE ${state}; no candidate pair between this browser and the hub succeeded`;
|
|
35628
|
+
}
|
|
35629
|
+
function verdict(layer, title, detail) {
|
|
35630
|
+
return {
|
|
35631
|
+
layer,
|
|
35632
|
+
title,
|
|
35633
|
+
detail
|
|
35634
|
+
};
|
|
35635
|
+
}
|
|
35636
|
+
function mediaVerdict(input) {
|
|
35637
|
+
const client = input.clientPacketsReceived;
|
|
35638
|
+
const sent = input.serverPacketsSent;
|
|
35639
|
+
if (client === null) return verdict("unknown", "Media unknown", "the browser reported no inbound video statistics");
|
|
35640
|
+
if (client === 0) {
|
|
35641
|
+
if (sent === null) return verdict("unknown", "Nothing received", "the browser received no packets and the hub's own count is unknown");
|
|
35642
|
+
if (sent > 0) return verdict("media-black-hole", "Media black-hole", `the hub sent ${String(sent)} RTP packets and this browser received none — media is lost between them after DTLS`);
|
|
35643
|
+
return verdict("hub-not-sending", "Hub not sending", "the session is connected but the hub sent no RTP — the source or the broker is not producing");
|
|
35644
|
+
}
|
|
35645
|
+
if (input.framesDecoded === null) return verdict("unknown", "Decode unknown", "packets arrive; the browser reported no decode count");
|
|
35646
|
+
if (input.framesDecoded === 0) return verdict("loss-keyframe", "Loss / keyframe size", "packets arrive but no frame decoded — a keyframe never completes over this path");
|
|
35647
|
+
const { pliCount: pli, freezeCount: freezes, lossPercent: loss } = input;
|
|
35648
|
+
const storm = pli !== null && pli >= 3 || freezes !== null && freezes > 0 || loss !== null && loss >= 2;
|
|
35649
|
+
const signals = [
|
|
35650
|
+
`${pli === null ? "unknown" : String(pli)} PLI`,
|
|
35651
|
+
`${freezes === null ? "unknown" : String(freezes)} freezes`,
|
|
35652
|
+
`${loss === null ? "unknown" : `${loss.toFixed(1)}%`} loss`
|
|
35653
|
+
].join(", ");
|
|
35654
|
+
if (storm) return verdict("loss-keyframe", "Loss / keyframe size", `frames decode but the path is lossy: ${signals}`);
|
|
35655
|
+
if (pli === null && freezes === null && loss === null) return verdict("healthy", "Decoding", "frames decode; the browser reported no loss signal to judge");
|
|
35656
|
+
return verdict("healthy", "Healthy", `connected, receiving and decoding: ${signals}`);
|
|
35657
|
+
}
|
|
35658
|
+
function classifySessionVerdict(input) {
|
|
35659
|
+
if (!input.iceConnected) return verdict("transport", "Transport (ICE) failed", transportDetail(input));
|
|
35660
|
+
if (!input.dtlsConnected && input.dtlsFinalState !== null) return verdict("hub-dtls", "DTLS stuck (hub side)", `ICE connected but DTLS stayed ${input.dtlsFinalState} — the hub never completed the handshake`);
|
|
35661
|
+
return mediaVerdict(input);
|
|
35662
|
+
}
|
|
35663
|
+
//#endregion
|
|
35664
|
+
//#region src/composites/webrtc-diagnostics/session-run.ts
|
|
35665
|
+
function emptySessionRun(policy) {
|
|
35666
|
+
return {
|
|
35667
|
+
policy,
|
|
35668
|
+
sessionIds: [],
|
|
35669
|
+
sdpAnswer: null,
|
|
35670
|
+
trickled: [],
|
|
35671
|
+
trickleDone: null,
|
|
35672
|
+
local: [],
|
|
35673
|
+
ice: [],
|
|
35674
|
+
dtls: [],
|
|
35675
|
+
peer: [],
|
|
35676
|
+
firstFrameAt: null,
|
|
35677
|
+
lastStats: null,
|
|
35678
|
+
lastServer: null,
|
|
35679
|
+
maxServerPacketsSent: null,
|
|
35680
|
+
lastServerError: null,
|
|
35681
|
+
samples: 0,
|
|
35682
|
+
reconnects: 0,
|
|
35683
|
+
errors: []
|
|
35684
|
+
};
|
|
35685
|
+
}
|
|
35686
|
+
function mark(list, state, at) {
|
|
35687
|
+
return list.at(-1)?.state === state ? list : [...list, {
|
|
35688
|
+
state,
|
|
35689
|
+
at
|
|
35690
|
+
}];
|
|
35691
|
+
}
|
|
35692
|
+
function maxOrNull(a, b) {
|
|
35693
|
+
if (a === null) return b;
|
|
35694
|
+
if (b === null) return a;
|
|
35695
|
+
return Math.max(a, b);
|
|
35696
|
+
}
|
|
35697
|
+
function reduceSample(state, sample) {
|
|
35698
|
+
return {
|
|
35699
|
+
...state,
|
|
35700
|
+
samples: state.samples + 1,
|
|
35701
|
+
lastStats: sample.stats ?? state.lastStats,
|
|
35702
|
+
lastServer: sample.server ?? state.lastServer,
|
|
35703
|
+
maxServerPacketsSent: maxOrNull(state.maxServerPacketsSent, sample.server?.rtpPacketsSent ?? null),
|
|
35704
|
+
lastServerError: sample.serverError,
|
|
35705
|
+
sessionIds: sample.sessionId !== null && !state.sessionIds.includes(sample.sessionId) ? [...state.sessionIds, sample.sessionId] : state.sessionIds
|
|
35706
|
+
};
|
|
35707
|
+
}
|
|
35708
|
+
function reduceSessionRun(state, event) {
|
|
35709
|
+
switch (event.kind) {
|
|
35710
|
+
case "offer-answered": return {
|
|
35711
|
+
...state,
|
|
35712
|
+
sdpAnswer: state.sdpAnswer ?? event.sdpAnswer,
|
|
35713
|
+
sessionIds: state.sessionIds.includes(event.sessionId) ? state.sessionIds : [...state.sessionIds, event.sessionId]
|
|
35714
|
+
};
|
|
35715
|
+
case "hub-candidates": return {
|
|
35716
|
+
...state,
|
|
35717
|
+
trickled: [...state.trickled, ...event.candidates.filter((c) => !state.trickled.includes(c))],
|
|
35718
|
+
trickleDone: event.done || state.trickleDone === true
|
|
35719
|
+
};
|
|
35720
|
+
case "local-candidate": {
|
|
35721
|
+
const candidate = parseIceCandidate(event.candidate);
|
|
35722
|
+
if (candidate === null) return state;
|
|
35723
|
+
return {
|
|
35724
|
+
...state,
|
|
35725
|
+
local: [...state.local, {
|
|
35726
|
+
candidate,
|
|
35727
|
+
url: event.url,
|
|
35728
|
+
relayProtocol: event.relayProtocol
|
|
35729
|
+
}]
|
|
35730
|
+
};
|
|
35731
|
+
}
|
|
35732
|
+
case "ice-state": return {
|
|
35733
|
+
...state,
|
|
35734
|
+
ice: mark(state.ice, event.state, event.at)
|
|
35735
|
+
};
|
|
35736
|
+
case "dtls-state": return {
|
|
35737
|
+
...state,
|
|
35738
|
+
dtls: mark(state.dtls, event.state, event.at)
|
|
35739
|
+
};
|
|
35740
|
+
case "peer-state": return {
|
|
35741
|
+
...state,
|
|
35742
|
+
peer: mark(state.peer, event.state, event.at)
|
|
35743
|
+
};
|
|
35744
|
+
case "first-frame": return {
|
|
35745
|
+
...state,
|
|
35746
|
+
firstFrameAt: state.firstFrameAt ?? event.at
|
|
35747
|
+
};
|
|
35748
|
+
case "sample": return reduceSample(state, event.sample);
|
|
35749
|
+
case "reconnect": return {
|
|
35750
|
+
...state,
|
|
35751
|
+
reconnects: state.reconnects + 1
|
|
35752
|
+
};
|
|
35753
|
+
case "error": return {
|
|
35754
|
+
...state,
|
|
35755
|
+
errors: [...state.errors, event.message]
|
|
35756
|
+
};
|
|
35757
|
+
}
|
|
35758
|
+
}
|
|
35759
|
+
var CONNECTED_ICE = new Set(["connected", "completed"]);
|
|
35760
|
+
function firstAt(list, states) {
|
|
35761
|
+
return list.find((m) => states.has(m.state))?.at ?? null;
|
|
35762
|
+
}
|
|
35763
|
+
var CONNECTED = new Set(["connected"]);
|
|
35764
|
+
function timelineOf(state) {
|
|
35765
|
+
const dtlsAt = firstAt(state.dtls, CONNECTED) ?? firstAt(state.peer, CONNECTED);
|
|
35766
|
+
const dtlsFinal = state.dtls.at(-1)?.state ?? state.peer.at(-1)?.state ?? null;
|
|
35767
|
+
return {
|
|
35768
|
+
iceConnectedMs: firstAt(state.ice, CONNECTED_ICE),
|
|
35769
|
+
dtlsConnectedMs: dtlsAt,
|
|
35770
|
+
firstFrameMs: state.firstFrameAt,
|
|
35771
|
+
iceFinalState: state.ice.at(-1)?.state ?? null,
|
|
35772
|
+
dtlsFinalState: dtlsFinal
|
|
35773
|
+
};
|
|
35774
|
+
}
|
|
35775
|
+
function mediaOf(state) {
|
|
35776
|
+
const s = state.lastStats;
|
|
35777
|
+
const srv = state.lastServer;
|
|
35778
|
+
return {
|
|
35779
|
+
clientBytesReceived: s?.raw.bytesReceived ?? null,
|
|
35780
|
+
clientPacketsReceived: s?.packetsReceived ?? null,
|
|
35781
|
+
framesDecoded: s?.framesDecoded ?? null,
|
|
35782
|
+
serverPacketsSent: state.maxServerPacketsSent,
|
|
35783
|
+
serverAlive: srv?.alive ?? null,
|
|
35784
|
+
servedTier: srv?.servedTier ?? null,
|
|
35785
|
+
egressLeg: srv?.egressLeg ?? null,
|
|
35786
|
+
lossPercent: s?.lossPercent ?? null,
|
|
35787
|
+
freezeCount: s?.freezeCount ?? null,
|
|
35788
|
+
totalFreezeMs: s?.totalFreezeMs ?? null,
|
|
35789
|
+
pliCount: s?.pliCount ?? null,
|
|
35790
|
+
nackCount: s?.nackCount ?? null
|
|
35791
|
+
};
|
|
35792
|
+
}
|
|
35793
|
+
function localOf(state) {
|
|
35794
|
+
const unique = (xs) => [...new Set(xs.filter((x) => x !== null))];
|
|
35795
|
+
return {
|
|
35796
|
+
countByType: countCandidateTypes(state.local.map((l) => l.candidate)),
|
|
35797
|
+
relayProtocols: unique(state.local.filter((l) => l.candidate.type === "relay").map((l) => l.relayProtocol)),
|
|
35798
|
+
urls: unique(state.local.map((l) => l.url))
|
|
35799
|
+
};
|
|
35800
|
+
}
|
|
35801
|
+
function finalizeSessionRun(state, durationMs) {
|
|
35802
|
+
const hub = summarizeHubCandidates({
|
|
35803
|
+
sdpAnswer: state.sdpAnswer,
|
|
35804
|
+
trickled: state.trickled,
|
|
35805
|
+
trickleDone: state.trickleDone
|
|
35806
|
+
});
|
|
35807
|
+
const timeline = timelineOf(state);
|
|
35808
|
+
const media = mediaOf(state);
|
|
35809
|
+
const verdict = classifySessionVerdict({
|
|
35810
|
+
policy: state.policy,
|
|
35811
|
+
durationMs,
|
|
35812
|
+
iceConnected: timeline.iceConnectedMs !== null,
|
|
35813
|
+
iceFinalState: timeline.iceFinalState,
|
|
35814
|
+
dtlsConnected: timeline.dtlsConnectedMs !== null,
|
|
35815
|
+
dtlsFinalState: timeline.dtlsFinalState,
|
|
35816
|
+
hubFlag: hub.flag,
|
|
35817
|
+
clientPacketsReceived: media.clientPacketsReceived,
|
|
35818
|
+
framesDecoded: media.framesDecoded,
|
|
35819
|
+
serverPacketsSent: media.serverPacketsSent,
|
|
35820
|
+
pliCount: media.pliCount,
|
|
35821
|
+
freezeCount: media.freezeCount,
|
|
35822
|
+
lossPercent: media.lossPercent
|
|
35823
|
+
});
|
|
35824
|
+
return {
|
|
35825
|
+
policy: state.policy,
|
|
35826
|
+
durationMs,
|
|
35827
|
+
sessionIds: state.sessionIds,
|
|
35828
|
+
hub,
|
|
35829
|
+
local: localOf(state),
|
|
35830
|
+
timeline,
|
|
35831
|
+
path: {
|
|
35832
|
+
pairType: state.lastStats?.candidatePairType ?? null,
|
|
35833
|
+
protocol: state.lastStats?.candidateProtocol ?? null,
|
|
35834
|
+
rttMs: state.lastStats?.rttMs ?? null
|
|
35835
|
+
},
|
|
35836
|
+
media,
|
|
35837
|
+
reconnects: state.reconnects,
|
|
35838
|
+
errors: state.errors,
|
|
35839
|
+
serverError: state.lastServerError,
|
|
35840
|
+
verdict
|
|
35841
|
+
};
|
|
35842
|
+
}
|
|
35843
|
+
//#endregion
|
|
35844
|
+
//#region src/composites/webrtc-diagnostics/HubSessionTestCard.tsx
|
|
35845
|
+
/**
|
|
35846
|
+
* "Hub session test" — a real live session from THIS browser to the hub,
|
|
35847
|
+
* through the same `CameraStreamPlayer` + `useDeviceWebrtc` signaling every
|
|
35848
|
+
* viewer uses, run twice: browser ICE policy `all`, then `relay`.
|
|
35849
|
+
*
|
|
35850
|
+
* Nothing is simulated. The player's own client-offer path builds the offer,
|
|
35851
|
+
* trickles, polls the hub's candidates, and reports its 1 Hz
|
|
35852
|
+
* `StreamDebugSample` (the `webrtc-debug-stats` collector plus the server's
|
|
35853
|
+
* `getSessionDebug`). This card only LISTENS: it wraps the signaling callbacks
|
|
35854
|
+
* to see the hub's answer SDP and trickled candidates, and adds listeners to
|
|
35855
|
+
* each peer connection the player builds (`onPeerConnection`) for the ICE /
|
|
35856
|
+
* DTLS timeline and the browser's own candidates. The target is pinned (a low
|
|
35857
|
+
* or sub stream by default) so a large keyframe does not decide the verdict.
|
|
35858
|
+
*/
|
|
35859
|
+
var HUB_SESSION_RUN_MS = 15e3;
|
|
35860
|
+
var POLICIES = ["all", "relay"];
|
|
35861
|
+
/** Between runs, so the first session is closed on the hub before the second opens. */
|
|
35862
|
+
var RUN_GAP_MS = 750;
|
|
35863
|
+
var PROGRESS_TICK_MS = 500;
|
|
35864
|
+
/** Pinned targets only, cheapest first: an adaptive target would climb mid-run. */
|
|
35865
|
+
function pinnedStreamChoices(streams) {
|
|
35866
|
+
const rank = (s) => {
|
|
35867
|
+
const t = s.target;
|
|
35868
|
+
if (t.kind === "profile") return t.profile === "low" ? 0 : t.profile === "mid" ? 2 : 3;
|
|
35869
|
+
if (t.kind === "cam-stream") return /sub|low|ext/i.test(t.camStreamId) ? 1 : 4;
|
|
35870
|
+
return 9;
|
|
35871
|
+
};
|
|
35872
|
+
return streams.filter((s) => s.target.kind !== "adaptive").toSorted((a, b) => rank(a) - rank(b));
|
|
35873
|
+
}
|
|
35874
|
+
var POLICY_LABEL = {
|
|
35875
|
+
all: "ICE policy all",
|
|
35876
|
+
relay: "ICE policy relay (TURN only)"
|
|
35877
|
+
};
|
|
35878
|
+
function HubSessionTestCard({ trpc, serverUrl, deviceId, cameraField, runMs = HUB_SESSION_RUN_MS }) {
|
|
35879
|
+
const webrtc = useDeviceWebrtc(trpc, deviceId);
|
|
35880
|
+
const choices = (0, react$1.useMemo)(() => pinnedStreamChoices(webrtc.streams), [webrtc.streams]);
|
|
35881
|
+
const [choiceId, setChoiceId] = (0, react$1.useState)(null);
|
|
35882
|
+
const choice = choices.find((c) => c.id === choiceId) ?? choices[0] ?? null;
|
|
35883
|
+
const [live, setLive] = (0, react$1.useState)(null);
|
|
35884
|
+
const [reports, setReports] = (0, react$1.useState)([]);
|
|
35885
|
+
const [elapsedMs, setElapsedMs] = (0, react$1.useState)(0);
|
|
35886
|
+
/** True from Run until the last policy reported or Stop — the gap between runs included. */
|
|
35887
|
+
const [active, setActive] = (0, react$1.useState)(false);
|
|
35888
|
+
const stateRef = (0, react$1.useRef)(emptySessionRun("all"));
|
|
35889
|
+
const t0Ref = (0, react$1.useRef)(0);
|
|
35890
|
+
const tokenRef = (0, react$1.useRef)(0);
|
|
35891
|
+
const gapTimerRef = (0, react$1.useRef)(null);
|
|
35892
|
+
const elapsed = (0, react$1.useCallback)(() => Math.round(performance.now() - t0Ref.current), []);
|
|
35893
|
+
/** Fold one observation into the CURRENT run; a late event from a closed run is dropped. */
|
|
35894
|
+
const record = (0, react$1.useCallback)((token, event) => {
|
|
35895
|
+
if (token !== tokenRef.current) return;
|
|
35896
|
+
stateRef.current = reduceSessionRun(stateRef.current, event);
|
|
35897
|
+
}, []);
|
|
35898
|
+
const stop = (0, react$1.useCallback)(() => {
|
|
35899
|
+
tokenRef.current += 1;
|
|
35900
|
+
if (gapTimerRef.current !== null) clearTimeout(gapTimerRef.current);
|
|
35901
|
+
gapTimerRef.current = null;
|
|
35902
|
+
setLive(null);
|
|
35903
|
+
setActive(false);
|
|
35904
|
+
}, []);
|
|
35905
|
+
const startRun = (0, react$1.useCallback)((index) => {
|
|
35906
|
+
const policy = POLICIES[index];
|
|
35907
|
+
if (policy === void 0) return;
|
|
35908
|
+
tokenRef.current += 1;
|
|
35909
|
+
stateRef.current = emptySessionRun(policy);
|
|
35910
|
+
t0Ref.current = performance.now();
|
|
35911
|
+
setElapsedMs(0);
|
|
35912
|
+
setLive({
|
|
35913
|
+
index,
|
|
35914
|
+
policy,
|
|
35915
|
+
token: tokenRef.current
|
|
35916
|
+
});
|
|
35917
|
+
}, []);
|
|
35918
|
+
(0, react$1.useEffect)(() => {
|
|
35919
|
+
if (live === null) return;
|
|
35920
|
+
const tick = setInterval(() => setElapsedMs(elapsed()), PROGRESS_TICK_MS);
|
|
35921
|
+
const done = setTimeout(() => {
|
|
35922
|
+
const report = finalizeSessionRun(stateRef.current, elapsed());
|
|
35923
|
+
setReports((prev) => [...prev, report]);
|
|
35924
|
+
tokenRef.current += 1;
|
|
35925
|
+
setLive(null);
|
|
35926
|
+
const next = live.index + 1;
|
|
35927
|
+
if (next < POLICIES.length) gapTimerRef.current = setTimeout(() => startRun(next), RUN_GAP_MS);
|
|
35928
|
+
else setActive(false);
|
|
35929
|
+
}, runMs);
|
|
35930
|
+
return () => {
|
|
35931
|
+
clearInterval(tick);
|
|
35932
|
+
clearTimeout(done);
|
|
35933
|
+
};
|
|
35934
|
+
}, [
|
|
35935
|
+
live,
|
|
35936
|
+
runMs,
|
|
35937
|
+
elapsed,
|
|
35938
|
+
startRun
|
|
35939
|
+
]);
|
|
35940
|
+
(0, react$1.useEffect)(() => () => {
|
|
35941
|
+
tokenRef.current += 1;
|
|
35942
|
+
if (gapTimerRef.current !== null) clearTimeout(gapTimerRef.current);
|
|
35943
|
+
}, []);
|
|
35944
|
+
const start = (0, react$1.useCallback)(() => {
|
|
35945
|
+
setReports([]);
|
|
35946
|
+
setActive(true);
|
|
35947
|
+
startRun(0);
|
|
35948
|
+
}, [startRun]);
|
|
35949
|
+
const target = choice?.target ?? null;
|
|
35950
|
+
const token = live?.token ?? -1;
|
|
35951
|
+
const handleOffer = (0, react$1.useCallback)(async (sdpOffer, sessionId, hints) => {
|
|
35952
|
+
if (target === null) throw new Error("no stream selected");
|
|
35953
|
+
const res = await webrtc.handleOffer(target, sdpOffer, sessionId, hints);
|
|
35954
|
+
record(token, {
|
|
35955
|
+
kind: "offer-answered",
|
|
35956
|
+
at: elapsed(),
|
|
35957
|
+
sessionId: res.sessionId,
|
|
35958
|
+
sdpAnswer: res.sdpAnswer
|
|
35959
|
+
});
|
|
35960
|
+
return res;
|
|
35961
|
+
}, [
|
|
35962
|
+
target,
|
|
35963
|
+
webrtc,
|
|
35964
|
+
record,
|
|
35965
|
+
token,
|
|
35966
|
+
elapsed
|
|
35967
|
+
]);
|
|
35968
|
+
const getIceCandidates = (0, react$1.useCallback)(async (sessionId) => {
|
|
35969
|
+
const res = await webrtc.getIceCandidates(sessionId);
|
|
35970
|
+
record(token, {
|
|
35971
|
+
kind: "hub-candidates",
|
|
35972
|
+
at: elapsed(),
|
|
35973
|
+
candidates: res.candidates.map((c) => c.candidate ?? "").filter((c) => c.length > 0),
|
|
35974
|
+
done: res.done
|
|
35975
|
+
});
|
|
35976
|
+
return res;
|
|
35977
|
+
}, [
|
|
35978
|
+
webrtc,
|
|
35979
|
+
record,
|
|
35980
|
+
token,
|
|
35981
|
+
elapsed
|
|
35982
|
+
]);
|
|
35983
|
+
const onPeerConnection = (0, react$1.useCallback)((pc) => {
|
|
35984
|
+
const seen = /* @__PURE__ */ new WeakSet();
|
|
35985
|
+
pc.addEventListener("iceconnectionstatechange", () => record(token, {
|
|
35986
|
+
kind: "ice-state",
|
|
35987
|
+
at: elapsed(),
|
|
35988
|
+
state: pc.iceConnectionState
|
|
35989
|
+
}));
|
|
35990
|
+
pc.addEventListener("connectionstatechange", () => record(token, {
|
|
35991
|
+
kind: "peer-state",
|
|
35992
|
+
at: elapsed(),
|
|
35993
|
+
state: pc.connectionState
|
|
35994
|
+
}));
|
|
35995
|
+
pc.addEventListener("icecandidate", (e) => {
|
|
35996
|
+
const probed = probedCandidateFromEvent(e, elapsed());
|
|
35997
|
+
if (probed === null) return;
|
|
35998
|
+
record(token, {
|
|
35999
|
+
kind: "local-candidate",
|
|
36000
|
+
at: probed.tMs,
|
|
36001
|
+
candidate: probed.candidate.raw,
|
|
36002
|
+
url: probed.url,
|
|
36003
|
+
relayProtocol: probed.relayProtocol
|
|
36004
|
+
});
|
|
36005
|
+
});
|
|
36006
|
+
pc.addEventListener("track", (e) => {
|
|
36007
|
+
const transport = e.receiver.transport;
|
|
36008
|
+
if (transport === null || seen.has(transport)) return;
|
|
36009
|
+
seen.add(transport);
|
|
36010
|
+
record(token, {
|
|
36011
|
+
kind: "dtls-state",
|
|
36012
|
+
at: elapsed(),
|
|
36013
|
+
state: transport.state
|
|
36014
|
+
});
|
|
36015
|
+
transport.addEventListener("statechange", () => record(token, {
|
|
36016
|
+
kind: "dtls-state",
|
|
36017
|
+
at: elapsed(),
|
|
36018
|
+
state: transport.state
|
|
36019
|
+
}));
|
|
36020
|
+
});
|
|
36021
|
+
}, [
|
|
36022
|
+
record,
|
|
36023
|
+
token,
|
|
36024
|
+
elapsed
|
|
36025
|
+
]);
|
|
36026
|
+
const onDebugStats = (0, react$1.useCallback)((sample) => record(token, {
|
|
36027
|
+
kind: "sample",
|
|
36028
|
+
at: elapsed(),
|
|
36029
|
+
sample
|
|
36030
|
+
}), [
|
|
36031
|
+
record,
|
|
36032
|
+
token,
|
|
36033
|
+
elapsed
|
|
36034
|
+
]);
|
|
36035
|
+
const onConnectTiming = (0, react$1.useCallback)((timing) => {
|
|
36036
|
+
if (timing["firstFrameMs"] !== void 0) record(token, {
|
|
36037
|
+
kind: "first-frame",
|
|
36038
|
+
at: elapsed()
|
|
36039
|
+
});
|
|
36040
|
+
}, [
|
|
36041
|
+
record,
|
|
36042
|
+
token,
|
|
36043
|
+
elapsed
|
|
36044
|
+
]);
|
|
36045
|
+
const progress = live === null ? null : Math.min(100, Math.round(elapsedMs / runMs * 100));
|
|
36046
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(SectionCard, {
|
|
36047
|
+
headingId: "webrtc-diag-hub-session",
|
|
36048
|
+
title: "Hub session test",
|
|
36049
|
+
icon: Activity,
|
|
36050
|
+
testId: "webrtc-diag-hub-session",
|
|
36051
|
+
actions: !active ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Button, {
|
|
36052
|
+
onClick: start,
|
|
36053
|
+
disabled: !(deviceId !== null && choice !== null && !active),
|
|
36054
|
+
children: [
|
|
36055
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Play, { className: "h-3 w-3" }),
|
|
36056
|
+
"Run test (",
|
|
36057
|
+
String(Math.round(runMs * POLICIES.length / 1e3)),
|
|
36058
|
+
" s)"
|
|
36059
|
+
]
|
|
36060
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Button, {
|
|
36061
|
+
variant: "outline",
|
|
36062
|
+
onClick: stop,
|
|
36063
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Square, { className: "h-3 w-3" }), "Stop"]
|
|
36064
|
+
}),
|
|
36065
|
+
children: [
|
|
36066
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
36067
|
+
className: `${SECTION_BODY} space-y-3`,
|
|
36068
|
+
children: [
|
|
36069
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
36070
|
+
className: `grid grid-cols-1 sm:grid-cols-2 ${GRID_GAP}`,
|
|
36071
|
+
children: [cameraField, /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
36072
|
+
className: "flex min-w-0 flex-col gap-1",
|
|
36073
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
36074
|
+
className: "text-[11px] text-foreground-subtle",
|
|
36075
|
+
children: "Stream (pinned)"
|
|
36076
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Select, {
|
|
36077
|
+
value: choice?.id ?? "",
|
|
36078
|
+
disabled: choices.length === 0 || active,
|
|
36079
|
+
onChange: (e) => setChoiceId(e.target.value),
|
|
36080
|
+
options: choices.length === 0 ? [{
|
|
36081
|
+
value: "",
|
|
36082
|
+
label: deviceId === null ? "Pick a camera first" : "No pinned stream"
|
|
36083
|
+
}] : choices.map((c) => ({
|
|
36084
|
+
value: c.id,
|
|
36085
|
+
label: c.label
|
|
36086
|
+
}))
|
|
36087
|
+
})]
|
|
36088
|
+
})]
|
|
36089
|
+
}),
|
|
36090
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
36091
|
+
className: TEXT_HINT,
|
|
36092
|
+
children: "Opens a real live session from this browser twice — first with every candidate type, then TURN relay only — and reads both ends of it."
|
|
36093
|
+
}),
|
|
36094
|
+
live !== null && deviceId !== null && choice !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
36095
|
+
className: `grid grid-cols-1 sm:grid-cols-[minmax(0,16rem)_1fr] gap-2 sm:gap-3 items-start`,
|
|
36096
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
36097
|
+
className: "aspect-video w-full overflow-hidden rounded-md border border-border bg-black",
|
|
36098
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CameraStreamPlayer, {
|
|
36099
|
+
serverUrl,
|
|
36100
|
+
streamKey: `diag-${choice.id}-${live.policy}`,
|
|
36101
|
+
autoPlay: true,
|
|
36102
|
+
muted: true,
|
|
36103
|
+
showControls: false,
|
|
36104
|
+
className: "h-full w-full",
|
|
36105
|
+
handleOffer,
|
|
36106
|
+
getIceServers: webrtc.getIceServers,
|
|
36107
|
+
addIceCandidate: webrtc.addIceCandidate,
|
|
36108
|
+
getIceCandidates,
|
|
36109
|
+
closeSession: webrtc.closeSession,
|
|
36110
|
+
getSessionDebug: webrtc.getSessionDebug,
|
|
36111
|
+
applyDownlinkProbe: webrtc.applyDownlinkProbe,
|
|
36112
|
+
iceTransportPolicy: live.policy,
|
|
36113
|
+
onPeerConnection,
|
|
36114
|
+
onDebugStats,
|
|
36115
|
+
onConnectTiming,
|
|
36116
|
+
onReconnectAttempt: () => record(token, {
|
|
36117
|
+
kind: "reconnect",
|
|
36118
|
+
at: elapsed()
|
|
36119
|
+
}),
|
|
36120
|
+
onError: (message) => record(token, {
|
|
36121
|
+
kind: "error",
|
|
36122
|
+
at: elapsed(),
|
|
36123
|
+
message
|
|
36124
|
+
})
|
|
36125
|
+
}, `${String(live.token)}-${live.policy}`)
|
|
36126
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
36127
|
+
className: "space-y-1.5",
|
|
36128
|
+
"data-testid": "webrtc-diag-progress",
|
|
36129
|
+
children: [
|
|
36130
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
36131
|
+
className: "flex items-center gap-2 text-xs text-foreground",
|
|
36132
|
+
children: [
|
|
36133
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LoaderCircle, { className: "h-3 w-3 animate-spin" }),
|
|
36134
|
+
"Run ",
|
|
36135
|
+
String(live.index + 1),
|
|
36136
|
+
" of ",
|
|
36137
|
+
String(POLICIES.length),
|
|
36138
|
+
" ·",
|
|
36139
|
+
" ",
|
|
36140
|
+
POLICY_LABEL[live.policy]
|
|
36141
|
+
]
|
|
36142
|
+
}),
|
|
36143
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
36144
|
+
className: "h-1.5 w-full overflow-hidden rounded bg-surface-hover",
|
|
36145
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
36146
|
+
className: "h-full bg-primary transition-[width]",
|
|
36147
|
+
style: { width: `${String(progress ?? 0)}%` }
|
|
36148
|
+
})
|
|
36149
|
+
}),
|
|
36150
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
36151
|
+
className: "text-[10px] text-foreground-subtle",
|
|
36152
|
+
children: [
|
|
36153
|
+
String(Math.round(elapsedMs / 1e3)),
|
|
36154
|
+
" s of ",
|
|
36155
|
+
String(Math.round(runMs / 1e3)),
|
|
36156
|
+
" s"
|
|
36157
|
+
]
|
|
36158
|
+
})
|
|
36159
|
+
]
|
|
36160
|
+
})]
|
|
36161
|
+
})
|
|
36162
|
+
]
|
|
36163
|
+
}),
|
|
36164
|
+
reports.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
36165
|
+
className: `grid grid grid-cols-1 lg:grid-cols-2 gap-2 sm:gap-3 border-t border-border p-2 sm:p-3`,
|
|
36166
|
+
children: reports.map((r, i) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RunReport, {
|
|
36167
|
+
report: r,
|
|
36168
|
+
title: `Run ${String(i + 1)} · ${POLICY_LABEL[r.policy]}`
|
|
36169
|
+
}, `${String(i)}-${r.policy}`))
|
|
36170
|
+
}),
|
|
36171
|
+
reports.length === 0 && !active && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
36172
|
+
className: `px-3 sm:px-4 py-1.5 sm:py-2 border-t border-border text-[10px] text-foreground-subtle`,
|
|
36173
|
+
children: "No run yet."
|
|
36174
|
+
})
|
|
36175
|
+
]
|
|
36176
|
+
});
|
|
36177
|
+
}
|
|
36178
|
+
//#endregion
|
|
36179
|
+
//#region src/composites/cap-settings/scene-monitor-copy.ts
|
|
36180
|
+
/** The lighting variants an operator is OFFERED. The resolver can also report
|
|
36181
|
+
* `night`, `dawn` and `dusk`; those are shown when present but never demanded,
|
|
36182
|
+
* because most installs only ever see day and IR. */
|
|
36183
|
+
var SCENE_CAPTURE_VARIANTS = ["day", "ir"];
|
|
36184
|
+
var VARIANT_LABEL = {
|
|
36185
|
+
day: "Daylight",
|
|
36186
|
+
ir: "Night (IR)",
|
|
36187
|
+
night: "Night",
|
|
36188
|
+
dawn: "Dawn",
|
|
36189
|
+
dusk: "Dusk"
|
|
36190
|
+
};
|
|
36191
|
+
function variantLabel(condition) {
|
|
36192
|
+
return VARIANT_LABEL[condition] ?? condition;
|
|
36193
|
+
}
|
|
34256
36194
|
/**
|
|
34257
|
-
*
|
|
36195
|
+
* "Daylight ✓ · Night (IR) ✗ — optional; checks in night (ir) are skipped."
|
|
34258
36196
|
*
|
|
34259
|
-
*
|
|
34260
|
-
*
|
|
34261
|
-
*
|
|
34262
|
-
* sorted). Returns the next direction, or `null` for "unsorted".
|
|
36197
|
+
* Converts the silent cross-condition fallback this engine used to have into an
|
|
36198
|
+
* operator-visible fact. Without it, a scene with only a daylight reference
|
|
36199
|
+
* simply stops answering after sunset and nothing anywhere says why.
|
|
34263
36200
|
*
|
|
34264
|
-
*
|
|
34265
|
-
*
|
|
34266
|
-
*
|
|
36201
|
+
* It used to end *"reports 'can't tell' in night (ir)"*, which was accurate and
|
|
36202
|
+
* read as a defect: the operator saw a ✗ and a complaint for a scene that was
|
|
36203
|
+
* configured exactly as they meant it. The missing half is now named as what it
|
|
36204
|
+
* is — the part you do not have to do — and the sentence says what actually
|
|
36205
|
+
* happens instead, which differs by the scene's own policy.
|
|
34267
36206
|
*/
|
|
34268
|
-
function
|
|
34269
|
-
|
|
34270
|
-
|
|
36207
|
+
function coverageLine(monitor) {
|
|
36208
|
+
const covered = new Set(monitor.coveredConditions);
|
|
36209
|
+
const parts = SCENE_CAPTURE_VARIANTS.map((v) => `${variantLabel(v)} ${covered.has(v) ? "✓" : "✗"}`);
|
|
36210
|
+
const extra = [...covered].filter((c) => !SCENE_CAPTURE_VARIANTS.some((v) => v === c));
|
|
36211
|
+
for (const c of extra) parts.push(`${variantLabel(c)} ✓`);
|
|
36212
|
+
const missing = SCENE_CAPTURE_VARIANTS.filter((v) => !covered.has(v));
|
|
36213
|
+
if (missing.length === 0) return parts.join(" · ");
|
|
36214
|
+
if (covered.size === 0) return `${parts.join(" · ")} — nothing captured yet, so this scene is not watching.`;
|
|
36215
|
+
const names = missing.map((v) => variantLabel(v).toLowerCase()).join(" and ");
|
|
36216
|
+
return monitor.onUncoveredCondition === "judge-anyway" ? `${parts.join(" · ")} — ${names} judged against the references you do have.` : `${parts.join(" · ")} — optional; checks in ${names} are skipped.`;
|
|
34271
36217
|
}
|
|
34272
36218
|
/**
|
|
34273
|
-
*
|
|
34274
|
-
*
|
|
36219
|
+
* The calm sentence for a scene that is sitting the current light out — `null`
|
|
36220
|
+
* whenever it is checking normally.
|
|
36221
|
+
*
|
|
36222
|
+
* Separate from {@link statusLine} because it is not a verdict and must not
|
|
36223
|
+
* read like one. A paused scene has not failed, has not lost its flag, and
|
|
36224
|
+
* needs nothing from the operator; the only thing worth saying is which light
|
|
36225
|
+
* it is waiting out and that it will pick itself back up.
|
|
34275
36226
|
*/
|
|
34276
|
-
function
|
|
34277
|
-
|
|
34278
|
-
|
|
34279
|
-
|
|
34280
|
-
dir
|
|
34281
|
-
};
|
|
36227
|
+
function suspensionLine(monitor) {
|
|
36228
|
+
if (monitor.suspendedCondition === null) return null;
|
|
36229
|
+
const light = variantLabel(monitor.suspendedCondition).toLowerCase();
|
|
36230
|
+
return `Paused — nothing was captured in ${light}, so checks are on hold until the light changes back. Capture ${light} if you want it watched around the clock.`;
|
|
34282
36231
|
}
|
|
36232
|
+
var UNAVAILABLE_SENTENCE = {
|
|
36233
|
+
"no-reference-for-condition": "can’t tell — nothing has been captured for this scene yet. Press “Capture daylight” to photograph what normal looks like.",
|
|
36234
|
+
"view-shifted": "can’t tell — the camera view has moved since the reference was captured. Re-capture it.",
|
|
36235
|
+
"no-vision-profile": "can’t tell — the vision model is unreachable, so the change is on hold.",
|
|
36236
|
+
"encoder-model-changed": "can’t tell — the embedding model changed, so the stored references are unreadable. Re-capture them.",
|
|
36237
|
+
"no-snapshot": "can’t tell — this camera did not return a frame."
|
|
36238
|
+
};
|
|
34283
36239
|
/**
|
|
34284
|
-
*
|
|
34285
|
-
*
|
|
34286
|
-
* Numbers compare numerically and strings compare with `localeCompare` — a
|
|
34287
|
-
* numeric column must never be ordered as text ("100" before "9"), and a text
|
|
34288
|
-
* column must respect the locale's collation. A mixed column (one row a
|
|
34289
|
-
* number, another a string) falls back to string comparison of both, which is
|
|
34290
|
-
* the only total order available; it is not a shape any caller should build on
|
|
34291
|
-
* purpose.
|
|
34292
|
-
*
|
|
34293
|
-
* `null` is NOT handled here — {@link sortRowsByColumn} pulls it out first,
|
|
34294
|
-
* because its placement is direction-independent and a comparator that is
|
|
34295
|
-
* multiplied by the direction factor cannot express that.
|
|
36240
|
+
* The one line under a scene's name. It answers, in order: can it judge, what
|
|
36241
|
+
* does it say, and how long has it said it.
|
|
34296
36242
|
*/
|
|
34297
|
-
function
|
|
34298
|
-
if (
|
|
34299
|
-
|
|
36243
|
+
function statusLine(monitor, now) {
|
|
36244
|
+
if (monitor.states.length === 0) return "Not armed yet — capture the initial state to start watching.";
|
|
36245
|
+
if (monitor.verdict === "unknown") return monitor.unavailable !== null ? UNAVAILABLE_SENTENCE[monitor.unavailable] : "can’t tell right now.";
|
|
36246
|
+
const since = monitor.verdict === "diverged" ? monitor.divergedAt : monitor.armedAt;
|
|
36247
|
+
const held = since !== null ? ` for ${humanDuration(Math.max(0, now - since))}` : "";
|
|
36248
|
+
if (monitor.latched) return monitor.verdict === "diverged" ? `CHANGED${held} — press Reset when you have dealt with it.` : `Back to normal, but still flagged as changed${held}. Press Reset to clear it.`;
|
|
36249
|
+
const paused = suspensionLine(monitor);
|
|
36250
|
+
if (paused !== null) return paused;
|
|
36251
|
+
return monitor.verdict === "diverged" ? `CHANGED${held}` : `Normal${held}`;
|
|
34300
36252
|
}
|
|
34301
|
-
/**
|
|
34302
|
-
*
|
|
36253
|
+
/** The badge word. `latched` wins over `verdict`, because the latch is the
|
|
36254
|
+
* thing the operator asked to be told and it outlives the live state.
|
|
34303
36255
|
*
|
|
34304
|
-
*
|
|
34305
|
-
*
|
|
34306
|
-
*
|
|
34307
|
-
*/
|
|
34308
|
-
function
|
|
34309
|
-
if (
|
|
34310
|
-
|
|
34311
|
-
|
|
34312
|
-
|
|
34313
|
-
|
|
34314
|
-
|
|
34315
|
-
|
|
34316
|
-
|
|
34317
|
-
|
|
34318
|
-
|
|
34319
|
-
|
|
36256
|
+
* A scene sitting out an uncovered light reads `Paused`, in the NEUTRAL tone —
|
|
36257
|
+
* never `alarm`, and never the `danger` variant the page maps that to. A
|
|
36258
|
+
* missing IR reference must not be able to paint a red badge on a scene that
|
|
36259
|
+
* is doing exactly what it was configured to do. */
|
|
36260
|
+
function badgeFor(monitor) {
|
|
36261
|
+
if (monitor.states.length === 0) return {
|
|
36262
|
+
text: "Not armed",
|
|
36263
|
+
tone: "unknown"
|
|
36264
|
+
};
|
|
36265
|
+
if (monitor.latched) return {
|
|
36266
|
+
text: "Changed",
|
|
36267
|
+
tone: "alarm"
|
|
36268
|
+
};
|
|
36269
|
+
if (monitor.suspendedCondition !== null) return {
|
|
36270
|
+
text: "Paused",
|
|
36271
|
+
tone: "unknown"
|
|
36272
|
+
};
|
|
36273
|
+
if (monitor.verdict === "unknown") return {
|
|
36274
|
+
text: "Can’t tell",
|
|
36275
|
+
tone: "unknown"
|
|
36276
|
+
};
|
|
36277
|
+
return monitor.verdict === "diverged" ? {
|
|
36278
|
+
text: "Changed",
|
|
36279
|
+
tone: "alarm"
|
|
36280
|
+
} : {
|
|
36281
|
+
text: "Normal",
|
|
36282
|
+
tone: "ok"
|
|
36283
|
+
};
|
|
34320
36284
|
}
|
|
34321
|
-
/**
|
|
34322
|
-
|
|
34323
|
-
|
|
34324
|
-
|
|
36285
|
+
/** A rect covering most of the frame is dominated by weather, sky and parked
|
|
36286
|
+
* cars: the bin is 2 % of the pixels and CLIP will not notice it leaving. The
|
|
36287
|
+
* failure is silent INSENSITIVITY, which is the worst kind — hence a warning
|
|
36288
|
+
* rather than a refusal. */
|
|
36289
|
+
var ROI_AREA_WARN_FRACTION = .4;
|
|
36290
|
+
function roiTooLarge(roi) {
|
|
36291
|
+
return roi.width * roi.height > ROI_AREA_WARN_FRACTION;
|
|
36292
|
+
}
|
|
36293
|
+
function humanDuration(ms) {
|
|
36294
|
+
const s = Math.floor(ms / 1e3);
|
|
36295
|
+
if (s < 60) return `${s}s`;
|
|
36296
|
+
const m = Math.floor(s / 60);
|
|
36297
|
+
if (m < 60) return `${m}m`;
|
|
36298
|
+
const h = Math.floor(m / 60);
|
|
36299
|
+
if (h < 48) return `${h}h`;
|
|
36300
|
+
return `${Math.floor(h / 24)}d`;
|
|
34325
36301
|
}
|
|
34326
36302
|
//#endregion
|
|
34327
|
-
//#region src/composites/
|
|
36303
|
+
//#region src/composites/code-block.tsx
|
|
36304
|
+
function CodeBlock({ children, maxHeight = 300, className }) {
|
|
36305
|
+
const [copied, setCopied] = (0, react$1.useState)(false);
|
|
36306
|
+
const handleCopy = (0, react$1.useCallback)(() => {
|
|
36307
|
+
navigator.clipboard.writeText(children).then(() => {
|
|
36308
|
+
setCopied(true);
|
|
36309
|
+
setTimeout(() => setCopied(false), 2e3);
|
|
36310
|
+
});
|
|
36311
|
+
}, [children]);
|
|
36312
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
36313
|
+
className: cn("relative group", className),
|
|
36314
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(ScrollArea, {
|
|
36315
|
+
style: { maxHeight },
|
|
36316
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("pre", {
|
|
36317
|
+
className: "font-mono text-xs bg-surface p-3 rounded-md border border-border-subtle",
|
|
36318
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", { children })
|
|
36319
|
+
})
|
|
36320
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
36321
|
+
className: "absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity",
|
|
36322
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconButton, {
|
|
36323
|
+
icon: copied ? Check : Copy,
|
|
36324
|
+
"aria-label": "Copy code",
|
|
36325
|
+
variant: "ghost",
|
|
36326
|
+
size: "sm",
|
|
36327
|
+
onClick: handleCopy
|
|
36328
|
+
})
|
|
36329
|
+
})]
|
|
36330
|
+
});
|
|
36331
|
+
}
|
|
36332
|
+
//#endregion
|
|
36333
|
+
//#region src/composites/confirm-action-button.tsx
|
|
34328
36334
|
/**
|
|
34329
|
-
*
|
|
34330
|
-
*
|
|
34331
|
-
* Recipes baked in:
|
|
34332
|
-
* - `overflow-x-auto` wrapper so narrow viewports don't clip the
|
|
34333
|
-
* right edge (the rounded border survives the scroll).
|
|
34334
|
-
* - `min-w-[Npx]` on the inner table so columns don't squash below
|
|
34335
|
-
* readable width before the scroll kicks in.
|
|
34336
|
-
* - `whitespace-nowrap` on headers — column titles never wrap to
|
|
34337
|
-
* two lines (`AUTO-KILL` was the regression that triggered this).
|
|
34338
|
-
* - Themed `bg-surface` + `border-border` so contrast works in both
|
|
34339
|
-
* light and dark themes.
|
|
34340
|
-
*
|
|
34341
|
-
* Column model: caller supplies `columns: DataColumn<T>[]` and
|
|
34342
|
-
* `rows: T[]`. Each column declares `header`, optional `align`,
|
|
34343
|
-
* optional `nowrap`, and a `render(row) => ReactNode` that produces
|
|
34344
|
-
* the cell content.
|
|
34345
|
-
*
|
|
34346
|
-
* <DataTable
|
|
34347
|
-
* columns={[
|
|
34348
|
-
* { key: 'username', header: 'Username', render: (u) => u.username },
|
|
34349
|
-
* { key: 'role', header: 'Role', render: (u) => <RoleBadge .../> },
|
|
34350
|
-
* { key: 'actions', header: '', align: 'right', render: (u) => <Actions .../> },
|
|
34351
|
-
* ]}
|
|
34352
|
-
* rows={users}
|
|
34353
|
-
* minWidthPx={560}
|
|
34354
|
-
* />
|
|
34355
|
-
*
|
|
34356
|
-
* If finer header markup is needed, pass `headerRender` instead of
|
|
34357
|
-
* `header` (e.g. for a sortable click affordance).
|
|
34358
|
-
*
|
|
34359
|
-
* ── Narrow viewports ──────────────────────────────────────────────────────
|
|
34360
|
-
* The table stays a table. The wrapper is `overflow-x-auto`, so a phone
|
|
34361
|
-
* scrolls the columns sideways INSIDE the table — the page body never moves
|
|
34362
|
-
* sideways, and the cells stay one line (`whitespace-nowrap`). That is the
|
|
34363
|
-
* default (`mobileMode="scroll"`). Cards are opt-in via `mobileMode="cards"`;
|
|
34364
|
-
* `auto` still applies the old column-count rule for a surface that wants it.
|
|
34365
|
-
* See `resolveTableLayout`.
|
|
34366
|
-
*
|
|
34367
|
-
* ── Scrolling the BODY, not the page ──────────────────────────────────────
|
|
34368
|
-
* `maxBodyHeight` caps the table and scrolls its rows INSIDE the container
|
|
34369
|
-
* with the column header pinned. It is ONE prop, not two, because "this list
|
|
34370
|
-
* scrolls internally" and "its header stays visible" are the same decision: a
|
|
34371
|
-
* capped table whose header scrolls away is a list of unlabelled cells.
|
|
36335
|
+
* ConfirmActionButton — destructive-action button with a modal confirm.
|
|
34372
36336
|
*
|
|
34373
|
-
*
|
|
34374
|
-
*
|
|
34375
|
-
*
|
|
34376
|
-
|
|
34377
|
-
|
|
34378
|
-
* The default cap for an internally-scrolling table.
|
|
36337
|
+
* Two-step UX for any operation operators shouldn't trigger by accident:
|
|
36338
|
+
* reboot device, restart addon, regenerate token, etc. The trigger is a
|
|
36339
|
+
* normal Button that opens a Dialog; confirming runs the async action,
|
|
36340
|
+
* the trigger spinner-disables itself for the duration, and the dialog
|
|
36341
|
+
* closes on success. Errors surface inline at the dialog's bottom.
|
|
34379
36342
|
*
|
|
34380
|
-
* `
|
|
34381
|
-
*
|
|
34382
|
-
*
|
|
36343
|
+
* Generic on `TResult` so callers don't have to discard the return
|
|
36344
|
+
* value. Pass an `icon` to render it inside the trigger (e.g. RotateCw
|
|
36345
|
+
* for reboot, RefreshCw for restart).
|
|
34383
36346
|
*/
|
|
34384
|
-
|
|
34385
|
-
|
|
34386
|
-
|
|
34387
|
-
|
|
34388
|
-
|
|
34389
|
-
|
|
34390
|
-
|
|
34391
|
-
|
|
34392
|
-
|
|
34393
|
-
|
|
34394
|
-
|
|
34395
|
-
|
|
34396
|
-
|
|
34397
|
-
|
|
34398
|
-
|
|
34399
|
-
|
|
34400
|
-
|
|
34401
|
-
|
|
34402
|
-
|
|
34403
|
-
|
|
34404
|
-
|
|
34405
|
-
|
|
34406
|
-
|
|
34407
|
-
|
|
34408
|
-
|
|
34409
|
-
|
|
34410
|
-
|
|
34411
|
-
|
|
34412
|
-
|
|
34413
|
-
|
|
34414
|
-
|
|
34415
|
-
|
|
34416
|
-
|
|
34417
|
-
|
|
34418
|
-
|
|
34419
|
-
|
|
34420
|
-
|
|
34421
|
-
|
|
34422
|
-
|
|
34423
|
-
|
|
34424
|
-
|
|
34425
|
-
|
|
34426
|
-
|
|
34427
|
-
|
|
34428
|
-
|
|
34429
|
-
|
|
34430
|
-
|
|
34431
|
-
|
|
34432
|
-
|
|
34433
|
-
|
|
34434
|
-
|
|
34435
|
-
|
|
34436
|
-
|
|
34437
|
-
|
|
34438
|
-
|
|
34439
|
-
|
|
34440
|
-
|
|
34441
|
-
|
|
34442
|
-
|
|
34443
|
-
title: sortable ? void 0 : col.notSortableReason,
|
|
34444
|
-
className: [
|
|
34445
|
-
"px-3 py-2 text-foreground-subtle font-medium bg-surface border-b border-border",
|
|
34446
|
-
scrolls ? "sticky top-0 z-10" : "",
|
|
34447
|
-
ALIGN_CLASS[align],
|
|
34448
|
-
nowrap ? "whitespace-nowrap" : "",
|
|
34449
|
-
col.width ?? ""
|
|
34450
|
-
].filter(Boolean).join(" "),
|
|
34451
|
-
children: sortable ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SortHeaderButton, {
|
|
34452
|
-
label: col.headerRender ? col.headerRender() : col.header,
|
|
34453
|
-
active: isSorted,
|
|
34454
|
-
dir: isSorted && activeSort !== null ? activeSort.dir : "asc",
|
|
34455
|
-
onSort: () => setSort((prev) => nextTableSort(prev, col.key))
|
|
34456
|
-
}) : col.headerRender ? col.headerRender() : col.header
|
|
34457
|
-
}, col.key);
|
|
34458
|
-
}) })
|
|
34459
|
-
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tbody", { children: displayRows.map((row, rowIndex) => {
|
|
34460
|
-
const key = rowKey ? rowKey(row, rowIndex) : rowIndex;
|
|
34461
|
-
const extra = rowClassName?.(row, rowIndex) ?? "";
|
|
34462
|
-
const interactive = onRowClick !== void 0;
|
|
34463
|
-
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tr", {
|
|
34464
|
-
onClick: interactive ? () => onRowClick(row, rowIndex) : void 0,
|
|
34465
|
-
className: [interactive ? "cursor-pointer hover:bg-primary/5" : "hover:bg-primary/5", extra].filter(Boolean).join(" "),
|
|
34466
|
-
children: columns.map((col) => {
|
|
34467
|
-
const align = col.align ?? "left";
|
|
34468
|
-
const nowrap = col.nowrap ?? true;
|
|
34469
|
-
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
|
|
34470
|
-
className: [
|
|
34471
|
-
"px-3 py-2 text-foreground border-b border-border",
|
|
34472
|
-
ALIGN_CLASS[align],
|
|
34473
|
-
nowrap ? "whitespace-nowrap" : ""
|
|
34474
|
-
].filter(Boolean).join(" "),
|
|
34475
|
-
children: col.render(row, rowIndex)
|
|
34476
|
-
}, col.key);
|
|
34477
|
-
})
|
|
34478
|
-
}, key);
|
|
34479
|
-
}) })]
|
|
34480
|
-
})
|
|
34481
|
-
});
|
|
36347
|
+
function ConfirmActionButton({ label, icon: Icon, title = "Confirm action", description, confirmLabel, triggerVariant = "outline", confirmVariant = "danger", size = "sm", disabled, className, action, onSuccess }) {
|
|
36348
|
+
const [open, setOpen] = (0, react$1.useState)(false);
|
|
36349
|
+
const [running, setRunning] = (0, react$1.useState)(false);
|
|
36350
|
+
const [error, setError] = (0, react$1.useState)(null);
|
|
36351
|
+
const handleConfirm = async () => {
|
|
36352
|
+
setError(null);
|
|
36353
|
+
setRunning(true);
|
|
36354
|
+
try {
|
|
36355
|
+
const result = await action();
|
|
36356
|
+
onSuccess?.(result);
|
|
36357
|
+
setOpen(false);
|
|
36358
|
+
} catch (err) {
|
|
36359
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
36360
|
+
} finally {
|
|
36361
|
+
setRunning(false);
|
|
36362
|
+
}
|
|
36363
|
+
};
|
|
36364
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Button, {
|
|
36365
|
+
type: "button",
|
|
36366
|
+
variant: triggerVariant,
|
|
36367
|
+
size,
|
|
36368
|
+
disabled,
|
|
36369
|
+
onClick: () => {
|
|
36370
|
+
setError(null);
|
|
36371
|
+
setOpen(true);
|
|
36372
|
+
},
|
|
36373
|
+
className,
|
|
36374
|
+
children: [Icon && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon, { className: "h-3.5 w-3.5" }), label]
|
|
36375
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Dialog, {
|
|
36376
|
+
open,
|
|
36377
|
+
onOpenChange: (next) => !running && setOpen(next),
|
|
36378
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(DialogContent, { children: [
|
|
36379
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(DialogHeader, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(DialogTitle, {
|
|
36380
|
+
className: "flex items-center gap-2",
|
|
36381
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(TriangleAlert, { className: "h-4 w-4 text-amber-400" }), title]
|
|
36382
|
+
}), typeof description === "string" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DialogDescription, { children: description }) : description] }),
|
|
36383
|
+
error && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
36384
|
+
className: cn("mt-2 px-3 py-2 rounded border border-danger/40 bg-danger/10", "text-xs text-danger"),
|
|
36385
|
+
children: error
|
|
36386
|
+
}),
|
|
36387
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(DialogFooter, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
|
|
36388
|
+
type: "button",
|
|
36389
|
+
variant: "ghost",
|
|
36390
|
+
size,
|
|
36391
|
+
disabled: running,
|
|
36392
|
+
onClick: () => setOpen(false),
|
|
36393
|
+
children: "Cancel"
|
|
36394
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Button, {
|
|
36395
|
+
type: "button",
|
|
36396
|
+
variant: confirmVariant,
|
|
36397
|
+
size,
|
|
36398
|
+
disabled: running,
|
|
36399
|
+
onClick: () => {
|
|
36400
|
+
handleConfirm();
|
|
36401
|
+
},
|
|
36402
|
+
children: [running && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LoaderCircle, { className: "h-3.5 w-3.5 animate-spin" }), confirmLabel ?? label]
|
|
36403
|
+
})] })
|
|
36404
|
+
] })
|
|
36405
|
+
})] });
|
|
34482
36406
|
}
|
|
36407
|
+
//#endregion
|
|
36408
|
+
//#region src/composites/copy-button.tsx
|
|
34483
36409
|
/**
|
|
34484
|
-
*
|
|
34485
|
-
*
|
|
34486
|
-
*
|
|
34487
|
-
*
|
|
34488
|
-
* full-width strip at the foot of the card.
|
|
36410
|
+
* `CopyButton` — a compact button that copies a string `value` to the
|
|
36411
|
+
* clipboard via `navigator.clipboard.writeText`, showing a brief
|
|
36412
|
+
* "copied" check state. Addon-agnostic; used wherever a secret / URL
|
|
36413
|
+
* needs a one-click copy affordance (export setup panels, etc.).
|
|
34489
36414
|
*/
|
|
34490
|
-
|
|
34491
|
-
|
|
34492
|
-
const
|
|
34493
|
-
const
|
|
34494
|
-
|
|
34495
|
-
|
|
34496
|
-
|
|
34497
|
-
|
|
34498
|
-
|
|
34499
|
-
|
|
34500
|
-
|
|
34501
|
-
|
|
34502
|
-
|
|
34503
|
-
|
|
34504
|
-
|
|
34505
|
-
|
|
34506
|
-
|
|
34507
|
-
|
|
34508
|
-
|
|
34509
|
-
|
|
34510
|
-
|
|
36415
|
+
var COPIED_RESET_MS = 2e3;
|
|
36416
|
+
function CopyButton({ value, label, srLabel, className, disabled }) {
|
|
36417
|
+
const [copied, setCopied] = (0, react$1.useState)(false);
|
|
36418
|
+
const handleCopy = (0, react$1.useCallback)(() => {
|
|
36419
|
+
if (!value) return;
|
|
36420
|
+
navigator.clipboard.writeText(value).then(() => {
|
|
36421
|
+
setCopied(true);
|
|
36422
|
+
setTimeout(() => setCopied(false), COPIED_RESET_MS);
|
|
36423
|
+
});
|
|
36424
|
+
}, [value]);
|
|
36425
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Button, {
|
|
36426
|
+
size: "sm",
|
|
36427
|
+
variant: "ghost",
|
|
36428
|
+
type: "button",
|
|
36429
|
+
disabled: disabled || value.length === 0,
|
|
36430
|
+
onClick: handleCopy,
|
|
36431
|
+
className: cn(className),
|
|
36432
|
+
"aria-label": copied ? "Copied" : `Copy ${srLabel ?? label ?? "value"}`,
|
|
36433
|
+
children: [copied ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Check, { className: "h-3.5 w-3.5 text-success" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Copy, { className: "h-3.5 w-3.5" }), label ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
36434
|
+
className: "ml-1",
|
|
36435
|
+
children: copied ? "Copied" : label
|
|
36436
|
+
}) : null]
|
|
34511
36437
|
});
|
|
34512
36438
|
}
|
|
34513
36439
|
//#endregion
|
|
@@ -54174,30 +56100,6 @@ function DoorbellRecentPanel({ history, limit = 5, latestIsFresh, className }) {
|
|
|
54174
56100
|
});
|
|
54175
56101
|
}
|
|
54176
56102
|
//#endregion
|
|
54177
|
-
//#region src/composites/section-card.tsx
|
|
54178
|
-
function SectionCard({ headingId, title, icon: Icon, actions, testId, children }) {
|
|
54179
|
-
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
|
|
54180
|
-
"aria-labelledby": headingId,
|
|
54181
|
-
className: SECTION_CARD,
|
|
54182
|
-
"data-testid": testId,
|
|
54183
|
-
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("header", {
|
|
54184
|
-
className: `${SECTION_HEADER} flex min-h-9 flex-wrap items-center gap-2`,
|
|
54185
|
-
children: [
|
|
54186
|
-
Icon !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon, {
|
|
54187
|
-
className: "h-3.5 w-3.5 shrink-0 text-foreground-subtle",
|
|
54188
|
-
"aria-hidden": true
|
|
54189
|
-
}),
|
|
54190
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", {
|
|
54191
|
-
id: headingId,
|
|
54192
|
-
className: `${TEXT_SECTION_LABEL} min-w-0 flex-1`,
|
|
54193
|
-
children: title
|
|
54194
|
-
}),
|
|
54195
|
-
actions
|
|
54196
|
-
]
|
|
54197
|
-
}), children]
|
|
54198
|
-
});
|
|
54199
|
-
}
|
|
54200
|
-
//#endregion
|
|
54201
56103
|
//#region src/composites/filter-bar.tsx
|
|
54202
56104
|
function FilterBar({ filters, values, onChange, className }) {
|
|
54203
56105
|
const isMobile = useIsMobile();
|
|
@@ -54710,26 +56612,6 @@ function KebabMenu({ items, header, triggerClassName, title = "More actions" })
|
|
|
54710
56612
|
});
|
|
54711
56613
|
}
|
|
54712
56614
|
//#endregion
|
|
54713
|
-
//#region src/composites/key-value-list.tsx
|
|
54714
|
-
/**
|
|
54715
|
-
* A `<dl>` of label/value rows. Layout (including the L1 stacking) is owned by
|
|
54716
|
-
* `SettingRow`; this composite only supplies the description-list semantics.
|
|
54717
|
-
*
|
|
54718
|
-
* The rows used to be `flex items-center h-7` with a `w-1/3` term, so a label
|
|
54719
|
-
* could neither wrap nor stack: on a phone it took a third of the width and
|
|
54720
|
-
* the value took whatever was left.
|
|
54721
|
-
*/
|
|
54722
|
-
function KeyValueList({ items, className }) {
|
|
54723
|
-
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("dl", {
|
|
54724
|
-
className: cn("flex flex-col", className),
|
|
54725
|
-
children: items.map((item) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SettingRow, {
|
|
54726
|
-
elements: "description",
|
|
54727
|
-
label: item.key,
|
|
54728
|
-
children: item.value
|
|
54729
|
-
}, item.key))
|
|
54730
|
-
});
|
|
54731
|
-
}
|
|
54732
|
-
//#endregion
|
|
54733
56615
|
//#region src/composites/mount-addon-page.tsx
|
|
54734
56616
|
/**
|
|
54735
56617
|
* mountAddonPage — one-liner to mount an addon page in dev mode.
|
|
@@ -58879,323 +60761,6 @@ function useStationaryOverlay(trpc, deviceId) {
|
|
|
58879
60761
|
return query.isError ? void 0 : query.data;
|
|
58880
60762
|
}
|
|
58881
60763
|
//#endregion
|
|
58882
|
-
//#region src/hooks/turn-server-cache.ts
|
|
58883
|
-
/** Short enough that rotated credentials are picked up promptly. */
|
|
58884
|
-
var DEFAULT_FRESH_TTL_MS = 5 * 6e4;
|
|
58885
|
-
/** Half of the shortest provider credential lifetime in the fleet (24 h). */
|
|
58886
|
-
var DEFAULT_STALE_CAP_MS = 720 * 6e4;
|
|
58887
|
-
var DEFAULT_STORAGE_KEY = "camstack:turn-servers:v1";
|
|
58888
|
-
function defaultStorage() {
|
|
58889
|
-
const g = globalThis;
|
|
58890
|
-
try {
|
|
58891
|
-
return g.localStorage ?? null;
|
|
58892
|
-
} catch {
|
|
58893
|
-
return null;
|
|
58894
|
-
}
|
|
58895
|
-
}
|
|
58896
|
-
/** Type guard for a persisted entry — storage content is external data. */
|
|
58897
|
-
function isCacheEntry(value) {
|
|
58898
|
-
if (typeof value !== "object" || value === null) return false;
|
|
58899
|
-
const v = value;
|
|
58900
|
-
if (typeof v.fetchedAt !== "number" || !Array.isArray(v.servers)) return false;
|
|
58901
|
-
return v.servers.every((s) => {
|
|
58902
|
-
if (typeof s !== "object" || s === null) return false;
|
|
58903
|
-
const srv = s;
|
|
58904
|
-
if (!(typeof srv.urls === "string" || Array.isArray(srv.urls) && srv.urls.every((u) => typeof u === "string"))) return false;
|
|
58905
|
-
if (srv.username !== void 0 && typeof srv.username !== "string") return false;
|
|
58906
|
-
if (srv.credential !== void 0 && typeof srv.credential !== "string") return false;
|
|
58907
|
-
return true;
|
|
58908
|
-
});
|
|
58909
|
-
}
|
|
58910
|
-
var TurnServerCache = class {
|
|
58911
|
-
freshTtlMs;
|
|
58912
|
-
staleCapMs;
|
|
58913
|
-
storage;
|
|
58914
|
-
now;
|
|
58915
|
-
storageKey;
|
|
58916
|
-
/** Keyed on the caller's tRPC client object (stable per connected system). */
|
|
58917
|
-
memory = /* @__PURE__ */ new WeakMap();
|
|
58918
|
-
/** In-flight fetch per key — concurrent callers share one round trip. */
|
|
58919
|
-
inflight = /* @__PURE__ */ new WeakMap();
|
|
58920
|
-
constructor(options = {}) {
|
|
58921
|
-
this.freshTtlMs = options.freshTtlMs ?? DEFAULT_FRESH_TTL_MS;
|
|
58922
|
-
this.staleCapMs = options.staleCapMs ?? DEFAULT_STALE_CAP_MS;
|
|
58923
|
-
this.storage = "storage" in options ? options.storage ?? null : defaultStorage();
|
|
58924
|
-
this.now = options.now ?? (() => Date.now());
|
|
58925
|
-
this.storageKey = options.storageKey ?? DEFAULT_STORAGE_KEY;
|
|
58926
|
-
}
|
|
58927
|
-
/**
|
|
58928
|
-
* Resolve the ICE servers for `key`, fetching via `fetch` only when no
|
|
58929
|
-
* fresh-enough entry exists. Never rejects: a failed fetch resolves to the
|
|
58930
|
-
* stale entry when one exists, else `undefined`.
|
|
58931
|
-
*/
|
|
58932
|
-
async getOrFetch(key, fetch) {
|
|
58933
|
-
const at = this.now();
|
|
58934
|
-
const entry = this.memory.get(key) ?? this.readStorage();
|
|
58935
|
-
if (entry) {
|
|
58936
|
-
const age = at - entry.fetchedAt;
|
|
58937
|
-
if (age < this.freshTtlMs) return entry.servers;
|
|
58938
|
-
if (age < this.staleCapMs) {
|
|
58939
|
-
this.startFetch(key, fetch, entry);
|
|
58940
|
-
return entry.servers;
|
|
58941
|
-
}
|
|
58942
|
-
}
|
|
58943
|
-
return this.startFetch(key, fetch, entry);
|
|
58944
|
-
}
|
|
58945
|
-
startFetch(key, fetch, stale) {
|
|
58946
|
-
const pending = this.inflight.get(key);
|
|
58947
|
-
if (pending) return pending;
|
|
58948
|
-
const run = (async () => {
|
|
58949
|
-
try {
|
|
58950
|
-
const servers = await fetch();
|
|
58951
|
-
if (servers.length > 0) {
|
|
58952
|
-
const entry = {
|
|
58953
|
-
servers,
|
|
58954
|
-
fetchedAt: this.now()
|
|
58955
|
-
};
|
|
58956
|
-
this.memory.set(key, entry);
|
|
58957
|
-
this.writeStorage(entry);
|
|
58958
|
-
}
|
|
58959
|
-
return servers;
|
|
58960
|
-
} catch {
|
|
58961
|
-
return stale?.servers;
|
|
58962
|
-
}
|
|
58963
|
-
})().finally(() => {
|
|
58964
|
-
this.inflight.delete(key);
|
|
58965
|
-
});
|
|
58966
|
-
this.inflight.set(key, run);
|
|
58967
|
-
return run;
|
|
58968
|
-
}
|
|
58969
|
-
readStorage() {
|
|
58970
|
-
if (!this.storage) return void 0;
|
|
58971
|
-
try {
|
|
58972
|
-
const raw = this.storage.getItem(this.storageKey);
|
|
58973
|
-
if (raw === null) return void 0;
|
|
58974
|
-
const parsed = JSON.parse(raw);
|
|
58975
|
-
return isCacheEntry(parsed) ? parsed : void 0;
|
|
58976
|
-
} catch {
|
|
58977
|
-
return;
|
|
58978
|
-
}
|
|
58979
|
-
}
|
|
58980
|
-
writeStorage(entry) {
|
|
58981
|
-
if (!this.storage) return;
|
|
58982
|
-
try {
|
|
58983
|
-
this.storage.setItem(this.storageKey, JSON.stringify(entry));
|
|
58984
|
-
} catch {}
|
|
58985
|
-
}
|
|
58986
|
-
};
|
|
58987
|
-
//#endregion
|
|
58988
|
-
//#region src/hooks/use-device-webrtc.ts
|
|
58989
|
-
/**
|
|
58990
|
-
* useDeviceWebrtc — WebRTC signaling hook for device-scoped streaming.
|
|
58991
|
-
*
|
|
58992
|
-
* Provides `createSession`, `sendAnswer`, `closeSession` callbacks and
|
|
58993
|
-
* a `streams` list (Adaptive + High/Mid/Low) for a given device.
|
|
58994
|
-
*
|
|
58995
|
-
* Works in both admin-ui (BackendClient.trpc) and addon pages
|
|
58996
|
-
* (AddonPageProps.trpc) — parametrized by a minimal tRPC proxy shape.
|
|
58997
|
-
*/
|
|
58998
|
-
/**
|
|
58999
|
-
* Hook that provides WebRTC signaling callbacks and stream choices for a device.
|
|
59000
|
-
*
|
|
59001
|
-
* @param trpc - tRPC proxy (admin-ui BackendClient.trpc or addon page trpc)
|
|
59002
|
-
* @param deviceId - numeric device ID (null = disabled)
|
|
59003
|
-
* @param pollIntervalMs - how often to refresh profile slots (default: 5000)
|
|
59004
|
-
*/
|
|
59005
|
-
/** Module-level TURN/STUN credential cache. Keyed on the caller's trpc client
|
|
59006
|
-
* object (stable per connected system — NOT on `trpc.turnProvider`, which is
|
|
59007
|
-
* a proxy minted fresh on every property access), so two admin tabs pointed
|
|
59008
|
-
* at different hubs never cross-serve credentials from the memory layer; the
|
|
59009
|
-
* storage layer is per-origin, and the page's origin IS the hub. Coalesces
|
|
59010
|
-
* concurrent fetches (the mount prefetch and the connect's own call used to
|
|
59011
|
-
* race into TWO round trips) and persists across page loads so a fresh embed
|
|
59012
|
-
* page — one per camera open on native — finds warm credentials instead of
|
|
59013
|
-
* re-paying the measured 0.2–3.6 s serialized fetch. See turn-server-cache.ts. */
|
|
59014
|
-
var turnServersCache = new TurnServerCache();
|
|
59015
|
-
function useDeviceWebrtc(trpc, deviceId, pollIntervalMs = 5e3) {
|
|
59016
|
-
const [remoteStreams, setRemoteStreams] = (0, react$1.useState)([]);
|
|
59017
|
-
(0, react$1.useEffect)(() => {
|
|
59018
|
-
if (deviceId === null) return;
|
|
59019
|
-
let cancelled = false;
|
|
59020
|
-
const fetch = () => {
|
|
59021
|
-
trpc.webrtcSession.listStreams.query({ deviceId }).then((rows) => {
|
|
59022
|
-
if (!cancelled) setRemoteStreams(rows);
|
|
59023
|
-
}).catch(() => {});
|
|
59024
|
-
};
|
|
59025
|
-
fetch();
|
|
59026
|
-
const iv = setInterval(fetch, pollIntervalMs);
|
|
59027
|
-
return () => {
|
|
59028
|
-
cancelled = true;
|
|
59029
|
-
clearInterval(iv);
|
|
59030
|
-
};
|
|
59031
|
-
}, [
|
|
59032
|
-
trpc,
|
|
59033
|
-
deviceId,
|
|
59034
|
-
pollIntervalMs
|
|
59035
|
-
]);
|
|
59036
|
-
const [pipelineMetrics, setPipelineMetrics] = (0, react$1.useState)(null);
|
|
59037
|
-
(0, react$1.useEffect)(() => {
|
|
59038
|
-
if (deviceId === null) return;
|
|
59039
|
-
let cancelled = false;
|
|
59040
|
-
if (trpc.pipelineOrchestrator) trpc.pipelineOrchestrator.getCameraMetrics.query({ deviceId }).then((m) => {
|
|
59041
|
-
if (cancelled || !m) return;
|
|
59042
|
-
setPipelineMetrics({
|
|
59043
|
-
phase: m.phase,
|
|
59044
|
-
detectionFps: m.actualFps,
|
|
59045
|
-
avgInferenceMs: m.avgInferenceTimeMs,
|
|
59046
|
-
droppedFrames: m.droppedFrames
|
|
59047
|
-
});
|
|
59048
|
-
}).catch(() => {});
|
|
59049
|
-
return () => {
|
|
59050
|
-
cancelled = true;
|
|
59051
|
-
};
|
|
59052
|
-
}, [trpc, deviceId]);
|
|
59053
|
-
useLiveEvent("pipeline.camera-metrics-snapshot", (evt) => {
|
|
59054
|
-
if (deviceId === null) return;
|
|
59055
|
-
const payload = evt.data;
|
|
59056
|
-
if (payload.deviceId !== deviceId || !payload.metrics) return;
|
|
59057
|
-
setPipelineMetrics({
|
|
59058
|
-
phase: payload.metrics.phase,
|
|
59059
|
-
detectionFps: payload.metrics.actualFps,
|
|
59060
|
-
avgInferenceMs: payload.metrics.avgInferenceTimeMs,
|
|
59061
|
-
droppedFrames: payload.metrics.droppedFrames
|
|
59062
|
-
});
|
|
59063
|
-
});
|
|
59064
|
-
const { streams, hasWebrtc } = (0, react$1.useMemo)(() => {
|
|
59065
|
-
if (deviceId === null || remoteStreams.length === 0) return {
|
|
59066
|
-
streams: [],
|
|
59067
|
-
hasWebrtc: false
|
|
59068
|
-
};
|
|
59069
|
-
return {
|
|
59070
|
-
streams: remoteStreams.map((r) => ({
|
|
59071
|
-
id: r.id,
|
|
59072
|
-
label: r.label,
|
|
59073
|
-
target: r.target,
|
|
59074
|
-
status: r.status,
|
|
59075
|
-
inputFps: r.inputFps,
|
|
59076
|
-
decodeFps: r.decodeFps,
|
|
59077
|
-
bitrateKbps: r.bitrateKbps
|
|
59078
|
-
})),
|
|
59079
|
-
hasWebrtc: true
|
|
59080
|
-
};
|
|
59081
|
-
}, [deviceId, remoteStreams]);
|
|
59082
|
-
const getIceServers = (0, react$1.useCallback)(async () => {
|
|
59083
|
-
const provider = trpc.turnProvider;
|
|
59084
|
-
if (!provider) return void 0;
|
|
59085
|
-
return turnServersCache.getOrFetch(trpc, async () => {
|
|
59086
|
-
return (await provider.getTurnServers.query()).map((s) => {
|
|
59087
|
-
return {
|
|
59088
|
-
urls: typeof s.urls === "string" ? s.urls : [...s.urls],
|
|
59089
|
-
...s.username !== void 0 ? { username: s.username } : {},
|
|
59090
|
-
...s.credential !== void 0 ? { credential: s.credential } : {}
|
|
59091
|
-
};
|
|
59092
|
-
});
|
|
59093
|
-
});
|
|
59094
|
-
}, [trpc]);
|
|
59095
|
-
(0, react$1.useEffect)(() => {
|
|
59096
|
-
getIceServers();
|
|
59097
|
-
}, [getIceServers]);
|
|
59098
|
-
const createSession = (0, react$1.useCallback)(async (target, hints) => {
|
|
59099
|
-
if (deviceId === null) throw new Error("No device selected");
|
|
59100
|
-
const res = await trpc.webrtcSession.createSession.mutate({
|
|
59101
|
-
deviceId,
|
|
59102
|
-
target,
|
|
59103
|
-
hints
|
|
59104
|
-
});
|
|
59105
|
-
const iceServers = await getIceServers();
|
|
59106
|
-
return {
|
|
59107
|
-
sessionId: res.sessionId,
|
|
59108
|
-
sdpOffer: res.sdpOffer,
|
|
59109
|
-
...iceServers ? { iceServers } : {}
|
|
59110
|
-
};
|
|
59111
|
-
}, [
|
|
59112
|
-
trpc,
|
|
59113
|
-
deviceId,
|
|
59114
|
-
getIceServers
|
|
59115
|
-
]);
|
|
59116
|
-
const handleOffer = (0, react$1.useCallback)(async (target, sdpOffer, sessionId, hints) => {
|
|
59117
|
-
if (deviceId === null) throw new Error("No device selected");
|
|
59118
|
-
return trpc.webrtcSession.handleOffer.mutate({
|
|
59119
|
-
deviceId,
|
|
59120
|
-
target,
|
|
59121
|
-
sdpOffer,
|
|
59122
|
-
...sessionId !== void 0 ? { sessionId } : {},
|
|
59123
|
-
...hints !== void 0 ? { hints } : {}
|
|
59124
|
-
});
|
|
59125
|
-
}, [trpc, deviceId]);
|
|
59126
|
-
const sendAnswer = (0, react$1.useCallback)(async (sessionId, sdpAnswer) => {
|
|
59127
|
-
if (deviceId === null) throw new Error("No device selected");
|
|
59128
|
-
await trpc.webrtcSession.handleAnswer.mutate({
|
|
59129
|
-
deviceId,
|
|
59130
|
-
sessionId,
|
|
59131
|
-
sdpAnswer
|
|
59132
|
-
});
|
|
59133
|
-
}, [trpc, deviceId]);
|
|
59134
|
-
return {
|
|
59135
|
-
streams,
|
|
59136
|
-
hasWebrtc,
|
|
59137
|
-
pipelineMetrics,
|
|
59138
|
-
createSession,
|
|
59139
|
-
handleOffer,
|
|
59140
|
-
getIceServers,
|
|
59141
|
-
addIceCandidate: (0, react$1.useCallback)(async (sessionId, candidate) => {
|
|
59142
|
-
if (deviceId === null) return;
|
|
59143
|
-
await trpc.webrtcSession.addIceCandidate.mutate({
|
|
59144
|
-
deviceId,
|
|
59145
|
-
sessionId,
|
|
59146
|
-
candidate: candidate.candidate ?? "",
|
|
59147
|
-
sdpMid: candidate.sdpMid ?? null,
|
|
59148
|
-
sdpMLineIndex: candidate.sdpMLineIndex ?? null
|
|
59149
|
-
}).catch(() => {});
|
|
59150
|
-
}, [trpc, deviceId]),
|
|
59151
|
-
getIceCandidates: (0, react$1.useCallback)(async (sessionId) => {
|
|
59152
|
-
if (deviceId === null) return {
|
|
59153
|
-
candidates: [],
|
|
59154
|
-
done: true
|
|
59155
|
-
};
|
|
59156
|
-
return trpc.webrtcSession.getIceCandidates.query({
|
|
59157
|
-
deviceId,
|
|
59158
|
-
sessionId
|
|
59159
|
-
});
|
|
59160
|
-
}, [trpc, deviceId]),
|
|
59161
|
-
sendAnswer,
|
|
59162
|
-
closeSession: (0, react$1.useCallback)(async (sessionId) => {
|
|
59163
|
-
if (deviceId === null) return;
|
|
59164
|
-
await trpc.webrtcSession.closeSession.mutate({
|
|
59165
|
-
deviceId,
|
|
59166
|
-
sessionId
|
|
59167
|
-
}).catch(() => {});
|
|
59168
|
-
}, [trpc, deviceId]),
|
|
59169
|
-
getSessionState: (0, react$1.useCallback)(async (sessionId) => {
|
|
59170
|
-
if (deviceId === null) return { pendingRenegotiation: null };
|
|
59171
|
-
try {
|
|
59172
|
-
return await trpc.webrtcSession.getSessionState.query({
|
|
59173
|
-
deviceId,
|
|
59174
|
-
sessionId
|
|
59175
|
-
});
|
|
59176
|
-
} catch {
|
|
59177
|
-
return { pendingRenegotiation: null };
|
|
59178
|
-
}
|
|
59179
|
-
}, [trpc, deviceId]),
|
|
59180
|
-
getSessionDebug: (0, react$1.useCallback)(async (sessionId) => {
|
|
59181
|
-
if (deviceId === null) throw new Error("getSessionDebug: no device bound to this player");
|
|
59182
|
-
return trpc.webrtcSession.getSessionDebug.query({
|
|
59183
|
-
deviceId,
|
|
59184
|
-
sessionId
|
|
59185
|
-
});
|
|
59186
|
-
}, [trpc, deviceId]),
|
|
59187
|
-
applyDownlinkProbe: (0, react$1.useCallback)(async (sessionId, downlinkMbps) => {
|
|
59188
|
-
const mutate = trpc.webrtcSession.applyDownlinkProbe;
|
|
59189
|
-
if (deviceId === null || mutate === void 0) throw new Error("applyDownlinkProbe: not available for this player");
|
|
59190
|
-
return mutate.mutate({
|
|
59191
|
-
deviceId,
|
|
59192
|
-
sessionId,
|
|
59193
|
-
downlinkMbps
|
|
59194
|
-
});
|
|
59195
|
-
}, [trpc, deviceId])
|
|
59196
|
-
};
|
|
59197
|
-
}
|
|
59198
|
-
//#endregion
|
|
59199
60764
|
//#region src/hooks/use-doorbell-events.ts
|
|
59200
60765
|
/**
|
|
59201
60766
|
* useDoorbellEvents — subscribe to doorbell.onPressed across the cluster.
|
|
@@ -59584,6 +61149,8 @@ exports.DummyInline = DummyInline;
|
|
|
59584
61149
|
exports.EMPTY_DATA_TABLE_FILTER = EMPTY_DATA_TABLE_FILTER;
|
|
59585
61150
|
exports.EVENT_KIND_ICONS = EVENT_KIND_ICONS;
|
|
59586
61151
|
exports.EmptyState = EmptyState;
|
|
61152
|
+
exports.EndpointResultsCard = EndpointResultsCard;
|
|
61153
|
+
exports.EndpointTestCard = EndpointTestCard;
|
|
59587
61154
|
exports.ErrorBox = ErrorBox;
|
|
59588
61155
|
exports.EventKindGlyph = EventKindGlyph;
|
|
59589
61156
|
exports.EventStream = EventStream;
|
|
@@ -59608,8 +61175,10 @@ exports.Grid2x2 = Grid2x2;
|
|
|
59608
61175
|
exports.GripTrack = GripTrack;
|
|
59609
61176
|
exports.GroupedModelSelector = GroupedModelSelector;
|
|
59610
61177
|
exports.HOST_WIDGETS = HOST_WIDGETS;
|
|
61178
|
+
exports.HUB_SESSION_RUN_MS = HUB_SESSION_RUN_MS;
|
|
59611
61179
|
exports.HlsVideo = HlsVideo;
|
|
59612
61180
|
exports.HoverZoomImage = HoverZoomImage;
|
|
61181
|
+
exports.HubSessionTestCard = HubSessionTestCard;
|
|
59613
61182
|
exports.HumidifierHeroCard = HumidifierHeroCard;
|
|
59614
61183
|
exports.HumidifierInlineControl = HumidifierInlineControl;
|
|
59615
61184
|
exports.INPUT_COMPACT = INPUT_COMPACT;
|
|
@@ -59647,6 +61216,7 @@ exports.MobileDrawer = MobileDrawer;
|
|
|
59647
61216
|
exports.ModelPicker = ModelPicker;
|
|
59648
61217
|
exports.MotionZonesSettings = MotionZonesSettings;
|
|
59649
61218
|
exports.NAVIGATION_DRIVE_INTERVAL_MS = NAVIGATION_DRIVE_INTERVAL_MS;
|
|
61219
|
+
exports.NatResultCard = NatResultCard;
|
|
59650
61220
|
exports.NavigationPanel = NavigationPanel;
|
|
59651
61221
|
exports.NetworkLinkBadge = NetworkLinkBadge;
|
|
59652
61222
|
exports.NodeMultiSelectField = NodeMultiSelectField;
|
|
@@ -59699,6 +61269,7 @@ exports.ResponseLog = ResponseLog;
|
|
|
59699
61269
|
exports.RoleDisplayDefaultsFromServer = RoleDisplayDefaultsFromServer;
|
|
59700
61270
|
exports.RoleDisplayDefaultsProvider = RoleDisplayDefaultsProvider;
|
|
59701
61271
|
exports.RotateCcw = RotateCcw;
|
|
61272
|
+
exports.RunReport = RunReport;
|
|
59702
61273
|
exports.SCENE_CAPTURE_VARIANTS = SCENE_CAPTURE_VARIANTS;
|
|
59703
61274
|
exports.SCENE_CAPTURE_VARIANTS$1 = SCENE_CAPTURE_VARIANTS;
|
|
59704
61275
|
exports.SECTION_BODY = SECTION_BODY;
|
|
@@ -59794,15 +61365,19 @@ exports.applyPickerFilters = applyPickerFilters;
|
|
|
59794
61365
|
exports.ariaSortForColumn = ariaSortForColumn;
|
|
59795
61366
|
exports.badgeFor = badgeFor;
|
|
59796
61367
|
exports.badgeFor$1 = badgeFor;
|
|
61368
|
+
exports.buildPeerConnectionConfig = buildPeerConnectionConfig;
|
|
59797
61369
|
exports.buildStepTreeFromSchema = buildStepTreeFromSchema;
|
|
59798
61370
|
exports.catalogHasDraft = catalogHasDraft;
|
|
59799
61371
|
exports.childEntityId = childEntityId;
|
|
59800
61372
|
exports.childListName = childListName;
|
|
59801
61373
|
exports.chipOverflow = chipOverflow;
|
|
61374
|
+
exports.classifyAddressScope = classifyAddressScope;
|
|
59802
61375
|
exports.classifyCapability = classifyCapability;
|
|
61376
|
+
exports.classifySessionVerdict = classifySessionVerdict;
|
|
59803
61377
|
exports.clearPickerGroup = clearPickerGroup;
|
|
59804
61378
|
exports.cn = cn;
|
|
59805
61379
|
exports.columnsForContext = columnsForContext;
|
|
61380
|
+
exports.compareSrflxMappings = compareSrflxMappings;
|
|
59806
61381
|
exports.containerChildToRef = containerChildToRef;
|
|
59807
61382
|
exports.countableDevices = countableDevices;
|
|
59808
61383
|
exports.coverHighlight = coverHighlight;
|
|
@@ -59830,12 +61405,14 @@ exports.deviceTypeMetaOf = deviceTypeMetaOf;
|
|
|
59830
61405
|
exports.devicesToOptions = devicesToOptions;
|
|
59831
61406
|
exports.draftFromQuery = draftFromQuery;
|
|
59832
61407
|
exports.emptyDevicesWidens = emptyDevicesWidens;
|
|
61408
|
+
exports.emptySessionRun = emptySessionRun;
|
|
59833
61409
|
exports.ensureMfHostInit = ensureMfHostInit;
|
|
59834
61410
|
exports.eventKindLabel = eventKindLabel;
|
|
59835
61411
|
exports.filterDataTableRows = filterDataTableRows;
|
|
59836
61412
|
exports.filterDeviceOptions = filterDeviceOptions;
|
|
59837
61413
|
exports.filterOptionSelectOptions = filterOptions;
|
|
59838
61414
|
exports.filterPickerDevices = filterPickerDevices;
|
|
61415
|
+
exports.finalizeSessionRun = finalizeSessionRun;
|
|
59839
61416
|
exports.findTimezone = _camstack_types.findTimezone;
|
|
59840
61417
|
exports.flattenPickerGrouping = flattenPickerGrouping;
|
|
59841
61418
|
exports.flattenPickerRows = flattenPickerRows;
|
|
@@ -59887,6 +61464,7 @@ exports.orphanDeviceLabel = orphanDeviceLabel;
|
|
|
59887
61464
|
exports.overrideEntityIdFromLink = overrideEntityIdFromLink;
|
|
59888
61465
|
exports.pageRows = pageRows;
|
|
59889
61466
|
exports.parentDetailOf = parentDetailOf;
|
|
61467
|
+
exports.parseIceCandidate = parseIceCandidate;
|
|
59890
61468
|
exports.parsePickerGroupBy = parsePickerGroupBy;
|
|
59891
61469
|
exports.parseRecordedServerMessage = parseRecordedServerMessage;
|
|
59892
61470
|
exports.partitionOptionSelectSelected = partitionSelected;
|
|
@@ -59902,8 +61480,10 @@ exports.pickerQueryTokens = pickerQueryTokens;
|
|
|
59902
61480
|
exports.pickerRowFacts = pickerRowFacts;
|
|
59903
61481
|
exports.pickerSearchText = pickerSearchText;
|
|
59904
61482
|
exports.pickerZoneFilterSuspended = pickerZoneFilterSuspended;
|
|
61483
|
+
exports.pinnedStreamChoices = pinnedStreamChoices;
|
|
59905
61484
|
exports.providerIcons = providerIcons;
|
|
59906
61485
|
exports.readStoredGroupBy = readStoredGroupBy;
|
|
61486
|
+
exports.reduceSessionRun = reduceSessionRun;
|
|
59907
61487
|
exports.resetHubMountForTests = resetHubMountForTests;
|
|
59908
61488
|
exports.resetPickerFilters = resetPickerFilters;
|
|
59909
61489
|
exports.resolveContainerPrimary = resolveContainerPrimary;
|
|
@@ -59944,6 +61524,7 @@ exports.statusLine$1 = statusLine;
|
|
|
59944
61524
|
exports.stepHasModelForFormat = stepHasModelForFormat;
|
|
59945
61525
|
exports.stepModelOptions = stepModelOptions;
|
|
59946
61526
|
exports.stripParentNamePrefix = stripParentNamePrefix;
|
|
61527
|
+
exports.summarizeHubCandidates = summarizeHubCandidates;
|
|
59947
61528
|
exports.tabForSelector = tabForSelector;
|
|
59948
61529
|
exports.tankAlert = tankAlert;
|
|
59949
61530
|
exports.themeToCss = require_theme_index.themeToCss$1;
|
|
@@ -60770,6 +62351,7 @@ exports.usePipelineOrchestratorSetAgentReachableHost = usePipelineOrchestratorSe
|
|
|
60770
62351
|
exports.usePipelineOrchestratorSetCameraPipelineForAgent = usePipelineOrchestratorSetCameraPipelineForAgent;
|
|
60771
62352
|
exports.usePipelineOrchestratorSetCameraSensors = usePipelineOrchestratorSetCameraSensors;
|
|
60772
62353
|
exports.usePipelineOrchestratorSetCameraStepOverride = usePipelineOrchestratorSetCameraStepOverride;
|
|
62354
|
+
exports.usePipelineOrchestratorSetCameraStepSettings = usePipelineOrchestratorSetCameraStepSettings;
|
|
60773
62355
|
exports.usePipelineOrchestratorSetCameraStepToggle = usePipelineOrchestratorSetCameraStepToggle;
|
|
60774
62356
|
exports.usePipelineOrchestratorSetCameraSwitch = usePipelineOrchestratorSetCameraSwitch;
|
|
60775
62357
|
exports.usePipelineOrchestratorSetCapabilityBinding = usePipelineOrchestratorSetCapabilityBinding;
|