@camstack/ui-library 1.2.111 → 1.2.113
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/device-export-panel.d.ts +8 -7
- package/dist/index.cjs +257 -42
- package/dist/index.js +257 -42
- package/package.json +1 -1
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { ReactElement } from 'react';
|
|
2
|
-
import { UseDeviceProxyTrpc } from '../hooks/use-device-proxy';
|
|
3
2
|
export interface DeviceExportPanelProps {
|
|
4
3
|
/**
|
|
5
4
|
* Addon id of the export addon whose surface is rendered. Used for
|
|
@@ -8,16 +7,18 @@ export interface DeviceExportPanelProps {
|
|
|
8
7
|
*/
|
|
9
8
|
readonly addonId: string;
|
|
10
9
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* Optional callback to open a device-details page.
|
|
11
|
+
*
|
|
12
|
+
* There is no `trpc` prop. It existed to feed the device-mode list's per-row
|
|
13
|
+
* capability atoms (snapshot, switch, live control); this surface renders
|
|
14
|
+
* type + name + export and asks a device nothing, so the prop was removed
|
|
15
|
+
* rather than left as a parameter every caller still threads and nothing
|
|
16
|
+
* reads.
|
|
14
17
|
*/
|
|
15
|
-
readonly trpc: UseDeviceProxyTrpc;
|
|
16
|
-
/** Optional callback to open a device-details page. */
|
|
17
18
|
readonly onOpenDevice?: (deviceId: string) => void;
|
|
18
19
|
}
|
|
19
20
|
/**
|
|
20
21
|
* Generic device-export panel. Addon-agnostic — only the `device-export`
|
|
21
22
|
* cap drives it.
|
|
22
23
|
*/
|
|
23
|
-
export declare function DeviceExportPanel({ addonId,
|
|
24
|
+
export declare function DeviceExportPanel({ addonId, onOpenDevice }: DeviceExportPanelProps): ReactElement;
|
package/dist/index.cjs
CHANGED
|
@@ -43813,12 +43813,33 @@ function DeviceList(props) {
|
|
|
43813
43813
|
* - a link-state status badge + exposed-device count from `getStatus`,
|
|
43814
43814
|
* - an optional "Setup" section (pairing QR + copyable fields) from
|
|
43815
43815
|
* the cap's `setup` block,
|
|
43816
|
-
* - ONE unified devices overview — the shared `<DeviceList>`
|
|
43817
|
-
*
|
|
43818
|
-
*
|
|
43819
|
-
*
|
|
43820
|
-
*
|
|
43821
|
-
*
|
|
43816
|
+
* - ONE unified devices overview — the shared `<DeviceList>` in its
|
|
43817
|
+
* GENERIC mode (the same table `/integrations` renders), restricted
|
|
43818
|
+
* to this exporter's `listSupportedDeviceKinds`.
|
|
43819
|
+
*
|
|
43820
|
+
* ── What an entry shows, and what it deliberately does not ───────────
|
|
43821
|
+
*
|
|
43822
|
+
* Three columns plus a selection box: device TYPE, NAME, EXPORT. That is
|
|
43823
|
+
* the whole row.
|
|
43824
|
+
*
|
|
43825
|
+
* It used to be the full device-mode row — Hardware (manufacturer ·
|
|
43826
|
+
* model), Features, and the merged Preview cell carrying the per-type
|
|
43827
|
+
* live control, the battery/charging pills, the snapshot eye, the reboot
|
|
43828
|
+
* quick-action, the PTZ button and the reachability dot. Every one of
|
|
43829
|
+
* those is reachable on `/devices` and on the device's own page; none of
|
|
43830
|
+
* them existed only here, which is the ONLY reason they were removable.
|
|
43831
|
+
* The Expose toggle is the affordance this surface owns, so it stays —
|
|
43832
|
+
* and the row click still opens the device page.
|
|
43833
|
+
*
|
|
43834
|
+
* ── Multi-select, and why every run reports ──────────────────────────
|
|
43835
|
+
*
|
|
43836
|
+
* Selecting rows exposes (or unexposes) them in one go. The toolbar
|
|
43837
|
+
* always names the COUNT the buttons will act on, and every run — bulk
|
|
43838
|
+
* or single-row — ends in a summary that names the devices on both
|
|
43839
|
+
* sides. A generic "export failed" would leave the operator re-running
|
|
43840
|
+
* the whole set to discover which half landed; and the previous
|
|
43841
|
+
* single-row toggle used `mutate` with no `onError`, so a refused
|
|
43842
|
+
* accessory changed nothing on screen at all.
|
|
43822
43843
|
*
|
|
43823
43844
|
* This composite is the reusable replacement for the per-addon
|
|
43824
43845
|
* Module-Federation overview tables that each export addon used to
|
|
@@ -43933,31 +43954,84 @@ function SetupSection({ setup }) {
|
|
|
43933
43954
|
});
|
|
43934
43955
|
}
|
|
43935
43956
|
/**
|
|
43936
|
-
* Per-row Expose/Exposed toggle
|
|
43937
|
-
*
|
|
43938
|
-
*
|
|
43957
|
+
* Per-row Expose/Exposed toggle. Reflects whether the row's device is exposed
|
|
43958
|
+
* to THIS exporter and flips it through the SAME runner the bulk buttons use —
|
|
43959
|
+
* so a single-row refusal is reported by name instead of vanishing, which is
|
|
43960
|
+
* what the previous fire-and-forget `mutate` did.
|
|
43961
|
+
*
|
|
43962
|
+
* The accessible name carries the device name (`Expose Garden cam`): several
|
|
43963
|
+
* of these live in one table, and "Expose" alone names none of them.
|
|
43939
43964
|
*/
|
|
43940
|
-
function ExportRowToggle({ addonId,
|
|
43965
|
+
function ExportRowToggle({ addonId, device, exposed, busy, onToggle }) {
|
|
43941
43966
|
const handleClick = (e) => {
|
|
43942
43967
|
e.stopPropagation();
|
|
43943
43968
|
if (busy) return;
|
|
43944
|
-
|
|
43945
|
-
else onExpose(deviceId);
|
|
43969
|
+
onToggle(device, exposed ? "unexpose" : "expose");
|
|
43946
43970
|
};
|
|
43947
43971
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
|
|
43948
43972
|
size: "sm",
|
|
43949
43973
|
variant: exposed ? "ghost" : "primary",
|
|
43950
43974
|
disabled: busy,
|
|
43951
43975
|
onClick: handleClick,
|
|
43976
|
+
"aria-label": `${exposed ? "Unexpose" : "Expose"} ${device.name}`,
|
|
43952
43977
|
title: exposed ? `Stop exposing to "${addonId}"` : `Expose to "${addonId}"`,
|
|
43953
43978
|
children: exposed ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Unlink2, { className: "mr-1 h-3.5 w-3.5" }), "Exposed"] }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Share2, { className: "mr-1 h-3.5 w-3.5" }), "Expose"] })
|
|
43954
43979
|
});
|
|
43955
43980
|
}
|
|
43956
43981
|
/**
|
|
43982
|
+
* The result of the last run, named device by device.
|
|
43983
|
+
*
|
|
43984
|
+
* Rendered for a CLEAN run too. "It probably worked" is not something an
|
|
43985
|
+
* operator should have to infer from silence, and a summary that appears only
|
|
43986
|
+
* on failure trains them to read no news as good news right up until the one
|
|
43987
|
+
* time the render itself was what failed.
|
|
43988
|
+
*/
|
|
43989
|
+
function BulkSummary({ outcome, onDismiss }) {
|
|
43990
|
+
const verb = outcome.action === "expose" ? "Exposed" : "Unexposed";
|
|
43991
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
43992
|
+
"data-testid": "export-bulk-summary",
|
|
43993
|
+
className: `flex flex-col gap-1 px-4 py-2 border-b border-border text-[11px] ${outcome.failed.length === 0 ? "text-foreground-subtle" : "text-danger"}`,
|
|
43994
|
+
children: [
|
|
43995
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
43996
|
+
className: "flex items-center justify-between gap-2",
|
|
43997
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
43998
|
+
className: "font-medium",
|
|
43999
|
+
children: [
|
|
44000
|
+
verb,
|
|
44001
|
+
" ",
|
|
44002
|
+
outcome.succeeded.length,
|
|
44003
|
+
" of ",
|
|
44004
|
+
outcome.total,
|
|
44005
|
+
" selected device",
|
|
44006
|
+
outcome.total === 1 ? "" : "s",
|
|
44007
|
+
"."
|
|
44008
|
+
]
|
|
44009
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
|
|
44010
|
+
size: "sm",
|
|
44011
|
+
variant: "ghost",
|
|
44012
|
+
onClick: onDismiss,
|
|
44013
|
+
"aria-label": "Dismiss export result",
|
|
44014
|
+
children: "Dismiss"
|
|
44015
|
+
})]
|
|
44016
|
+
}),
|
|
44017
|
+
outcome.succeeded.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
44018
|
+
className: "text-foreground-subtle",
|
|
44019
|
+
children: ["Succeeded: ", outcome.succeeded.join(", ")]
|
|
44020
|
+
}),
|
|
44021
|
+
outcome.failed.map((failure) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
|
|
44022
|
+
"Failed: ",
|
|
44023
|
+
failure.label,
|
|
44024
|
+
" — ",
|
|
44025
|
+
failure.message
|
|
44026
|
+
] }, failure.label))
|
|
44027
|
+
]
|
|
44028
|
+
});
|
|
44029
|
+
}
|
|
44030
|
+
/**
|
|
43957
44031
|
* Generic device-export panel. Addon-agnostic — only the `device-export`
|
|
43958
44032
|
* cap drives it.
|
|
43959
44033
|
*/
|
|
43960
|
-
function DeviceExportPanel({ addonId,
|
|
44034
|
+
function DeviceExportPanel({ addonId, onOpenDevice }) {
|
|
43961
44035
|
const queryClient = (0, _tanstack_react_query.useQueryClient)();
|
|
43962
44036
|
const statusQuery = useDeviceExportGetStatus({
|
|
43963
44037
|
nodeId: "hub",
|
|
@@ -44019,38 +44093,171 @@ function DeviceExportPanel({ addonId, trpc, onOpenDevice }) {
|
|
|
44019
44093
|
const loading = statusQuery.isLoading || exposedQuery.isLoading || kindsQuery.isLoading || devicesQuery.isLoading;
|
|
44020
44094
|
const exposedCount = status?.exposedDeviceCount ?? exposedIds.size;
|
|
44021
44095
|
const busy = exposeMutation.isPending || unexposeMutation.isPending;
|
|
44022
|
-
const
|
|
44023
|
-
|
|
44024
|
-
|
|
44025
|
-
|
|
44026
|
-
|
|
44096
|
+
const [selectedIds, setSelectedIds] = (0, react$1.useState)(() => /* @__PURE__ */ new Set());
|
|
44097
|
+
const [outcome, setOutcome] = (0, react$1.useState)(null);
|
|
44098
|
+
const [running, setRunning] = (0, react$1.useState)(false);
|
|
44099
|
+
const toggleSelection = (0, react$1.useCallback)((deviceId) => {
|
|
44100
|
+
setSelectedIds((current) => {
|
|
44101
|
+
const next = new Set(current);
|
|
44102
|
+
if (next.has(deviceId)) next.delete(deviceId);
|
|
44103
|
+
else next.add(deviceId);
|
|
44104
|
+
return next;
|
|
44027
44105
|
});
|
|
44028
|
-
}, [
|
|
44029
|
-
const
|
|
44030
|
-
|
|
44106
|
+
}, []);
|
|
44107
|
+
const clearSelection = (0, react$1.useCallback)(() => setSelectedIds(/* @__PURE__ */ new Set()), []);
|
|
44108
|
+
const dismissOutcome = (0, react$1.useCallback)(() => setOutcome(null), []);
|
|
44109
|
+
/** Display label for a device id — its name, or the raw id if it left the fleet. */
|
|
44110
|
+
const labelFor = (0, react$1.useCallback)((deviceId) => candidateDevices.find((d) => String(d.id) === deviceId)?.name ?? `#${deviceId}`, [candidateDevices]);
|
|
44111
|
+
/**
|
|
44112
|
+
* The ONE path an expose/unexpose takes — a single row's toggle is a run of
|
|
44113
|
+
* one. Every device is dispatched, every result is recorded, and a failure is
|
|
44114
|
+
* both logged (with `deviceId`, so it can be asked about per-camera) and
|
|
44115
|
+
* named on screen. Nothing here can drop a device quietly: the loop visits
|
|
44116
|
+
* the whole input and each visit lands in `succeeded` or in `failed`.
|
|
44117
|
+
*/
|
|
44118
|
+
const runExport = (0, react$1.useCallback)(async (action, deviceIds) => {
|
|
44119
|
+
if (deviceIds.length === 0 || running) return;
|
|
44120
|
+
setRunning(true);
|
|
44121
|
+
setOutcome(null);
|
|
44122
|
+
const succeeded = [];
|
|
44123
|
+
const failed = [];
|
|
44124
|
+
const mutation = action === "expose" ? exposeMutation : unexposeMutation;
|
|
44125
|
+
(await Promise.allSettled(deviceIds.map((deviceId) => mutation.mutateAsync({
|
|
44031
44126
|
deviceId,
|
|
44032
44127
|
nodeId: "hub",
|
|
44033
44128
|
addonId
|
|
44129
|
+
})))).forEach((result, index) => {
|
|
44130
|
+
const deviceId = deviceIds[index] ?? "";
|
|
44131
|
+
const label = labelFor(deviceId);
|
|
44132
|
+
if (result.status === "fulfilled") {
|
|
44133
|
+
succeeded.push(label);
|
|
44134
|
+
return;
|
|
44135
|
+
}
|
|
44136
|
+
const message = result.reason instanceof Error ? result.reason.message : String(result.reason);
|
|
44137
|
+
console.error(`[device-export] ${action} failed`, {
|
|
44138
|
+
tags: { deviceId: Number.parseInt(deviceId, 10) },
|
|
44139
|
+
addonId,
|
|
44140
|
+
error: message
|
|
44141
|
+
});
|
|
44142
|
+
failed.push({
|
|
44143
|
+
label,
|
|
44144
|
+
message
|
|
44145
|
+
});
|
|
44034
44146
|
});
|
|
44035
|
-
|
|
44036
|
-
|
|
44037
|
-
|
|
44038
|
-
|
|
44039
|
-
|
|
44040
|
-
deviceId: id,
|
|
44041
|
-
exposed: exposedIds.has(id),
|
|
44042
|
-
busy,
|
|
44043
|
-
onExpose,
|
|
44044
|
-
onUnexpose
|
|
44147
|
+
setOutcome({
|
|
44148
|
+
action,
|
|
44149
|
+
total: deviceIds.length,
|
|
44150
|
+
succeeded,
|
|
44151
|
+
failed
|
|
44045
44152
|
});
|
|
44153
|
+
setSelectedIds(/* @__PURE__ */ new Set());
|
|
44154
|
+
setRunning(false);
|
|
44046
44155
|
}, [
|
|
44047
44156
|
addonId,
|
|
44048
|
-
|
|
44157
|
+
exposeMutation,
|
|
44158
|
+
unexposeMutation,
|
|
44159
|
+
labelFor,
|
|
44160
|
+
running
|
|
44161
|
+
]);
|
|
44162
|
+
const selectedList = (0, react$1.useMemo)(() => [...selectedIds], [selectedIds]);
|
|
44163
|
+
const exposeSelected = (0, react$1.useCallback)(() => {
|
|
44164
|
+
runExport("expose", selectedList);
|
|
44165
|
+
}, [runExport, selectedList]);
|
|
44166
|
+
const unexposeSelected = (0, react$1.useCallback)(() => {
|
|
44167
|
+
runExport("unexpose", selectedList);
|
|
44168
|
+
}, [runExport, selectedList]);
|
|
44169
|
+
const onRowToggle = (0, react$1.useCallback)((device, action) => {
|
|
44170
|
+
runExport(action, [String(device.id)]);
|
|
44171
|
+
}, [runExport]);
|
|
44172
|
+
const onRowClick = (0, react$1.useCallback)((device) => onOpenDevice?.(String(device.id)), [onOpenDevice]);
|
|
44173
|
+
const getKey = (0, react$1.useCallback)((device) => String(device.id), []);
|
|
44174
|
+
const getSearchText = (0, react$1.useCallback)((device) => `${device.name} ${device.stableId}`, []);
|
|
44175
|
+
const getFilterType = (0, react$1.useCallback)((device) => device.type, []);
|
|
44176
|
+
const columns = (0, react$1.useMemo)(() => [
|
|
44177
|
+
{
|
|
44178
|
+
key: "select",
|
|
44179
|
+
header: "",
|
|
44180
|
+
headerClassName: "w-8",
|
|
44181
|
+
cell: (device) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
44182
|
+
type: "checkbox",
|
|
44183
|
+
checked: selectedIds.has(String(device.id)),
|
|
44184
|
+
onChange: () => toggleSelection(String(device.id)),
|
|
44185
|
+
onClick: (e) => e.stopPropagation(),
|
|
44186
|
+
"aria-label": `Select ${device.name}`
|
|
44187
|
+
})
|
|
44188
|
+
},
|
|
44189
|
+
{
|
|
44190
|
+
key: "type",
|
|
44191
|
+
header: "Type",
|
|
44192
|
+
headerClassName: "w-32",
|
|
44193
|
+
cell: (device) => {
|
|
44194
|
+
const meta = deviceTypeMetaOf(device.type);
|
|
44195
|
+
const TypeIcon = meta.icon;
|
|
44196
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
44197
|
+
className: "inline-flex items-center gap-1.5 text-[11px] text-foreground-subtle",
|
|
44198
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(TypeIcon, { className: "h-3.5 w-3.5 shrink-0" }), meta.label]
|
|
44199
|
+
});
|
|
44200
|
+
}
|
|
44201
|
+
},
|
|
44202
|
+
{
|
|
44203
|
+
key: "name",
|
|
44204
|
+
header: "Name",
|
|
44205
|
+
cell: (device) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
44206
|
+
className: "text-xs font-medium text-foreground",
|
|
44207
|
+
children: device.name
|
|
44208
|
+
})
|
|
44209
|
+
},
|
|
44210
|
+
{
|
|
44211
|
+
key: "export",
|
|
44212
|
+
header: "Export",
|
|
44213
|
+
headerClassName: "w-32 text-right",
|
|
44214
|
+
cellClassName: "text-right",
|
|
44215
|
+
cell: (device) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ExportRowToggle, {
|
|
44216
|
+
addonId,
|
|
44217
|
+
device,
|
|
44218
|
+
exposed: exposedIds.has(String(device.id)),
|
|
44219
|
+
busy,
|
|
44220
|
+
onToggle: onRowToggle
|
|
44221
|
+
})
|
|
44222
|
+
}
|
|
44223
|
+
], [
|
|
44224
|
+
addonId,
|
|
44049
44225
|
busy,
|
|
44050
|
-
|
|
44051
|
-
|
|
44226
|
+
exposedIds,
|
|
44227
|
+
onRowToggle,
|
|
44228
|
+
selectedIds,
|
|
44229
|
+
toggleSelection
|
|
44052
44230
|
]);
|
|
44053
|
-
const
|
|
44231
|
+
const selectionToolbar = selectedIds.size === 0 ? void 0 : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
44232
|
+
className: "inline-flex items-center gap-1.5",
|
|
44233
|
+
children: [
|
|
44234
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
44235
|
+
"data-testid": "export-selection-count",
|
|
44236
|
+
className: "text-[11px] text-foreground-subtle",
|
|
44237
|
+
children: [selectedIds.size, " selected"]
|
|
44238
|
+
}),
|
|
44239
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Button, {
|
|
44240
|
+
size: "sm",
|
|
44241
|
+
variant: "primary",
|
|
44242
|
+
disabled: busy,
|
|
44243
|
+
onClick: exposeSelected,
|
|
44244
|
+
children: ["Expose ", selectedIds.size]
|
|
44245
|
+
}),
|
|
44246
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Button, {
|
|
44247
|
+
size: "sm",
|
|
44248
|
+
variant: "secondary",
|
|
44249
|
+
disabled: busy,
|
|
44250
|
+
onClick: unexposeSelected,
|
|
44251
|
+
children: ["Unexpose ", selectedIds.size]
|
|
44252
|
+
}),
|
|
44253
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
|
|
44254
|
+
size: "sm",
|
|
44255
|
+
variant: "ghost",
|
|
44256
|
+
onClick: clearSelection,
|
|
44257
|
+
children: "Clear"
|
|
44258
|
+
})
|
|
44259
|
+
]
|
|
44260
|
+
});
|
|
44054
44261
|
const hasSupportedKinds = supportedKindList.length > 0;
|
|
44055
44262
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
44056
44263
|
className: "rounded-lg border border-border bg-surface overflow-hidden",
|
|
@@ -44081,19 +44288,27 @@ function DeviceExportPanel({ addonId, trpc, onOpenDevice }) {
|
|
|
44081
44288
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ErrorBox, { message: status.error })
|
|
44082
44289
|
}),
|
|
44083
44290
|
status?.setup && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SetupSection, { setup: status.setup }),
|
|
44291
|
+
outcome !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BulkSummary, {
|
|
44292
|
+
outcome,
|
|
44293
|
+
onDismiss: dismissOutcome
|
|
44294
|
+
}),
|
|
44084
44295
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
44085
44296
|
className: "p-3",
|
|
44086
44297
|
children: loading && candidateDevices.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
44087
44298
|
className: "flex items-center gap-2 px-1 py-6 text-xs text-foreground-subtle",
|
|
44088
44299
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LoaderCircle, { className: "h-3.5 w-3.5 animate-spin" }), "Loading exportable devices…"]
|
|
44089
44300
|
}) : hasSupportedKinds && candidateDevices.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DeviceList, {
|
|
44090
|
-
|
|
44091
|
-
|
|
44092
|
-
|
|
44093
|
-
|
|
44094
|
-
|
|
44095
|
-
|
|
44096
|
-
|
|
44301
|
+
mode: "generic",
|
|
44302
|
+
items: candidateDevices,
|
|
44303
|
+
getKey,
|
|
44304
|
+
getSearchText,
|
|
44305
|
+
getFilterType,
|
|
44306
|
+
filterOptions: supportedTypeOptions,
|
|
44307
|
+
columns,
|
|
44308
|
+
onRowClick: onOpenDevice ? onRowClick : void 0,
|
|
44309
|
+
toolbar: selectionToolbar,
|
|
44310
|
+
searchPlaceholder: "Search by name or stableId",
|
|
44311
|
+
emptyLabel: "No device matches this search."
|
|
44097
44312
|
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(EmptyState, {
|
|
44098
44313
|
icon: Share2,
|
|
44099
44314
|
title: "No exportable devices",
|
package/dist/index.js
CHANGED
|
@@ -43789,12 +43789,33 @@ function DeviceList(props) {
|
|
|
43789
43789
|
* - a link-state status badge + exposed-device count from `getStatus`,
|
|
43790
43790
|
* - an optional "Setup" section (pairing QR + copyable fields) from
|
|
43791
43791
|
* the cap's `setup` block,
|
|
43792
|
-
* - ONE unified devices overview — the shared `<DeviceList>`
|
|
43793
|
-
*
|
|
43794
|
-
*
|
|
43795
|
-
*
|
|
43796
|
-
*
|
|
43797
|
-
*
|
|
43792
|
+
* - ONE unified devices overview — the shared `<DeviceList>` in its
|
|
43793
|
+
* GENERIC mode (the same table `/integrations` renders), restricted
|
|
43794
|
+
* to this exporter's `listSupportedDeviceKinds`.
|
|
43795
|
+
*
|
|
43796
|
+
* ── What an entry shows, and what it deliberately does not ───────────
|
|
43797
|
+
*
|
|
43798
|
+
* Three columns plus a selection box: device TYPE, NAME, EXPORT. That is
|
|
43799
|
+
* the whole row.
|
|
43800
|
+
*
|
|
43801
|
+
* It used to be the full device-mode row — Hardware (manufacturer ·
|
|
43802
|
+
* model), Features, and the merged Preview cell carrying the per-type
|
|
43803
|
+
* live control, the battery/charging pills, the snapshot eye, the reboot
|
|
43804
|
+
* quick-action, the PTZ button and the reachability dot. Every one of
|
|
43805
|
+
* those is reachable on `/devices` and on the device's own page; none of
|
|
43806
|
+
* them existed only here, which is the ONLY reason they were removable.
|
|
43807
|
+
* The Expose toggle is the affordance this surface owns, so it stays —
|
|
43808
|
+
* and the row click still opens the device page.
|
|
43809
|
+
*
|
|
43810
|
+
* ── Multi-select, and why every run reports ──────────────────────────
|
|
43811
|
+
*
|
|
43812
|
+
* Selecting rows exposes (or unexposes) them in one go. The toolbar
|
|
43813
|
+
* always names the COUNT the buttons will act on, and every run — bulk
|
|
43814
|
+
* or single-row — ends in a summary that names the devices on both
|
|
43815
|
+
* sides. A generic "export failed" would leave the operator re-running
|
|
43816
|
+
* the whole set to discover which half landed; and the previous
|
|
43817
|
+
* single-row toggle used `mutate` with no `onError`, so a refused
|
|
43818
|
+
* accessory changed nothing on screen at all.
|
|
43798
43819
|
*
|
|
43799
43820
|
* This composite is the reusable replacement for the per-addon
|
|
43800
43821
|
* Module-Federation overview tables that each export addon used to
|
|
@@ -43909,31 +43930,84 @@ function SetupSection({ setup }) {
|
|
|
43909
43930
|
});
|
|
43910
43931
|
}
|
|
43911
43932
|
/**
|
|
43912
|
-
* Per-row Expose/Exposed toggle
|
|
43913
|
-
*
|
|
43914
|
-
*
|
|
43933
|
+
* Per-row Expose/Exposed toggle. Reflects whether the row's device is exposed
|
|
43934
|
+
* to THIS exporter and flips it through the SAME runner the bulk buttons use —
|
|
43935
|
+
* so a single-row refusal is reported by name instead of vanishing, which is
|
|
43936
|
+
* what the previous fire-and-forget `mutate` did.
|
|
43937
|
+
*
|
|
43938
|
+
* The accessible name carries the device name (`Expose Garden cam`): several
|
|
43939
|
+
* of these live in one table, and "Expose" alone names none of them.
|
|
43915
43940
|
*/
|
|
43916
|
-
function ExportRowToggle({ addonId,
|
|
43941
|
+
function ExportRowToggle({ addonId, device, exposed, busy, onToggle }) {
|
|
43917
43942
|
const handleClick = (e) => {
|
|
43918
43943
|
e.stopPropagation();
|
|
43919
43944
|
if (busy) return;
|
|
43920
|
-
|
|
43921
|
-
else onExpose(deviceId);
|
|
43945
|
+
onToggle(device, exposed ? "unexpose" : "expose");
|
|
43922
43946
|
};
|
|
43923
43947
|
return /* @__PURE__ */ jsx(Button, {
|
|
43924
43948
|
size: "sm",
|
|
43925
43949
|
variant: exposed ? "ghost" : "primary",
|
|
43926
43950
|
disabled: busy,
|
|
43927
43951
|
onClick: handleClick,
|
|
43952
|
+
"aria-label": `${exposed ? "Unexpose" : "Expose"} ${device.name}`,
|
|
43928
43953
|
title: exposed ? `Stop exposing to "${addonId}"` : `Expose to "${addonId}"`,
|
|
43929
43954
|
children: exposed ? /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(Unlink2, { className: "mr-1 h-3.5 w-3.5" }), "Exposed"] }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(Share2, { className: "mr-1 h-3.5 w-3.5" }), "Expose"] })
|
|
43930
43955
|
});
|
|
43931
43956
|
}
|
|
43932
43957
|
/**
|
|
43958
|
+
* The result of the last run, named device by device.
|
|
43959
|
+
*
|
|
43960
|
+
* Rendered for a CLEAN run too. "It probably worked" is not something an
|
|
43961
|
+
* operator should have to infer from silence, and a summary that appears only
|
|
43962
|
+
* on failure trains them to read no news as good news right up until the one
|
|
43963
|
+
* time the render itself was what failed.
|
|
43964
|
+
*/
|
|
43965
|
+
function BulkSummary({ outcome, onDismiss }) {
|
|
43966
|
+
const verb = outcome.action === "expose" ? "Exposed" : "Unexposed";
|
|
43967
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
43968
|
+
"data-testid": "export-bulk-summary",
|
|
43969
|
+
className: `flex flex-col gap-1 px-4 py-2 border-b border-border text-[11px] ${outcome.failed.length === 0 ? "text-foreground-subtle" : "text-danger"}`,
|
|
43970
|
+
children: [
|
|
43971
|
+
/* @__PURE__ */ jsxs("div", {
|
|
43972
|
+
className: "flex items-center justify-between gap-2",
|
|
43973
|
+
children: [/* @__PURE__ */ jsxs("span", {
|
|
43974
|
+
className: "font-medium",
|
|
43975
|
+
children: [
|
|
43976
|
+
verb,
|
|
43977
|
+
" ",
|
|
43978
|
+
outcome.succeeded.length,
|
|
43979
|
+
" of ",
|
|
43980
|
+
outcome.total,
|
|
43981
|
+
" selected device",
|
|
43982
|
+
outcome.total === 1 ? "" : "s",
|
|
43983
|
+
"."
|
|
43984
|
+
]
|
|
43985
|
+
}), /* @__PURE__ */ jsx(Button, {
|
|
43986
|
+
size: "sm",
|
|
43987
|
+
variant: "ghost",
|
|
43988
|
+
onClick: onDismiss,
|
|
43989
|
+
"aria-label": "Dismiss export result",
|
|
43990
|
+
children: "Dismiss"
|
|
43991
|
+
})]
|
|
43992
|
+
}),
|
|
43993
|
+
outcome.succeeded.length > 0 && /* @__PURE__ */ jsxs("span", {
|
|
43994
|
+
className: "text-foreground-subtle",
|
|
43995
|
+
children: ["Succeeded: ", outcome.succeeded.join(", ")]
|
|
43996
|
+
}),
|
|
43997
|
+
outcome.failed.map((failure) => /* @__PURE__ */ jsxs("span", { children: [
|
|
43998
|
+
"Failed: ",
|
|
43999
|
+
failure.label,
|
|
44000
|
+
" — ",
|
|
44001
|
+
failure.message
|
|
44002
|
+
] }, failure.label))
|
|
44003
|
+
]
|
|
44004
|
+
});
|
|
44005
|
+
}
|
|
44006
|
+
/**
|
|
43933
44007
|
* Generic device-export panel. Addon-agnostic — only the `device-export`
|
|
43934
44008
|
* cap drives it.
|
|
43935
44009
|
*/
|
|
43936
|
-
function DeviceExportPanel({ addonId,
|
|
44010
|
+
function DeviceExportPanel({ addonId, onOpenDevice }) {
|
|
43937
44011
|
const queryClient = useQueryClient();
|
|
43938
44012
|
const statusQuery = useDeviceExportGetStatus({
|
|
43939
44013
|
nodeId: "hub",
|
|
@@ -43995,38 +44069,171 @@ function DeviceExportPanel({ addonId, trpc, onOpenDevice }) {
|
|
|
43995
44069
|
const loading = statusQuery.isLoading || exposedQuery.isLoading || kindsQuery.isLoading || devicesQuery.isLoading;
|
|
43996
44070
|
const exposedCount = status?.exposedDeviceCount ?? exposedIds.size;
|
|
43997
44071
|
const busy = exposeMutation.isPending || unexposeMutation.isPending;
|
|
43998
|
-
const
|
|
43999
|
-
|
|
44000
|
-
|
|
44001
|
-
|
|
44002
|
-
|
|
44072
|
+
const [selectedIds, setSelectedIds] = useState(() => /* @__PURE__ */ new Set());
|
|
44073
|
+
const [outcome, setOutcome] = useState(null);
|
|
44074
|
+
const [running, setRunning] = useState(false);
|
|
44075
|
+
const toggleSelection = useCallback((deviceId) => {
|
|
44076
|
+
setSelectedIds((current) => {
|
|
44077
|
+
const next = new Set(current);
|
|
44078
|
+
if (next.has(deviceId)) next.delete(deviceId);
|
|
44079
|
+
else next.add(deviceId);
|
|
44080
|
+
return next;
|
|
44003
44081
|
});
|
|
44004
|
-
}, [
|
|
44005
|
-
const
|
|
44006
|
-
|
|
44082
|
+
}, []);
|
|
44083
|
+
const clearSelection = useCallback(() => setSelectedIds(/* @__PURE__ */ new Set()), []);
|
|
44084
|
+
const dismissOutcome = useCallback(() => setOutcome(null), []);
|
|
44085
|
+
/** Display label for a device id — its name, or the raw id if it left the fleet. */
|
|
44086
|
+
const labelFor = useCallback((deviceId) => candidateDevices.find((d) => String(d.id) === deviceId)?.name ?? `#${deviceId}`, [candidateDevices]);
|
|
44087
|
+
/**
|
|
44088
|
+
* The ONE path an expose/unexpose takes — a single row's toggle is a run of
|
|
44089
|
+
* one. Every device is dispatched, every result is recorded, and a failure is
|
|
44090
|
+
* both logged (with `deviceId`, so it can be asked about per-camera) and
|
|
44091
|
+
* named on screen. Nothing here can drop a device quietly: the loop visits
|
|
44092
|
+
* the whole input and each visit lands in `succeeded` or in `failed`.
|
|
44093
|
+
*/
|
|
44094
|
+
const runExport = useCallback(async (action, deviceIds) => {
|
|
44095
|
+
if (deviceIds.length === 0 || running) return;
|
|
44096
|
+
setRunning(true);
|
|
44097
|
+
setOutcome(null);
|
|
44098
|
+
const succeeded = [];
|
|
44099
|
+
const failed = [];
|
|
44100
|
+
const mutation = action === "expose" ? exposeMutation : unexposeMutation;
|
|
44101
|
+
(await Promise.allSettled(deviceIds.map((deviceId) => mutation.mutateAsync({
|
|
44007
44102
|
deviceId,
|
|
44008
44103
|
nodeId: "hub",
|
|
44009
44104
|
addonId
|
|
44105
|
+
})))).forEach((result, index) => {
|
|
44106
|
+
const deviceId = deviceIds[index] ?? "";
|
|
44107
|
+
const label = labelFor(deviceId);
|
|
44108
|
+
if (result.status === "fulfilled") {
|
|
44109
|
+
succeeded.push(label);
|
|
44110
|
+
return;
|
|
44111
|
+
}
|
|
44112
|
+
const message = result.reason instanceof Error ? result.reason.message : String(result.reason);
|
|
44113
|
+
console.error(`[device-export] ${action} failed`, {
|
|
44114
|
+
tags: { deviceId: Number.parseInt(deviceId, 10) },
|
|
44115
|
+
addonId,
|
|
44116
|
+
error: message
|
|
44117
|
+
});
|
|
44118
|
+
failed.push({
|
|
44119
|
+
label,
|
|
44120
|
+
message
|
|
44121
|
+
});
|
|
44010
44122
|
});
|
|
44011
|
-
|
|
44012
|
-
|
|
44013
|
-
|
|
44014
|
-
|
|
44015
|
-
|
|
44016
|
-
deviceId: id,
|
|
44017
|
-
exposed: exposedIds.has(id),
|
|
44018
|
-
busy,
|
|
44019
|
-
onExpose,
|
|
44020
|
-
onUnexpose
|
|
44123
|
+
setOutcome({
|
|
44124
|
+
action,
|
|
44125
|
+
total: deviceIds.length,
|
|
44126
|
+
succeeded,
|
|
44127
|
+
failed
|
|
44021
44128
|
});
|
|
44129
|
+
setSelectedIds(/* @__PURE__ */ new Set());
|
|
44130
|
+
setRunning(false);
|
|
44022
44131
|
}, [
|
|
44023
44132
|
addonId,
|
|
44024
|
-
|
|
44133
|
+
exposeMutation,
|
|
44134
|
+
unexposeMutation,
|
|
44135
|
+
labelFor,
|
|
44136
|
+
running
|
|
44137
|
+
]);
|
|
44138
|
+
const selectedList = useMemo(() => [...selectedIds], [selectedIds]);
|
|
44139
|
+
const exposeSelected = useCallback(() => {
|
|
44140
|
+
runExport("expose", selectedList);
|
|
44141
|
+
}, [runExport, selectedList]);
|
|
44142
|
+
const unexposeSelected = useCallback(() => {
|
|
44143
|
+
runExport("unexpose", selectedList);
|
|
44144
|
+
}, [runExport, selectedList]);
|
|
44145
|
+
const onRowToggle = useCallback((device, action) => {
|
|
44146
|
+
runExport(action, [String(device.id)]);
|
|
44147
|
+
}, [runExport]);
|
|
44148
|
+
const onRowClick = useCallback((device) => onOpenDevice?.(String(device.id)), [onOpenDevice]);
|
|
44149
|
+
const getKey = useCallback((device) => String(device.id), []);
|
|
44150
|
+
const getSearchText = useCallback((device) => `${device.name} ${device.stableId}`, []);
|
|
44151
|
+
const getFilterType = useCallback((device) => device.type, []);
|
|
44152
|
+
const columns = useMemo(() => [
|
|
44153
|
+
{
|
|
44154
|
+
key: "select",
|
|
44155
|
+
header: "",
|
|
44156
|
+
headerClassName: "w-8",
|
|
44157
|
+
cell: (device) => /* @__PURE__ */ jsx("input", {
|
|
44158
|
+
type: "checkbox",
|
|
44159
|
+
checked: selectedIds.has(String(device.id)),
|
|
44160
|
+
onChange: () => toggleSelection(String(device.id)),
|
|
44161
|
+
onClick: (e) => e.stopPropagation(),
|
|
44162
|
+
"aria-label": `Select ${device.name}`
|
|
44163
|
+
})
|
|
44164
|
+
},
|
|
44165
|
+
{
|
|
44166
|
+
key: "type",
|
|
44167
|
+
header: "Type",
|
|
44168
|
+
headerClassName: "w-32",
|
|
44169
|
+
cell: (device) => {
|
|
44170
|
+
const meta = deviceTypeMetaOf(device.type);
|
|
44171
|
+
const TypeIcon = meta.icon;
|
|
44172
|
+
return /* @__PURE__ */ jsxs("span", {
|
|
44173
|
+
className: "inline-flex items-center gap-1.5 text-[11px] text-foreground-subtle",
|
|
44174
|
+
children: [/* @__PURE__ */ jsx(TypeIcon, { className: "h-3.5 w-3.5 shrink-0" }), meta.label]
|
|
44175
|
+
});
|
|
44176
|
+
}
|
|
44177
|
+
},
|
|
44178
|
+
{
|
|
44179
|
+
key: "name",
|
|
44180
|
+
header: "Name",
|
|
44181
|
+
cell: (device) => /* @__PURE__ */ jsx("span", {
|
|
44182
|
+
className: "text-xs font-medium text-foreground",
|
|
44183
|
+
children: device.name
|
|
44184
|
+
})
|
|
44185
|
+
},
|
|
44186
|
+
{
|
|
44187
|
+
key: "export",
|
|
44188
|
+
header: "Export",
|
|
44189
|
+
headerClassName: "w-32 text-right",
|
|
44190
|
+
cellClassName: "text-right",
|
|
44191
|
+
cell: (device) => /* @__PURE__ */ jsx(ExportRowToggle, {
|
|
44192
|
+
addonId,
|
|
44193
|
+
device,
|
|
44194
|
+
exposed: exposedIds.has(String(device.id)),
|
|
44195
|
+
busy,
|
|
44196
|
+
onToggle: onRowToggle
|
|
44197
|
+
})
|
|
44198
|
+
}
|
|
44199
|
+
], [
|
|
44200
|
+
addonId,
|
|
44025
44201
|
busy,
|
|
44026
|
-
|
|
44027
|
-
|
|
44202
|
+
exposedIds,
|
|
44203
|
+
onRowToggle,
|
|
44204
|
+
selectedIds,
|
|
44205
|
+
toggleSelection
|
|
44028
44206
|
]);
|
|
44029
|
-
const
|
|
44207
|
+
const selectionToolbar = selectedIds.size === 0 ? void 0 : /* @__PURE__ */ jsxs("span", {
|
|
44208
|
+
className: "inline-flex items-center gap-1.5",
|
|
44209
|
+
children: [
|
|
44210
|
+
/* @__PURE__ */ jsxs("span", {
|
|
44211
|
+
"data-testid": "export-selection-count",
|
|
44212
|
+
className: "text-[11px] text-foreground-subtle",
|
|
44213
|
+
children: [selectedIds.size, " selected"]
|
|
44214
|
+
}),
|
|
44215
|
+
/* @__PURE__ */ jsxs(Button, {
|
|
44216
|
+
size: "sm",
|
|
44217
|
+
variant: "primary",
|
|
44218
|
+
disabled: busy,
|
|
44219
|
+
onClick: exposeSelected,
|
|
44220
|
+
children: ["Expose ", selectedIds.size]
|
|
44221
|
+
}),
|
|
44222
|
+
/* @__PURE__ */ jsxs(Button, {
|
|
44223
|
+
size: "sm",
|
|
44224
|
+
variant: "secondary",
|
|
44225
|
+
disabled: busy,
|
|
44226
|
+
onClick: unexposeSelected,
|
|
44227
|
+
children: ["Unexpose ", selectedIds.size]
|
|
44228
|
+
}),
|
|
44229
|
+
/* @__PURE__ */ jsx(Button, {
|
|
44230
|
+
size: "sm",
|
|
44231
|
+
variant: "ghost",
|
|
44232
|
+
onClick: clearSelection,
|
|
44233
|
+
children: "Clear"
|
|
44234
|
+
})
|
|
44235
|
+
]
|
|
44236
|
+
});
|
|
44030
44237
|
const hasSupportedKinds = supportedKindList.length > 0;
|
|
44031
44238
|
return /* @__PURE__ */ jsxs("div", {
|
|
44032
44239
|
className: "rounded-lg border border-border bg-surface overflow-hidden",
|
|
@@ -44057,19 +44264,27 @@ function DeviceExportPanel({ addonId, trpc, onOpenDevice }) {
|
|
|
44057
44264
|
children: /* @__PURE__ */ jsx(ErrorBox, { message: status.error })
|
|
44058
44265
|
}),
|
|
44059
44266
|
status?.setup && /* @__PURE__ */ jsx(SetupSection, { setup: status.setup }),
|
|
44267
|
+
outcome !== null && /* @__PURE__ */ jsx(BulkSummary, {
|
|
44268
|
+
outcome,
|
|
44269
|
+
onDismiss: dismissOutcome
|
|
44270
|
+
}),
|
|
44060
44271
|
/* @__PURE__ */ jsx("div", {
|
|
44061
44272
|
className: "p-3",
|
|
44062
44273
|
children: loading && candidateDevices.length === 0 ? /* @__PURE__ */ jsxs("div", {
|
|
44063
44274
|
className: "flex items-center gap-2 px-1 py-6 text-xs text-foreground-subtle",
|
|
44064
44275
|
children: [/* @__PURE__ */ jsx(LoaderCircle, { className: "h-3.5 w-3.5 animate-spin" }), "Loading exportable devices…"]
|
|
44065
44276
|
}) : hasSupportedKinds && candidateDevices.length > 0 ? /* @__PURE__ */ jsx(DeviceList, {
|
|
44066
|
-
|
|
44067
|
-
|
|
44068
|
-
|
|
44069
|
-
|
|
44070
|
-
|
|
44071
|
-
|
|
44072
|
-
|
|
44277
|
+
mode: "generic",
|
|
44278
|
+
items: candidateDevices,
|
|
44279
|
+
getKey,
|
|
44280
|
+
getSearchText,
|
|
44281
|
+
getFilterType,
|
|
44282
|
+
filterOptions: supportedTypeOptions,
|
|
44283
|
+
columns,
|
|
44284
|
+
onRowClick: onOpenDevice ? onRowClick : void 0,
|
|
44285
|
+
toolbar: selectionToolbar,
|
|
44286
|
+
searchPlaceholder: "Search by name or stableId",
|
|
44287
|
+
emptyLabel: "No device matches this search."
|
|
44073
44288
|
}) : /* @__PURE__ */ jsx(EmptyState, {
|
|
44074
44289
|
icon: Share2,
|
|
44075
44290
|
title: "No exportable devices",
|