From 85eef1dadfc8b0cb1fb55d44082bf72593ed231e Mon Sep 17 00:00:00 2001 From: Gareth Sylvester-Bradley Date: Fri, 14 Aug 2026 18:39:18 +0100 Subject: [PATCH 1/7] Add design plan for editable IS-08 Channel Mapping in nmos-js Document Show/Edit (not API staged), diff-only activations with scheduled modes, soft cap warnings, and a separate Activations tab. --- Documents/channelmapping-edit-plan.md | 147 ++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 Documents/channelmapping-edit-plan.md diff --git a/Documents/channelmapping-edit-plan.md b/Documents/channelmapping-edit-plan.md new file mode 100644 index 00000000..c2a938bd --- /dev/null +++ b/Documents/channelmapping-edit-plan.md @@ -0,0 +1,147 @@ +# Design plan: Editable IS-08 Channel Mapping in nmos-js + +Status: proposal (not implemented). Builds on the existing read-only Device +**Active Map** view (`ChannelMappingMatrix` with `isShow={true}`) and the NMOS +Bridge support for Channel Mapping. Reads and activation requests use the +resolved `$channelmappingAPI`, so No Bridge, Auto Bridge and Forced Bridge +apply consistently. + +## Motivation + +nmos-js currently advertises IS-08 as read-only. The matrix UI is largely built +for interaction (`MappingButton`, `handleMap`, `mappingDisabled={isShow}`), but +`DevicesShow` never enables edit mode or posts activations. Operators still +need an external client to change maps. + +## Decisions (agreed) + +| Topic | Choice | +| --- | --- | +| IA | Normal react-admin **Show** + **Edit** (not IS-05-style API "Staged" tabs) | +| Naming | **Edit** — browser-side draft only; IS-08 has no client `/staged` resource | +| Activation modes | Immediate **and** scheduled (relative/absolute), matching Connection Edit | +| Persist draft | **No** — nothing to save until Activate (`POST /map/activations/`) | +| POST `action` body | **Diffs only** (changed output channels), which matches IS-08 map-entry merge semantics | +| Caps / routability | Soft warnings (like Connect tab receiver caps); still allow the request for node testing | +| Pending activations | Separate **Activations** show tab (list + cancel) | +| Navigate-away warning | **No** — Connection Edit does not use unsaved-navigation prompts either | +| Bridge | Use the existing bridge-aware `$channelmappingAPI` for reads and activation requests | + +## API reminder (IS-08) + +Relevant Device Channel Mapping endpoints (under the control `href` base): + +- `GET …/map/active` — current map (Show) +- `GET …/map/activations` — pending / recent activations (Activations tab) +- `POST …/map/activations/` — body `{ activation, action }`; `action` is a + partial map (output id → channel index → `{ input, channel_index }`; `null`s + in both entry fields unroute) +- `DELETE …/map/activations/{id}` — cancel a pending activation + +Node-side "staging" happens inside the activation machinery after POST; the UI +Edit view is only a local draft until Activate. + +## UI shape + +### Show — Active Map (existing, keep read-only) + +- Tab **Active Map** (current `active_map`): `ChannelMappingMatrix` with + `isShow={true}`, data from `$active.map` / `$io`. +- Actions: link/button to **Edit** (react-admin Edit route), JSON link as + today where useful. + +### Edit — map draft + activate + +- Route: the normal react-admin Device Edit route, implemented by a dedicated + Channel Mapping Edit component. Enter it from the Active Map tab; the Device + resource has no other Edit view today. +- Seed local draft from current `$active.map` on load / refresh. +- Matrix with `isShow={false}` and `handleMap` updating the draft (including + unroute). +- Activation controls aligned with Connection Edit (`ReceiversEdit` / + `SendersEdit`): + - mode: `activate_immediate` | `activate_scheduled_relative` | + `activate_scheduled_absolute` (plus clear/empty) + - `requested_time` when scheduled +- Primary action: **Activate** (or Save in react-admin terms that maps to + Activate) → build diff `action` → `POST …/map/activations/` → on success + refresh active map / activations and navigate to Show Active Map (or + Activations if scheduled and still pending). +- No "save draft" control. +- Soft validation: visually flag cells / rows that violate + `routable_inputs`, reordering, or `block_size` (and similar caps from `$io`), + but do not block Activate; show API error body if the Node rejects. + +### Show — Activations (new tab) + +- List from `$activations` / `GET …/map/activations`. +- Show id, mode, times, summary of `action` if practical. +- **Cancel** → `DELETE …/map/activations/{id}` when the API allows. +- Optional: highlight activations that still affect locked outputs. + +## dataProvider / client + +- Extend Device load (or Edit load) as needed so Edit has `$io`, `$active`, + `$channelmappingAPI`, and Activations tab has activations data (already + partially fetched as `map/activations` today). +- Implement a dedicated activation helper that posts to the resolved + `$channelmappingAPI`. Do not represent activation as a fake react-admin + `UPDATE` of the Device resource. +- Diff algorithm: compare draft map to the active map snapshot taken at Edit + load (or last successful activate); emit only changed + `output_id → channel_index → { input, channel_index }` entries; omit + unchanged outputs entirely. +- Auth: reuse existing bearer headers when auth is on (`channelmapping` scope + already listed). + +## Soft validation (Connect-tab analogy) + +On the Connect tab, receiver caps filter / warn without always forbidding +connect. Same idea here: + +- When a mapping would break caps, style the control (warning colour / icon / + tooltip stating which rule). +- Activate remains enabled. +- Useful for testing strict vs buggy Nodes. + +Exact visual language: match existing warning patterns in Connect / forms +where possible; avoid inventing a second design system. + +## Non-goals + +- Changing IS-08 or Node behaviour. +- Scheduled-activation calendar UX beyond the same mode + `requested_time` + fields Connection Edit already uses. +- Navigate-away dirty prompts. +- Persisting drafts in `localStorage` (optional later; not required). + +## Sequencing + +| Step | Work | +| --- | --- | +| 1 | Wire Edit route + matrix `handleMap` draft state; no POST yet | +| 2 | Diff builder + `POST /map/activations/` + immediate mode end-to-end | +| 3 | Scheduled modes + `requested_time` (mirror Connection Edit) | +| 4 | Soft cap warnings on the matrix | +| 5 | Activations show tab + DELETE cancel | +| 6 | README: drop "read-only for now" for IS-08 | + +## Acceptance + +- From a Device with `cm-ctrl`, user can open Edit, change mappings, Activate + immediate, and see Active Map update. +- Scheduled activation appears under Activations and can be cancelled when + still pending. +- POST body `action` contains only changed channels. +- Invalid-per-caps mappings show a warning but can still be activated (Node + may still 4xx). +- Active Map show remains read-only; no draft saved without Activate. +- No Bridge, Auto Bridge and Forced Bridge use the same resolved + `$channelmappingAPI` as the existing read-only view. + +## References + +- Existing `ChannelMappingMatrix`, `DevicesShow` Active Map tab +- Connection Edit activation mode UI (`ReceiversEdit` / `SendersEdit`) +- IS-08 `POST /map/activations/` / map-entries schema (partial `action`) +- README today: "IS-08 … (read-only for now)" From 18482b1297c14386b7841c53dee3e4c93bb01293 Mon Sep 17 00:00:00 2001 From: Gareth Sylvester-Bradley Date: Thu, 20 Aug 2026 12:17:22 +0100 Subject: [PATCH 2/7] Add immediate IS-08 Channel Mapping edit and activation Open Edit from the Active Map tab, keep a local draft, and POST only the changed channels through dataProvider UPDATE of devices. Require Jest to find tests now that the first suite exists. Signed-off-by: Gareth Sylvester-Bradley --- .github/workflows/build-test.yml | 2 +- .travis.yml | 2 +- Development/src/App.js | 9 +- .../src/components/MappingShowActions.js | 13 +- Development/src/dataProvider.js | 73 ++++++- Development/src/dataProvider.test.js | 125 ++++++++++++ Development/src/pages/devices/DevicesEdit.js | 193 ++++++++++++++++++ Development/src/pages/devices/index.js | 3 +- Documents/channelmapping-edit-plan.md | 30 +-- README.md | 3 +- 10 files changed, 432 insertions(+), 21 deletions(-) create mode 100644 Development/src/dataProvider.test.js create mode 100644 Development/src/pages/devices/DevicesEdit.js diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 2dd31dde..432273e4 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -48,7 +48,7 @@ jobs: run: | yarn run lint-check yarn run build - yarn test --passWithNoTests --coverage --watchAll=false + yarn test --coverage --watchAll=false - name: install python uses: actions/setup-python@v7 diff --git a/.travis.yml b/.travis.yml index 5e81c6e5..80acc346 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,4 +8,4 @@ install: yarn install script: - yarn run lint-check - yarn run build - - yarn test --passWithNoTests --coverage --watchAll=false + - yarn test --coverage --watchAll=false diff --git a/Development/src/App.js b/Development/src/App.js index 1a88ce65..5d6d81a7 100644 --- a/Development/src/App.js +++ b/Development/src/App.js @@ -12,7 +12,7 @@ import AdminMenu from './pages/menu'; import AppBar from './pages/appbar'; import About from './pages/about'; import { NodesList, NodesShow } from './pages/nodes'; -import { DevicesList, DevicesShow } from './pages/devices'; +import { DevicesEdit, DevicesList, DevicesShow } from './pages/devices'; import { SourcesList, SourcesShow } from './pages/sources'; import { FlowsList, FlowsShow } from './pages/flows'; import { ReceiversEdit, ReceiversList, ReceiversShow } from './pages/receivers'; @@ -59,7 +59,12 @@ const AppAdmin = () => { > - + ) : null} + {record && tab === 'active_map' && record.$channelmappingAPI ? ( + + ) : null} { return token && usingAuth(); }; +// map entries which differ between the active map and the requested map, +// cf. the deep-diff of '$staged' used to PATCH the Connection API +export const channelMappingAction = (activeMap, requestedMap) => { + const action = {}; + for (const outputId of union( + Object.keys(activeMap || {}), + Object.keys(requestedMap || {}) + )) { + const activeOutput = get(activeMap, outputId, {}); + const requestedOutput = get(requestedMap, outputId, {}); + for (const channelIndex of union( + Object.keys(activeOutput), + Object.keys(requestedOutput) + )) { + const activeEntry = get(activeOutput, channelIndex); + const requestedEntry = get(requestedOutput, channelIndex); + if (!isEqual(activeEntry, requestedEntry)) { + // use setWith rather than set to avoid creating arrays if any + // channel index is a number + setWith( + action, + [outputId, channelIndex], + requestedEntry === undefined + ? null + : cloneDeep(requestedEntry), + Object + ); + } + } + } + return action; +}; + const convertDataProviderRequestToHTTP = ( type, resource, @@ -535,6 +579,29 @@ const convertDataProviderRequestToHTTP = ( } } case UPDATE: { + if (resource === 'devices') { + // an IS-08 activation request carries only the changed output + // channels, not the whole map + const action = channelMappingAction( + get(params, 'previousData.$active.map'), + get(params, 'data.$active.map') + ); + return { + url: concatUrl( + params.data.$channelmappingAPI, + '/map/activations/' + ), + options: { + method: 'POST', + headers, + body: JSON.stringify({ + activation: { mode: 'activate_immediate' }, + action, + }), + }, + }; + } + let differences = []; let allDifferences = diff( get(params, 'previousData.$staged'), @@ -1138,6 +1205,10 @@ const convertHTTPResponseToDataProvider = async ( total: null, }; case UPDATE: + if (resource === 'devices') { + // the Channel Mapping API returns the activation, not the Device + return { data: { ...params.data, id: params.id } }; + } return { data: { ...json, id: json.id } }; case CREATE: return { data: { ...params.data, id: json.id } }; diff --git a/Development/src/dataProvider.test.js b/Development/src/dataProvider.test.js new file mode 100644 index 00000000..ebde4b93 --- /dev/null +++ b/Development/src/dataProvider.test.js @@ -0,0 +1,125 @@ +import { fetchUtils } from 'react-admin'; +import dataProvider, { channelMappingAction } from './dataProvider'; + +describe('channelMappingAction', () => { + const activeMap = { + output0: { + 0: { input: null, channel_index: null }, + 1: { input: 'input0', channel_index: 1 }, + }, + }; + + it('omits unchanged output channels', () => { + expect(channelMappingAction(activeMap, activeMap)).toEqual({}); + }); + + it('includes all changed output channels', () => { + const requestedMap = { + output0: { + 0: { input: 'input0', channel_index: 0 }, + 1: { input: null, channel_index: null }, + }, + outputB: { + 0: { input: 'inputX', channel_index: 0 }, + }, + }; + + expect(channelMappingAction(activeMap, requestedMap)).toEqual({ + output0: { + 0: { input: 'input0', channel_index: 0 }, + 1: { input: null, channel_index: null }, + }, + outputB: { + 0: { input: 'inputX', channel_index: 0 }, + }, + }); + }); + + it('uses null fields for an unrouted channel', () => { + const requestedMap = { + output0: { + 0: { input: null, channel_index: null }, + 1: { input: null, channel_index: null }, + }, + }; + + expect(channelMappingAction(activeMap, requestedMap)).toEqual({ + output0: { + 1: { input: null, channel_index: null }, + }, + }); + }); + + it('compares with the map from the most recent activation', () => { + const activatedMap = { + output0: { + 0: { input: 'input0', channel_index: 0 }, + 1: { input: 'input0', channel_index: 1 }, + }, + }; + const requestedMap = { + output0: { + 0: { input: 'input0', channel_index: 0 }, + 1: { input: null, channel_index: null }, + }, + }; + + expect(channelMappingAction(activatedMap, requestedMap)).toEqual({ + output0: { + 1: { input: null, channel_index: null }, + }, + }); + }); + + it('does not create an array for numeric channel indices', () => { + const action = channelMappingAction( + {}, + { outputX: { 0: { input: 'inputA', channel_index: 0 } } } + ); + + expect(Array.isArray(action.outputX)).toBe(false); + }); +}); + +describe('UPDATE devices', () => { + const record = { + id: '11111111-1111-4111-8111-111111111111', + $channelmappingAPI: 'http://node/x-nmos/channelmapping/v1.0', + $active: { + map: { + output0: { + 0: { input: null, channel_index: null }, + }, + }, + }, + }; + + it('posts an immediate activation of the changed channels', async () => { + const fetchJson = jest + .spyOn(fetchUtils, 'fetchJson') + .mockResolvedValue({ json: { activation0: {} } }); + + const requestedMap = { + output0: { + 0: { input: 'input0', channel_index: 0 }, + }, + }; + + await dataProvider('UPDATE', 'devices', { + id: record.id, + data: { ...record, $active: { map: requestedMap } }, + previousData: record, + }); + + expect(fetchJson).toHaveBeenCalledWith( + 'http://node/x-nmos/channelmapping/v1.0/map/activations/', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + activation: { mode: 'activate_immediate' }, + action: requestedMap, + }), + }) + ); + }); +}); diff --git a/Development/src/pages/devices/DevicesEdit.js b/Development/src/pages/devices/DevicesEdit.js new file mode 100644 index 00000000..0f817962 --- /dev/null +++ b/Development/src/pages/devices/DevicesEdit.js @@ -0,0 +1,193 @@ +import React, { Fragment, useEffect, useMemo, useState } from 'react'; +import { Button, Paper, Tab, Tabs } from '@material-ui/core'; +import { useTheme } from '@material-ui/styles'; +import { cloneDeep, get, isEqual, setWith } from 'lodash'; +import { + Loading, + ShowButton, + ShowContextProvider, + ShowView, + SimpleShowLayout, + TopToolbar, + useNotify, + useRecordContext, + useRefresh, + useShowController, +} from 'react-admin'; +import { Link, useHistory } from 'react-router-dom'; +import ResourceTitle from '../../components/ResourceTitle'; +import { ActivateImmediateIcon } from '../../icons'; +import dataProvider from '../../dataProvider'; +import ChannelMappingMatrix from './ChannelMappingMatrix'; + +const DevicesEditActions = ({ basePath, id }) => { + const theme = useTheme(); + return ( + + + + ); +}; + +const DevicesEditView = props => { + const { record } = useRecordContext(); + const activeMap = get(record, '$active.map'); + const [draftMap, setDraftMap] = useState(); + const [activating, setActivating] = useState(false); + const history = useHistory(); + const notify = useNotify(); + const refresh = useRefresh(); + const theme = useTheme(); + + // Seed the draft once, so that a refresh of the Device record while still + // on Edit does not discard it. A later Edit visit remounts and seeds from + // the map fetched after the last activation. + useEffect(() => { + if (activeMap && !draftMap) { + setDraftMap(cloneDeep(activeMap)); + } + }, [activeMap, draftMap]); + + useEffect( + () => () => { + window.localStorage.removeItem('Channel Mapping Expanded'); + }, + [] + ); + + const changed = useMemo( + () => !isEqual(activeMap, draftMap), + [activeMap, draftMap] + ); + + if (!record || !draftMap) return ; + + const handleMap = ( + inputId, + outputId, + inputChannelIndex, + outputChannelIndex + ) => { + setDraftMap(current => { + const next = cloneDeep(current); + setWith( + next, + [outputId, outputChannelIndex], + inputId === null + ? { input: null, channel_index: null } + : { + input: inputId, + channel_index: Number(inputChannelIndex), + }, + Object + ); + return next; + }); + }; + + const activate = async () => { + setActivating(true); + try { + await dataProvider('UPDATE', props.resource, { + id: props.id, + data: { ...record, $active: { map: draftMap } }, + previousData: record, + }); + notify('Channel Mapping activated', 'info'); + refresh(); + // returning to Show unmounts this view, so leave `activating` set + history.push(`${props.basePath}/${props.id}/show/active_map`); + } catch (error) { + notify(error.toString(), 'warning'); + setActivating(false); + } + }; + + const tabBackgroundColor = + theme.palette.type === 'light' + ? theme.palette.grey[100] + : theme.palette.grey[900]; + + return ( + <> +
+ + + + + + + + +
+ } + actions={} + > + +
+ +
+ +
+
+ + ); +}; + +const DevicesEdit = props => { + const controllerProps = useShowController(props); + return ( + + + + ); +}; + +export default DevicesEdit; diff --git a/Development/src/pages/devices/index.js b/Development/src/pages/devices/index.js index ffdcc5f3..3a5aec3c 100644 --- a/Development/src/pages/devices/index.js +++ b/Development/src/pages/devices/index.js @@ -1,4 +1,5 @@ +import DevicesEdit from './DevicesEdit'; import DevicesList from './DevicesList'; import DevicesShow from './DevicesShow'; -export { DevicesList, DevicesShow }; +export { DevicesEdit, DevicesList, DevicesShow }; diff --git a/Documents/channelmapping-edit-plan.md b/Documents/channelmapping-edit-plan.md index c2a938bd..7d106646 100644 --- a/Documents/channelmapping-edit-plan.md +++ b/Documents/channelmapping-edit-plan.md @@ -53,8 +53,12 @@ Edit view is only a local draft until Activate. ### Edit — map draft + activate - Route: the normal react-admin Device Edit route, implemented by a dedicated - Channel Mapping Edit component. Enter it from the Active Map tab; the Device - resource has no other Edit view today. + Channel Mapping Edit component. Enter it from the Active Map tab (Edit + button is shown only on that tab); the Device resource has no other Edit + view today. The Edit view keeps the Summary / Active Map tabs and highlights + Active Map, matching Receiver/Sender Staged edit. Activate sits in a bar at + the top of the tab content, above the filter panels, so it is not a long + scroll away when the matrix is tall. - Seed local draft from current `$active.map` on load / refresh. - Matrix with `isShow={false}` and `handleMap` updating the draft (including unroute). @@ -65,8 +69,9 @@ Edit view is only a local draft until Activate. - `requested_time` when scheduled - Primary action: **Activate** (or Save in react-admin terms that maps to Activate) → build diff `action` → `POST …/map/activations/` → on success - refresh active map / activations and navigate to Show Active Map (or - Activations if scheduled and still pending). + refresh the Device record and return to Show Active Map. A later Edit visit + remounts and seeds from the refreshed `$active.map`, so the next Activate + only includes changes since that POST. - No "save draft" control. - Soft validation: visually flag cells / rows that violate `routable_inputs`, reordering, or `block_size` (and similar caps from `$io`), @@ -84,13 +89,16 @@ Edit view is only a local draft until Activate. - Extend Device load (or Edit load) as needed so Edit has `$io`, `$active`, `$channelmappingAPI`, and Activations tab has activations data (already partially fetched as `map/activations` today). -- Implement a dedicated activation helper that posts to the resolved - `$channelmappingAPI`. Do not represent activation as a fake react-admin - `UPDATE` of the Device resource. -- Diff algorithm: compare draft map to the active map snapshot taken at Edit - load (or last successful activate); emit only changed +- Activation goes through the dataProvider as `UPDATE` of the `devices` + resource, posting to the resolved `$channelmappingAPI`, in the same way + `UPDATE` of `receivers` / `senders` PATCHes the resolved `$connectionAPI`. + That keeps URL, headers, auth and error-body handling in one place. +- Diff algorithm (in the dataProvider, beside the `$staged` deep-diff): + compare the requested map in `data.$active.map` to the map the Device + reported in `previousData.$active.map`; emit only changed `output_id → channel_index → { input, channel_index }` entries; omit - unchanged outputs entirely. + unchanged outputs entirely. The Edit view holds the draft and enables + Activate only while it differs from the map it was seeded with. - Auth: reuse existing bearer headers when auth is on (`channelmapping` scope already listed). @@ -124,7 +132,6 @@ where possible; avoid inventing a second design system. | 3 | Scheduled modes + `requested_time` (mirror Connection Edit) | | 4 | Soft cap warnings on the matrix | | 5 | Activations show tab + DELETE cancel | -| 6 | README: drop "read-only for now" for IS-08 | ## Acceptance @@ -144,4 +151,3 @@ where possible; avoid inventing a second design system. - Existing `ChannelMappingMatrix`, `DevicesShow` Active Map tab - Connection Edit activation mode UI (`ReceiversEdit` / `SendersEdit`) - IS-08 `POST /map/activations/` / map-entries schema (partial `action`) -- README today: "IS-08 … (read-only for now)" diff --git a/README.md b/README.md index a176ac59..c8fec26f 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ This repository contains a client implementation of the [AMWA Networked Media Op - [AMWA IS-04 NMOS Discovery and Registration Specification](https://amwa-tv.github.io/nmos-discovery-registration) - [AMWA IS-05 NMOS Device Connection Management Specification](https://amwa-tv.github.io/nmos-device-connection-management) -- [AMWA IS-08 NMOS Audio Channel Mapping Specification](https://specs.amwa.tv/is-08/) (read-only for now) +- [AMWA IS-08 NMOS Audio Channel Mapping Specification](https://specs.amwa.tv/is-08/) - [AMWA BCP-004-01 NMOS Receiver Capabilities](https://specs.amwa.tv/bcp-004-01/) - [AMWA BCP-007-03 NMOS Support for MXL](https://specs.amwa.tv/bcp-007-03/) @@ -73,6 +73,7 @@ The implementation is designed to be extended. Development is ongoing, following Recent activity on the project (newest first): +- IS-08 Channel Mapping: edit the Active Map and activate immediately. - Added the optional NMOS Bridge (formerly Connection API Bridge). - Launch IS-12 Device Model browser client from within nmos-js Device summary tab. - Added BCP-007-03 NMOS Support for MXL From c8d88f8af5f30eef26a027cafaf0ad024e50d181 Mon Sep 17 00:00:00 2001 From: Gareth Sylvester-Bradley Date: Thu, 20 Aug 2026 12:37:33 +0100 Subject: [PATCH 3/7] Warn on Channel Mapping cells that violate routable_inputs Keep every mapping control clickable. Unselected cells that the Output does not list use a faded warning colour; selected ones keep the check in the same warning colour so a Node can still be tested with a POST. Signed-off-by: Gareth Sylvester-Bradley --- Development/src/components/MappingButton.js | 43 +++++++++---- .../src/pages/devices/ChannelMappingMatrix.js | 63 +++++++++++++++++++ .../devices/ChannelMappingMatrix.test.js | 45 +++++++++++++ Documents/channelmapping-edit-plan.md | 29 ++++++--- 4 files changed, 160 insertions(+), 20 deletions(-) create mode 100644 Development/src/pages/devices/ChannelMappingMatrix.test.js diff --git a/Development/src/components/MappingButton.js b/Development/src/components/MappingButton.js index 70830c36..5070e248 100644 --- a/Development/src/components/MappingButton.js +++ b/Development/src/components/MappingButton.js @@ -5,28 +5,49 @@ import RadioButtonUncheckedIcon from '@material-ui/icons/RadioButtonUnchecked'; // de-emphasize the unchecked state const faded = { opacity: 0.3 }; -const styles = { +const styles = theme => ({ unchecked: faded, + constraintWarning: { + color: + theme.palette.type === 'light' + ? theme.palette.warning.dark + : theme.palette.warning.light, + }, + constraintWarningUnchecked: { opacity: 0.5 }, checked: {}, -}; +}); // filter out our classes to avoid the Material-UI console warning const MappingButton = ({ checked, + constraintWarning, classes: { checked: checkedClass, + constraintWarning: constraintWarningClass, + constraintWarningUnchecked: constraintWarningUncheckedClass, unchecked: uncheckedClass, ...inheritedClasses }, ...props -}) => ( - - {checked ? : } - -); +}) => { + const stateClass = checked + ? checkedClass + : constraintWarning + ? constraintWarningUncheckedClass + : uncheckedClass; + const className = [stateClass, constraintWarning && constraintWarningClass] + .filter(Boolean) + .join(' '); + + return ( + + {checked ? ( + + ) : ( + + )} + + ); +}; export default withStyles(styles)(MappingButton); diff --git a/Development/src/pages/devices/ChannelMappingMatrix.js b/Development/src/pages/devices/ChannelMappingMatrix.js index 17596a35..fb257ee5 100644 --- a/Development/src/pages/devices/ChannelMappingMatrix.js +++ b/Development/src/pages/devices/ChannelMappingMatrix.js @@ -139,6 +139,29 @@ const TooltipDivider = withStyles({ }, })(Divider); +const ConstraintWarning = withStyles(theme => ({ + root: { + color: + theme.palette.type === 'light' + ? theme.palette.warning.dark + : theme.palette.warning.light, + }, +}))(Typography); + +export const isRoutableInput = (outputItem, inputId) => { + const routableInputs = get(outputItem, 'caps.routable_inputs'); + // null means that the Output has no routing restrictions. If the field is + // absent or malformed, leave validation to the Node. + return !Array.isArray(routableInputs) || routableInputs.includes(inputId); +}; + +const routableInputConstraintWarning = (outputItem, inputId) => { + if (isRoutableInput(outputItem, inputId)) return; + return inputId === null + ? "This output's routable inputs do not include Unrouted." + : "This output's routable inputs do not include this input."; +}; + const InteractiveTooltipContext = createContext(); const InteractiveTooltip = ({ title, ...props }) => { @@ -329,6 +352,7 @@ const MappedCellTooltip = ({ inputName, inputChannelIndex, inputChannelLabel, + constraintWarning, }) => ( <> {'Input'} @@ -345,6 +369,15 @@ const MappedCellTooltip = ({ {outputChannelLabel} {outputChannelIndex && ` (Channel ${outputChannelIndex})`} + {constraintWarning && ( + <> + + {'Expected Constraint Violation'} + + {constraintWarning} + + + )} ); @@ -610,6 +643,14 @@ const InputChannelMappingCells = ({ `inputs.${inputId}.channels.${inputChannelIndex}` ) || inputChannel.label } + constraintWarning={ + mappingDisabled + ? undefined + : routableInputConstraintWarning( + outputItem, + inputId + ) + } /> } placement="bottom-start" @@ -633,6 +674,13 @@ const InputChannelMappingCells = ({ inputChannelIndex, outputChannelIndex )} + constraintWarning={ + !mappingDisabled && + routableInputConstraintWarning( + outputItem, + inputId + ) + } /> @@ -683,6 +731,14 @@ const UnroutedRow = ({ ) || outputChannel.label } inputName="Unrouted" + constraintWarning={ + mappingDisabled + ? undefined + : routableInputConstraintWarning( + outputItem, + null + ) + } /> } placement="bottom-start" @@ -709,6 +765,13 @@ const UnroutedRow = ({ null, outputChannelIndex )} + constraintWarning={ + !mappingDisabled && + routableInputConstraintWarning( + outputItem, + null + ) + } /> diff --git a/Development/src/pages/devices/ChannelMappingMatrix.test.js b/Development/src/pages/devices/ChannelMappingMatrix.test.js new file mode 100644 index 00000000..5c4b0aae --- /dev/null +++ b/Development/src/pages/devices/ChannelMappingMatrix.test.js @@ -0,0 +1,45 @@ +import { isRoutableInput } from './ChannelMappingMatrix'; + +describe('isRoutableInput', () => { + it('allows any input when routable_inputs is null', () => { + const output = { caps: { routable_inputs: null } }; + + expect(isRoutableInput(output, 'input0')).toBe(true); + expect(isRoutableInput(output, null)).toBe(true); + }); + + it('allows inputs listed in routable_inputs', () => { + const output = { + caps: { routable_inputs: ['input0', 'input1'] }, + }; + + expect(isRoutableInput(output, 'input1')).toBe(true); + }); + + it('warns for inputs not listed in routable_inputs', () => { + const output = { + caps: { routable_inputs: ['input0'] }, + }; + + expect(isRoutableInput(output, 'input1')).toBe(false); + }); + + it('allows unroute only when routable_inputs includes null', () => { + expect( + isRoutableInput( + { caps: { routable_inputs: ['input0', null] } }, + null + ) + ).toBe(true); + expect( + isRoutableInput({ caps: { routable_inputs: ['input0'] } }, null) + ).toBe(false); + }); + + it('leaves missing or malformed constraints to the Node', () => { + expect(isRoutableInput({}, 'input0')).toBe(true); + expect( + isRoutableInput({ caps: { routable_inputs: 'input0' } }, 'input1') + ).toBe(true); + }); +}); diff --git a/Documents/channelmapping-edit-plan.md b/Documents/channelmapping-edit-plan.md index 7d106646..f1761c3d 100644 --- a/Documents/channelmapping-edit-plan.md +++ b/Documents/channelmapping-edit-plan.md @@ -1,10 +1,11 @@ # Design plan: Editable IS-08 Channel Mapping in nmos-js -Status: proposal (not implemented). Builds on the existing read-only Device -**Active Map** view (`ChannelMappingMatrix` with `isShow={true}`) and the NMOS -Bridge support for Channel Mapping. Reads and activation requests use the -resolved `$channelmappingAPI`, so No Bridge, Auto Bridge and Forced Bridge -apply consistently. +Status: immediate activation (steps 1 and 2) implemented. Soft validation +(step 4) is in progress. Builds on the existing read-only Device **Active Map** +view (`ChannelMappingMatrix` with `isShow={true}`) and the NMOS Bridge support +for Channel Mapping. Reads and activation requests use the resolved +`$channelmappingAPI`, so No Bridge, Auto Bridge and Forced Bridge apply +consistently. ## Motivation @@ -107,9 +108,19 @@ Edit view is only a local draft until Activate. On the Connect tab, receiver caps filter / warn without always forbidding connect. Same idea here: -- When a mapping would break caps, style the control (warning colour / icon / - tooltip stating which rule). -- Activate remains enabled. +- All mapping controls remain usable and Activate remains enabled. The Node is + the authority; the UI warning is a prediction which can deliberately be + submitted when testing a Node. +- For `routable_inputs`, an unselected mapping which the Output does not list + uses a faded warning-colour hollow icon. If selected, it uses a full + warning-colour checked icon. Both tooltips state the expected constraint + violation. The same applies to Unrouted when the constraint omits `null`. +- `routable_inputs: null` means unconstrained. Missing or malformed caps are + left to the Node rather than guessed at. +- Block size and reordering require validation of the complete draft. Warn on + selected cells participating in a broken block; do not pre-colour all cells + which might form an incomplete block. +- The read-only Active Map does not show predicted warnings. - Useful for testing strict vs buggy Nodes. Exact visual language: match existing warning patterns in Connect / forms @@ -130,7 +141,7 @@ where possible; avoid inventing a second design system. | 1 | Wire Edit route + matrix `handleMap` draft state; no POST yet | | 2 | Diff builder + `POST /map/activations/` + immediate mode end-to-end | | 3 | Scheduled modes + `requested_time` (mirror Connection Edit) | -| 4 | Soft cap warnings on the matrix | +| 4 | Soft cap warnings on the matrix (`routable_inputs` first, then block size / reordering) | | 5 | Activations show tab + DELETE cancel | ## Acceptance From 74a6c2eb91f065e3c1bb6320c6039dc3dc9deb37 Mon Sep 17 00:00:00 2001 From: Gareth Sylvester-Bradley Date: Thu, 20 Aug 2026 13:07:29 +0100 Subject: [PATCH 4/7] Warn on Channel Mapping cells that violate block_size or reordering Predict incomplete blocks and non-fixed offsets from the draft map, then show the highest-priority warning on selected cells. Leave Activate enabled. Signed-off-by: Gareth Sylvester-Bradley --- .../src/pages/devices/ChannelMappingMatrix.js | 249 +++++++++++++++--- .../devices/ChannelMappingMatrix.test.js | 107 +++++++- 2 files changed, 325 insertions(+), 31 deletions(-) diff --git a/Development/src/pages/devices/ChannelMappingMatrix.js b/Development/src/pages/devices/ChannelMappingMatrix.js index fb257ee5..e649c22d 100644 --- a/Development/src/pages/devices/ChannelMappingMatrix.js +++ b/Development/src/pages/devices/ChannelMappingMatrix.js @@ -162,6 +162,172 @@ const routableInputConstraintWarning = (outputItem, inputId) => { : "This output's routable inputs do not include this input."; }; +const setConstraintWarning = ( + warnings, + outputId, + outputChannelIndex, + warning +) => { + setWith(warnings, [outputId, outputChannelIndex], warning, Object); +}; + +export const channelMappingConstraintWarnings = (io, mapping) => { + const routableInputWarnings = {}; + const blockSizeWarnings = {}; + const reorderingWarnings = {}; + + for (const [outputId, outputMap] of Object.entries(mapping || {})) { + const outputItem = get(io, ['outputs', outputId]); + const entries = Object.entries(outputMap) + .sort(([left], [right]) => Number(left) - Number(right)) + .map(([outputChannelIndex, entry]) => ({ + outputChannelIndex, + outputIndex: Number(outputChannelIndex), + inputId: get(entry, 'input'), + inputIndex: get(entry, 'channel_index'), + })); + + for (const entry of entries) { + const warning = routableInputConstraintWarning( + outputItem, + entry.inputId + ); + if (warning) { + setConstraintWarning( + routableInputWarnings, + outputId, + entry.outputChannelIndex, + warning + ); + } + } + + const inputOffsets = {}; + const reorderingViolationInputs = new Set(); + let currentInputId; + let currentBlockSize; + let currentBlock = []; + + const checkCurrentBlock = () => { + if (!currentBlock.length || !currentBlockSize) return; + const inputBlock = Math.floor( + currentBlock[0].inputIndex / currentBlockSize + ); + const inputChannels = new Set( + currentBlock.map(({ inputIndex }) => inputIndex) + ); + const complete = + currentBlock.length === currentBlockSize && + inputChannels.size === currentBlockSize && + currentBlock.every( + ({ inputIndex }) => + Math.floor(inputIndex / currentBlockSize) === inputBlock + ); + if (!complete) { + const warning = `This input requires channels to be routed in complete blocks of ${currentBlockSize}.`; + for (const { outputChannelIndex } of currentBlock) { + setConstraintWarning( + blockSizeWarnings, + outputId, + outputChannelIndex, + warning + ); + } + } + }; + + for (const entry of entries) { + if (entry.inputId === null) continue; + const inputItem = get(io, ['inputs', entry.inputId]); + const blockSize = get(inputItem, 'caps.block_size'); + if ( + !Number.isInteger(entry.outputIndex) || + !Number.isInteger(entry.inputIndex) || + !Number.isInteger(blockSize) || + blockSize < 1 + ) { + checkCurrentBlock(); + currentBlock = []; + currentInputId = undefined; + currentBlockSize = undefined; + continue; + } + + if (entry.inputId !== currentInputId) { + checkCurrentBlock(); + currentBlock = []; + currentInputId = entry.inputId; + currentBlockSize = blockSize; + if (get(inputItem, 'caps.reordering') === false) { + if (entry.inputIndex % blockSize !== 0) { + reorderingViolationInputs.add(entry.inputId); + } + if ( + !Object.prototype.hasOwnProperty.call( + inputOffsets, + entry.inputId + ) + ) { + inputOffsets[entry.inputId] = + entry.inputIndex - entry.outputIndex; + } + } + } else if (currentBlock.length === currentBlockSize) { + checkCurrentBlock(); + currentBlock = []; + } + + if (get(inputItem, 'caps.reordering') === false) { + const offset = entry.inputIndex - entry.outputIndex; + if (offset !== inputOffsets[entry.inputId]) { + reorderingViolationInputs.add(entry.inputId); + } + if ( + currentBlock.length && + entry.inputIndex !== + currentBlock[currentBlock.length - 1].inputIndex + 1 + ) { + reorderingViolationInputs.add(entry.inputId); + } + } + currentBlock.push(entry); + } + checkCurrentBlock(); + + const reorderingWarning = + 'This input does not allow reordering; channels must keep a fixed offset on this output.'; + for (const entry of entries) { + if (reorderingViolationInputs.has(entry.inputId)) { + setConstraintWarning( + reorderingWarnings, + outputId, + entry.outputChannelIndex, + reorderingWarning + ); + } + } + } + + const warnings = {}; + for (const [outputId, outputMap] of Object.entries(mapping || {})) { + for (const outputChannelIndex of Object.keys(outputMap)) { + const warning = + get(routableInputWarnings, [outputId, outputChannelIndex]) || + get(blockSizeWarnings, [outputId, outputChannelIndex]) || + get(reorderingWarnings, [outputId, outputChannelIndex]); + if (warning) { + setConstraintWarning( + warnings, + outputId, + outputChannelIndex, + warning + ); + } + } + } + return warnings; +}; + const InteractiveTooltipContext = createContext(); const InteractiveTooltip = ({ title, ...props }) => { @@ -577,6 +743,7 @@ const InputChannelMappingCells = ({ mappingDisabled, handleMap, isMapped, + getConstraintWarning, truncateValue, }) => { const { getCustomName } = useCustomNamesContext(); @@ -643,14 +810,13 @@ const InputChannelMappingCells = ({ `inputs.${inputId}.channels.${inputChannelIndex}` ) || inputChannel.label } - constraintWarning={ - mappingDisabled - ? undefined - : routableInputConstraintWarning( - outputItem, - inputId - ) - } + constraintWarning={getConstraintWarning( + inputId, + outputId, + inputChannelIndex, + outputChannelIndex, + outputItem + )} /> } placement="bottom-start" @@ -674,13 +840,13 @@ const InputChannelMappingCells = ({ inputChannelIndex, outputChannelIndex )} - constraintWarning={ - !mappingDisabled && - routableInputConstraintWarning( - outputItem, - inputId - ) - } + constraintWarning={getConstraintWarning( + inputId, + outputId, + inputChannelIndex, + outputChannelIndex, + outputItem + )} /> @@ -703,6 +869,7 @@ const UnroutedRow = ({ mappingDisabled, handleMap, isMapped, + getConstraintWarning, isOutputExpanded, }) => { const { getCustomName } = useCustomNamesContext(); @@ -731,14 +898,13 @@ const UnroutedRow = ({ ) || outputChannel.label } inputName="Unrouted" - constraintWarning={ - mappingDisabled - ? undefined - : routableInputConstraintWarning( - outputItem, - null - ) - } + constraintWarning={getConstraintWarning( + null, + outputId, + null, + outputChannelIndex, + outputItem + )} /> } placement="bottom-start" @@ -765,13 +931,13 @@ const UnroutedRow = ({ null, outputChannelIndex )} - constraintWarning={ - !mappingDisabled && - routableInputConstraintWarning( - outputItem, - null - ) - } + constraintWarning={getConstraintWarning( + null, + outputId, + null, + outputChannelIndex, + outputItem + )} /> @@ -891,6 +1057,7 @@ const InputsRows = ({ isShow, handleMap, isMapped, + getConstraintWarning, truncateValue, }) => { const { getCustomName } = useCustomNamesContext(); @@ -957,6 +1124,7 @@ const InputsRows = ({ mappingDisabled={isShow} handleMap={handleMap} isMapped={isMapped} + getConstraintWarning={getConstraintWarning} truncateValue={truncateValue} /> ) : null} @@ -977,6 +1145,7 @@ const InputsRows = ({ mappingDisabled={isShow} handleMap={handleMap} isMapped={isMapped} + getConstraintWarning={getConstraintWarning} truncateValue={truncateValue} /> @@ -1077,6 +1246,24 @@ const ChannelMappingMatrix = ({ record, isShow, mapping, handleMap }) => { const truncateValue = value => truncateValueAtLength(value, maxLength); const io = convertChannelsArraysToObjects(get(record, '$io')); + const constraintWarnings = channelMappingConstraintWarnings(io, mapping); + const getConstraintWarning = ( + inputId, + outputId, + inputChannelIndex, + outputChannelIndex, + outputItem + ) => { + if (isShow) return; + return isMapped( + inputId, + outputId, + inputChannelIndex, + outputChannelIndex + ) + ? get(constraintWarnings, [outputId, outputChannelIndex]) + : routableInputConstraintWarning(outputItem, inputId); + }; const getInputAPIName = inputId => get(io, `inputs.${inputId}.properties.name`); @@ -1189,6 +1376,7 @@ const ChannelMappingMatrix = ({ record, isShow, mapping, handleMap }) => { mappingDisabled={isShow} handleMap={handleMap} isMapped={isMapped} + getConstraintWarning={getConstraintWarning} isOutputExpanded={id => isExpanded('outputs', id)} /> { isShow={isShow} handleMap={handleMap} isMapped={isMapped} + getConstraintWarning={getConstraintWarning} truncateValue={truncateValue} /> diff --git a/Development/src/pages/devices/ChannelMappingMatrix.test.js b/Development/src/pages/devices/ChannelMappingMatrix.test.js index 5c4b0aae..c7c4f3d3 100644 --- a/Development/src/pages/devices/ChannelMappingMatrix.test.js +++ b/Development/src/pages/devices/ChannelMappingMatrix.test.js @@ -1,4 +1,7 @@ -import { isRoutableInput } from './ChannelMappingMatrix'; +import { + channelMappingConstraintWarnings, + isRoutableInput, +} from './ChannelMappingMatrix'; describe('isRoutableInput', () => { it('allows any input when routable_inputs is null', () => { @@ -43,3 +46,105 @@ describe('isRoutableInput', () => { ).toBe(true); }); }); + +describe('channelMappingConstraintWarnings', () => { + const io = { + inputs: { + reorderable: { + caps: { block_size: 2, reordering: true }, + }, + fixed: { + caps: { block_size: 2, reordering: false }, + }, + }, + outputs: { + output0: { + caps: { routable_inputs: null }, + }, + }, + }; + const outputMap = channels => ({ output0: channels }); + + it('accepts a complete input block when reordering is allowed', () => { + const warnings = channelMappingConstraintWarnings( + io, + outputMap({ + 0: { input: 'reorderable', channel_index: 1 }, + 1: { input: 'reorderable', channel_index: 0 }, + }) + ); + + expect(warnings).toEqual({}); + }); + + it('warns on selected channels in an incomplete input block', () => { + const warnings = channelMappingConstraintWarnings( + io, + outputMap({ + 0: { input: 'reorderable', channel_index: 0 }, + }) + ); + + expect(warnings.output0[0]).toMatch(/complete blocks of 2/); + }); + + it('warns when selected channels come from different input blocks', () => { + const warnings = channelMappingConstraintWarnings( + io, + outputMap({ + 0: { input: 'reorderable', channel_index: 0 }, + 1: { input: 'reorderable', channel_index: 2 }, + }) + ); + + expect(warnings.output0[0]).toMatch(/complete blocks of 2/); + expect(warnings.output0[1]).toMatch(/complete blocks of 2/); + }); + + it('warns when reordering changes the fixed channel offset', () => { + const warnings = channelMappingConstraintWarnings( + io, + outputMap({ + 0: { input: 'fixed', channel_index: 1 }, + 1: { input: 'fixed', channel_index: 0 }, + }) + ); + + expect(warnings.output0[0]).toMatch(/fixed offset/); + expect(warnings.output0[1]).toMatch(/fixed offset/); + }); + + it('uses block size warnings ahead of reordering', () => { + const warnings = channelMappingConstraintWarnings( + io, + outputMap({ + 0: { input: 'fixed', channel_index: 0 }, + 1: { input: 'fixed', channel_index: 2 }, + }) + ); + + expect(warnings.output0[0]).toMatch(/complete blocks of 2/); + expect(warnings.output0[1]).toMatch(/complete blocks of 2/); + }); + + it('uses routable inputs warnings ahead of other constraints', () => { + const restrictedIo = { + ...io, + outputs: { + output0: { + caps: { routable_inputs: ['reorderable'] }, + }, + }, + }; + const warnings = channelMappingConstraintWarnings( + restrictedIo, + outputMap({ + 0: { input: 'fixed', channel_index: 1 }, + 1: { input: 'fixed', channel_index: 0 }, + }) + ); + + expect(warnings.output0[0]).toMatch(/routable inputs/); + expect(warnings.output0[1]).toMatch(/routable inputs/); + }); +}); From b5bae3fe0a4c9dfd9ba6eaf87437b32e7729449d Mon Sep 17 00:00:00 2001 From: Gareth Sylvester-Bradley Date: Thu, 20 Aug 2026 13:51:31 +0100 Subject: [PATCH 5/7] Add scheduled Channel Mapping activations and a pending Activations tab POST relative and absolute modes with requested_time, list pending activations from $activations, and cancel them with DELETE. Signed-off-by: Gareth Sylvester-Bradley --- .../src/components/MappingShowActions.js | 5 + Development/src/dataProvider.js | 31 +++- Development/src/dataProvider.test.js | 65 +++++++ Development/src/pages/devices/DevicesEdit.js | 109 +++++++++++- Development/src/pages/devices/DevicesShow.js | 168 ++++++++++++++++-- 5 files changed, 354 insertions(+), 24 deletions(-) diff --git a/Development/src/components/MappingShowActions.js b/Development/src/components/MappingShowActions.js index c321c03f..098c01ec 100644 --- a/Development/src/components/MappingShowActions.js +++ b/Development/src/components/MappingShowActions.js @@ -20,6 +20,11 @@ export default function MappingShowActions({ basePath, id, resource }) { if (record) { if (tab === 'active_map' && record.$channelmappingAPI) { json_href = concatUrl(record.$channelmappingAPI, '/map/active'); + } else if (tab === 'activations' && record.$channelmappingAPI) { + json_href = concatUrl( + record.$channelmappingAPI, + '/map/activations' + ); } else { json_href = resourceUrl(resource, `/${id}`); } diff --git a/Development/src/dataProvider.js b/Development/src/dataProvider.js index 06583488..bcfa6f60 100644 --- a/Development/src/dataProvider.js +++ b/Development/src/dataProvider.js @@ -586,6 +586,19 @@ const convertDataProviderRequestToHTTP = ( get(params, 'previousData.$active.map'), get(params, 'data.$active.map') ); + const mode = get( + params, + 'data.$activation.mode', + 'activate_immediate' + ); + const activation = { mode }; + if (mode !== 'activate_immediate') { + activation.requested_time = get( + params, + 'data.$activation.requested_time', + null + ); + } return { url: concatUrl( params.data.$channelmappingAPI, @@ -595,7 +608,7 @@ const convertDataProviderRequestToHTTP = ( method: 'POST', headers, body: JSON.stringify({ - activation: { mode: 'activate_immediate' }, + activation, action, }), }, @@ -691,6 +704,22 @@ const convertDataProviderRequestToHTTP = ( }; } case DELETE: { + if (resource === 'devices') { + const activationId = params.activationId; + if (!activationId) { + throw new Error('missing activation id'); + } + return { + url: concatUrl( + get(params, 'previousData.$channelmappingAPI'), + `/map/activations/${activationId}` + ), + options: { + method: 'DELETE', + headers, + }, + }; + } return { url: resourceUrl(resource, `/${params.id}`), options: { diff --git a/Development/src/dataProvider.test.js b/Development/src/dataProvider.test.js index ebde4b93..db09f765 100644 --- a/Development/src/dataProvider.test.js +++ b/Development/src/dataProvider.test.js @@ -122,4 +122,69 @@ describe('UPDATE devices', () => { }) ); }); + + it('posts a scheduled activation with requested_time', async () => { + const fetchJson = jest + .spyOn(fetchUtils, 'fetchJson') + .mockResolvedValue({ json: { activation0: {} } }); + + const requestedMap = { + output0: { + 0: { input: 'input0', channel_index: 0 }, + }, + }; + + await dataProvider('UPDATE', 'devices', { + id: record.id, + data: { + ...record, + $active: { map: requestedMap }, + $activation: { + mode: 'activate_scheduled_relative', + requested_time: '0:1000000000', + }, + }, + previousData: record, + }); + + expect(fetchJson).toHaveBeenCalledWith( + 'http://node/x-nmos/channelmapping/v1.0/map/activations/', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + activation: { + mode: 'activate_scheduled_relative', + requested_time: '0:1000000000', + }, + action: requestedMap, + }), + }) + ); + }); +}); + +describe('DELETE devices', () => { + const record = { + id: '11111111-1111-4111-8111-111111111111', + $channelmappingAPI: 'http://node/x-nmos/channelmapping/v1.0', + }; + + it('deletes the pending activation, not the Device', async () => { + const fetchJson = jest + .spyOn(fetchUtils, 'fetchJson') + .mockResolvedValue({ json: {} }); + + await dataProvider('DELETE', 'devices', { + id: record.id, + activationId: 'activation0', + previousData: record, + }); + + expect(fetchJson).toHaveBeenCalledWith( + 'http://node/x-nmos/channelmapping/v1.0/map/activations/activation0', + expect.objectContaining({ + method: 'DELETE', + }) + ); + }); }); diff --git a/Development/src/pages/devices/DevicesEdit.js b/Development/src/pages/devices/DevicesEdit.js index 0f817962..4bdd2704 100644 --- a/Development/src/pages/devices/DevicesEdit.js +++ b/Development/src/pages/devices/DevicesEdit.js @@ -1,5 +1,12 @@ import React, { Fragment, useEffect, useMemo, useState } from 'react'; -import { Button, Paper, Tab, Tabs } from '@material-ui/core'; +import { + Button, + MenuItem, + Paper, + Tab, + Tabs, + TextField, +} from '@material-ui/core'; import { useTheme } from '@material-ui/styles'; import { cloneDeep, get, isEqual, setWith } from 'lodash'; import { @@ -16,10 +23,16 @@ import { } from 'react-admin'; import { Link, useHistory } from 'react-router-dom'; import ResourceTitle from '../../components/ResourceTitle'; -import { ActivateImmediateIcon } from '../../icons'; +import { ActivateImmediateIcon, ActivateScheduledIcon } from '../../icons'; import dataProvider from '../../dataProvider'; import ChannelMappingMatrix from './ChannelMappingMatrix'; +const activationModes = [ + 'activate_immediate', + 'activate_scheduled_relative', + 'activate_scheduled_absolute', +]; + const DevicesEditActions = ({ basePath, id }) => { const theme = useTheme(); return ( @@ -47,11 +60,14 @@ const DevicesEditView = props => { const { record } = useRecordContext(); const activeMap = get(record, '$active.map'); const [draftMap, setDraftMap] = useState(); + const [activationMode, setActivationMode] = useState('activate_immediate'); + const [requestedTime, setRequestedTime] = useState(''); const [activating, setActivating] = useState(false); const history = useHistory(); const notify = useNotify(); const refresh = useRefresh(); const theme = useTheme(); + const scheduled = activationMode !== 'activate_immediate'; // Seed the draft once, so that a refresh of the Device record while still // on Edit does not discard it. A later Edit visit remounts and seeds from @@ -104,13 +120,29 @@ const DevicesEditView = props => { try { await dataProvider('UPDATE', props.resource, { id: props.id, - data: { ...record, $active: { map: draftMap } }, + data: { + ...record, + $active: { map: draftMap }, + $activation: { + mode: activationMode, + requested_time: scheduled ? requestedTime : null, + }, + }, previousData: record, }); - notify('Channel Mapping activated', 'info'); + notify( + scheduled + ? 'Channel Mapping activation scheduled' + : 'Channel Mapping activated', + 'info' + ); refresh(); // returning to Show unmounts this view, so leave `activating` set - history.push(`${props.basePath}/${props.id}/show/active_map`); + history.push( + scheduled + ? `${props.basePath}/${props.id}/show/activations` + : `${props.basePath}/${props.id}/show/active_map` + ); } catch (error) { notify(error.toString(), 'warning'); setActivating(false); @@ -147,6 +179,11 @@ const DevicesEditView = props => { component={Link} to={`${props.basePath}/${props.id}/show/active_map`} /> + @@ -158,15 +195,69 @@ const DevicesEditView = props => { actions={} > -
+
+ { + setActivationMode(event.target.value); + if ( + event.target.value === 'activate_immediate' + ) { + setRequestedTime(''); + } + }} + select + style={{ + marginRight: theme.spacing(2), + minWidth: 240, + }} + value={activationMode} + variant="filled" + > + {activationModes.map(mode => ( + + {mode} + + ))} + + {scheduled && ( + + setRequestedTime(event.target.value) + } + onFocus={event => event.target.select()} + style={{ marginRight: theme.spacing(2) }} + value={requestedTime} + variant="filled" + /> + )}
{ const controllerProps = useShowController(props); return ( @@ -91,18 +112,21 @@ const DevicesShowView = props => { component={Link} to={`${props.basePath}/${props.id}/show/`} /> - {['active_map'].map(key => ( - - ))} + {Object.entries(channelMappingTabs).map( + ([key, source]) => ( + + ) + )} @@ -114,6 +138,12 @@ const DevicesShowView = props => { + + + ); }; @@ -250,4 +280,114 @@ const ShowActiveMapTab = ({ record, ...props }) => { ); }; +// the changed output channels of a pending activation, e.g. 'output0 (0, 1)' +const actionSummary = action => + map( + action, + (channels, outputId) => + `${outputId} (${Object.keys(channels).join(', ')})` + ).join('; '); + +const CancelActivationButton = ({ record, activationId }) => { + const notify = useNotify(); + const refresh = useRefresh(); + const [cancelling, setCancelling] = useState(false); + return ( + + ); +}; + +// cf. ObjectField +const ActivationsField = ({ record, source }) => { + const activations = get(record, source); + if (isEmpty(activations)) { + return ( + {'No pending activations'} + ); + } + return ( + + + + ID + Mode + Requested Time + Activation Time + Action + + + + + {map(activations, (activation, activationId) => ( + + {activationId} + + {get(activation, 'activation.mode')} + + + + + + + + + {actionSummary(get(activation, 'action'))} + + + + + + ))} + +
+ ); +}; +ActivationsField.defaultProps = { + addLabel: true, +}; + +const ShowActivationsTab = ({ record, ...props }) => { + if (!record || get(record, '$activations') === undefined) { + return ; + } + return ( + } actions={}> + + + + + ); +}; + export default DevicesShow; From 29f6e4ad23455336729986686b611022fb280198 Mon Sep 17 00:00:00 2001 From: Gareth Sylvester-Bradley Date: Thu, 20 Aug 2026 14:05:32 +0100 Subject: [PATCH 6/7] Document completed Channel Mapping edit in the plan and README Signed-off-by: Gareth Sylvester-Bradley --- Documents/channelmapping-edit-plan.md | 26 +++++++++++++------------- README.md | 2 +- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/Documents/channelmapping-edit-plan.md b/Documents/channelmapping-edit-plan.md index f1761c3d..ea38e8bd 100644 --- a/Documents/channelmapping-edit-plan.md +++ b/Documents/channelmapping-edit-plan.md @@ -1,11 +1,11 @@ # Design plan: Editable IS-08 Channel Mapping in nmos-js -Status: immediate activation (steps 1 and 2) implemented. Soft validation -(step 4) is in progress. Builds on the existing read-only Device **Active Map** -view (`ChannelMappingMatrix` with `isShow={true}`) and the NMOS Bridge support -for Channel Mapping. Reads and activation requests use the resolved -`$channelmappingAPI`, so No Bridge, Auto Bridge and Forced Bridge apply -consistently. +Status: implemented. Device Edit drafts the Active Map and posts immediate or +scheduled activations (diff-only `action`). The matrix soft-validates +`routable_inputs`, `block_size` and `reordering` without blocking Activate. +Show has a pending Activations tab with Cancel (`DELETE`). Reads and +activation requests use the resolved `$channelmappingAPI`, so No Bridge, Auto +Bridge and Forced Bridge apply consistently. ## Motivation @@ -136,13 +136,13 @@ where possible; avoid inventing a second design system. ## Sequencing -| Step | Work | -| --- | --- | -| 1 | Wire Edit route + matrix `handleMap` draft state; no POST yet | -| 2 | Diff builder + `POST /map/activations/` + immediate mode end-to-end | -| 3 | Scheduled modes + `requested_time` (mirror Connection Edit) | -| 4 | Soft cap warnings on the matrix (`routable_inputs` first, then block size / reordering) | -| 5 | Activations show tab + DELETE cancel | +| Step | Work | Status | +| --- | --- | --- | +| 1 | Wire Edit route + matrix `handleMap` draft state; no POST yet | Done | +| 2 | Diff builder + `POST /map/activations/` + immediate mode end-to-end | Done | +| 3 | Scheduled modes + `requested_time` (mirror Connection Edit) | Done | +| 4 | Soft cap warnings on the matrix (`routable_inputs` first, then block size / reordering) | Done | +| 5 | Activations show tab + DELETE cancel | Done | ## Acceptance diff --git a/README.md b/README.md index c8fec26f..762ba3fa 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ The implementation is designed to be extended. Development is ongoing, following Recent activity on the project (newest first): -- IS-08 Channel Mapping: edit the Active Map and activate immediately. +- IS-08 Channel Mapping: edit the Active Map and post immediate or scheduled activations. - Added the optional NMOS Bridge (formerly Connection API Bridge). - Launch IS-12 Device Model browser client from within nmos-js Device summary tab. - Added BCP-007-03 NMOS Support for MXL From 99337ee17e4626b9a7a6452fb46203c46f609abb Mon Sep 17 00:00:00 2001 From: Gareth Sylvester-Bradley Date: Thu, 20 Aug 2026 15:32:05 +0100 Subject: [PATCH 7/7] Stop mapping-cell tooltips capturing pointer events Use a non-interactive MappingCellTooltip on matrix cells so the popover does not block the cell below or stay open after the pointer leaves. Keep MappingHeadTooltip interactive for header names and chip links. Signed-off-by: Gareth Sylvester-Bradley --- .../src/pages/devices/ChannelMappingMatrix.js | 64 +++++++++++-------- 1 file changed, 36 insertions(+), 28 deletions(-) diff --git a/Development/src/pages/devices/ChannelMappingMatrix.js b/Development/src/pages/devices/ChannelMappingMatrix.js index e649c22d..3e36ae1f 100644 --- a/Development/src/pages/devices/ChannelMappingMatrix.js +++ b/Development/src/pages/devices/ChannelMappingMatrix.js @@ -328,11 +328,11 @@ export const channelMappingConstraintWarnings = (io, mapping) => { return warnings; }; -const InteractiveTooltipContext = createContext(); +const MappingHeadTooltipContext = createContext(); -const InteractiveTooltip = ({ title, ...props }) => { +const MappingHeadTooltip = ({ title, ...props }) => { const { tooltipModal, setTooltipModal } = useContext( - InteractiveTooltipContext + MappingHeadTooltipContext ); const [open, setOpen] = useState(false); @@ -368,6 +368,14 @@ const InteractiveTooltip = ({ title, ...props }) => { ); }; +// mapping cell tooltips have no editable content, so they must not capture the +// pointer or stay open when the mouse moves on to another cell +const MappingCellTooltip = props => { + const { tooltipModal } = useContext(MappingHeadTooltipContext); + + return ; +}; + const popperPropsOffset = (skidding, distance) => ({ popperOptions: { modifiers: { @@ -383,7 +391,7 @@ const popperPropsOffset = (skidding, distance) => ({ const popperPropsNearer = popperPropsOffset(0, -10); const OutputTooltip = ({ outputId, outputItem, getInputAPIName }) => { - const { setTooltipModal } = useContext(InteractiveTooltipContext); + const { setTooltipModal } = useContext(MappingHeadTooltipContext); const { getCustomName } = useCustomNamesContext(); const source = `outputs.${outputId}.name`; @@ -434,7 +442,7 @@ const OutputTooltip = ({ outputId, outputItem, getInputAPIName }) => { }; const InputTooltip = ({ inputId, inputItem }) => { - const { setTooltipModal } = useContext(InteractiveTooltipContext); + const { setTooltipModal } = useContext(MappingHeadTooltipContext); const { getCustomName } = useCustomNamesContext(); const source = `inputs.${inputId}.name`; @@ -485,7 +493,7 @@ const InputTooltip = ({ inputId, inputItem }) => { const ChannelTooltip = ({ ioResource, id, channelIndex, channelLabel }) => { const { getCustomName } = useCustomNamesContext(); - const { setTooltipModal } = useContext(InteractiveTooltipContext); + const { setTooltipModal } = useContext(MappingHeadTooltipContext); const source = `${ioResource}.${id}.channels.${channelIndex}`; return ( <> @@ -629,7 +637,7 @@ const OutputSourceAssociation = ({ outputs, isExpanded, truncateValue }) => key={outputId} > {get(outputItem, 'source_id') ? ( - } placement="top" arrow @@ -647,9 +655,9 @@ const OutputSourceAssociation = ({ outputs, isExpanded, truncateValue }) =>
- + ) : ( - {'No Source'} } @@ -658,7 +666,7 @@ const OutputSourceAssociation = ({ outputs, isExpanded, truncateValue }) => PopperProps={popperPropsNearer} >
{truncateValue('No Source')}
-
+ )} )); @@ -693,16 +701,16 @@ const InputParentAssociation = ({ rowSpan={isInputExpanded ? Object.keys(inputItem.channels).length : 1} > {inputItem.parent.type === null ? ( - {'No Parent'}} placement="left" arrow PopperProps={popperPropsNearer} >
{truncateValue('No Parent')}
-
+ ) : ( - } placement="left" arrow @@ -713,7 +721,7 @@ const InputParentAssociation = ({ - + )} ); @@ -750,7 +758,7 @@ const InputChannelMappingCells = ({ return ( <> - - + <> {outputs.map(([outputId, outputItem]) => @@ -780,7 +788,7 @@ const InputChannelMappingCells = ({ Object.entries(outputItem.channels).map( ([outputChannelIndex, outputChannel]) => ( - - + ) ) @@ -881,7 +889,7 @@ const UnroutedRow = ({ Object.entries(outputItem.channels).map( ([outputChannelIndex, outputChannel]) => ( - - + ) ) @@ -975,7 +983,7 @@ const OutputsHeadRow = ({ rowSpan={isOutputExpanded(outputId) ? 1 : 2} key={outputId} > - - + onExpandOutput(outputId)} isExpanded={isOutputExpanded(outputId)} @@ -1014,7 +1022,7 @@ const OutputsHeadRow = ({ ? Object.entries(outputItem.channels).map( ([channelIndex, channel]) => ( - - + ) ) @@ -1077,7 +1085,7 @@ const InputsRows = ({ } colSpan={isInputExpanded(inputId) ? 1 : 2} > - - + onExpandInput(inputId)} isExpanded={isInputExpanded(inputId)} @@ -1347,7 +1355,7 @@ const ChannelMappingMatrix = ({ record, isShow, mapping, handleMap }) => { Clear Custom Names - @@ -1393,7 +1401,7 @@ const ChannelMappingMatrix = ({ record, isShow, mapping, handleMap }) => { />
-
+ ); };