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 = () => { > - + ({ 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/components/MappingShowActions.js b/Development/src/components/MappingShowActions.js index e4131214..098c01ec 100644 --- a/Development/src/components/MappingShowActions.js +++ b/Development/src/components/MappingShowActions.js @@ -1,5 +1,11 @@ import React from 'react'; -import { Button, ListButton, TopToolbar, useRecordContext } from 'react-admin'; +import { + Button, + EditButton, + ListButton, + TopToolbar, + useRecordContext, +} from 'react-admin'; import JsonIcon from '../icons/JsonIcon'; import { useTheme } from '@material-ui/styles'; import { concatUrl } from '../settings'; @@ -10,10 +16,15 @@ export default function MappingShowActions({ basePath, id, resource }) { const { record } = useRecordContext(); let json_href; const theme = useTheme(); + const tab = window.location.href.split('/').pop(); if (record) { - const tab = window.location.href.split('/').pop(); 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}`); } @@ -40,6 +51,9 @@ export default function MappingShowActions({ basePath, id, resource }) { ) : 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,42 @@ 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') + ); + 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, + '/map/activations/' + ), + options: { + method: 'POST', + headers, + body: JSON.stringify({ + activation, + action, + }), + }, + }; + } + let differences = []; let allDifferences = diff( get(params, 'previousData.$staged'), @@ -624,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: { @@ -1138,6 +1234,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..db09f765 --- /dev/null +++ b/Development/src/dataProvider.test.js @@ -0,0 +1,190 @@ +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, + }), + }) + ); + }); + + 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/ChannelMappingMatrix.js b/Development/src/pages/devices/ChannelMappingMatrix.js index 17596a35..3e36ae1f 100644 --- a/Development/src/pages/devices/ChannelMappingMatrix.js +++ b/Development/src/pages/devices/ChannelMappingMatrix.js @@ -139,11 +139,200 @@ const TooltipDivider = withStyles({ }, })(Divider); -const InteractiveTooltipContext = createContext(); +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 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 InteractiveTooltip = ({ title, ...props }) => { + 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 MappingHeadTooltipContext = createContext(); + +const MappingHeadTooltip = ({ title, ...props }) => { const { tooltipModal, setTooltipModal } = useContext( - InteractiveTooltipContext + MappingHeadTooltipContext ); const [open, setOpen] = useState(false); @@ -179,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: { @@ -194,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`; @@ -245,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`; @@ -296,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 ( <> @@ -329,6 +526,7 @@ const MappedCellTooltip = ({ inputName, inputChannelIndex, inputChannelLabel, + constraintWarning, }) => ( <> {'Input'} @@ -345,6 +543,15 @@ const MappedCellTooltip = ({ {outputChannelLabel} {outputChannelIndex && ` (Channel ${outputChannelIndex})`} + {constraintWarning && ( + <> + + {'Expected Constraint Violation'} + + {constraintWarning} + + + )} ); @@ -430,7 +637,7 @@ const OutputSourceAssociation = ({ outputs, isExpanded, truncateValue }) => key={outputId} > {get(outputItem, 'source_id') ? ( - } placement="top" arrow @@ -448,9 +655,9 @@ const OutputSourceAssociation = ({ outputs, isExpanded, truncateValue }) => - + ) : ( - {'No Source'} } @@ -459,7 +666,7 @@ const OutputSourceAssociation = ({ outputs, isExpanded, truncateValue }) => PopperProps={popperPropsNearer} >
{truncateValue('No Source')}
-
+ )} )); @@ -494,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 @@ -514,7 +721,7 @@ const InputParentAssociation = ({ - + )} ); @@ -544,13 +751,14 @@ const InputChannelMappingCells = ({ mappingDisabled, handleMap, isMapped, + getConstraintWarning, truncateValue, }) => { const { getCustomName } = useCustomNamesContext(); return ( <> - - + <> {outputs.map(([outputId, outputItem]) => @@ -580,7 +788,7 @@ const InputChannelMappingCells = ({ Object.entries(outputItem.channels).map( ([outputChannelIndex, outputChannel]) => ( - } placement="bottom-start" @@ -633,9 +848,16 @@ const InputChannelMappingCells = ({ inputChannelIndex, outputChannelIndex )} + constraintWarning={getConstraintWarning( + inputId, + outputId, + inputChannelIndex, + outputChannelIndex, + outputItem + )} /> - + ) ) @@ -655,6 +877,7 @@ const UnroutedRow = ({ mappingDisabled, handleMap, isMapped, + getConstraintWarning, isOutputExpanded, }) => { const { getCustomName } = useCustomNamesContext(); @@ -666,7 +889,7 @@ const UnroutedRow = ({ Object.entries(outputItem.channels).map( ([outputChannelIndex, outputChannel]) => ( - } placement="bottom-start" @@ -709,9 +939,16 @@ const UnroutedRow = ({ null, outputChannelIndex )} + constraintWarning={getConstraintWarning( + null, + outputId, + null, + outputChannelIndex, + outputItem + )} /> - + ) ) @@ -746,7 +983,7 @@ const OutputsHeadRow = ({ rowSpan={isOutputExpanded(outputId) ? 1 : 2} key={outputId} > - - + onExpandOutput(outputId)} isExpanded={isOutputExpanded(outputId)} @@ -785,7 +1022,7 @@ const OutputsHeadRow = ({ ? Object.entries(outputItem.channels).map( ([channelIndex, channel]) => ( - - + ) ) @@ -828,6 +1065,7 @@ const InputsRows = ({ isShow, handleMap, isMapped, + getConstraintWarning, truncateValue, }) => { const { getCustomName } = useCustomNamesContext(); @@ -847,7 +1085,7 @@ const InputsRows = ({ } colSpan={isInputExpanded(inputId) ? 1 : 2} > - - + onExpandInput(inputId)} isExpanded={isInputExpanded(inputId)} @@ -894,6 +1132,7 @@ const InputsRows = ({ mappingDisabled={isShow} handleMap={handleMap} isMapped={isMapped} + getConstraintWarning={getConstraintWarning} truncateValue={truncateValue} /> ) : null} @@ -914,6 +1153,7 @@ const InputsRows = ({ mappingDisabled={isShow} handleMap={handleMap} isMapped={isMapped} + getConstraintWarning={getConstraintWarning} truncateValue={truncateValue} /> @@ -1014,6 +1254,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`); @@ -1097,7 +1355,7 @@ const ChannelMappingMatrix = ({ record, isShow, mapping, handleMap }) => { Clear Custom Names - @@ -1126,6 +1384,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 new file mode 100644 index 00000000..c7c4f3d3 --- /dev/null +++ b/Development/src/pages/devices/ChannelMappingMatrix.test.js @@ -0,0 +1,150 @@ +import { + channelMappingConstraintWarnings, + 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); + }); +}); + +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/); + }); +}); diff --git a/Development/src/pages/devices/DevicesEdit.js b/Development/src/pages/devices/DevicesEdit.js new file mode 100644 index 00000000..4bdd2704 --- /dev/null +++ b/Development/src/pages/devices/DevicesEdit.js @@ -0,0 +1,284 @@ +import React, { Fragment, useEffect, useMemo, useState } from 'react'; +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 { + 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, 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 ( + + + + ); +}; + +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 + // 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 }, + $activation: { + mode: activationMode, + requested_time: scheduled ? requestedTime : null, + }, + }, + previousData: record, + }); + notify( + scheduled + ? 'Channel Mapping activation scheduled' + : 'Channel Mapping activated', + 'info' + ); + refresh(); + // returning to Show unmounts this view, so leave `activating` set + history.push( + scheduled + ? `${props.basePath}/${props.id}/show/activations` + : `${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={} + > + +
+ { + 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 DevicesEdit = props => { + const controllerProps = useShowController(props); + return ( + + + + ); +}; + +export default DevicesEdit; diff --git a/Development/src/pages/devices/DevicesShow.js b/Development/src/pages/devices/DevicesShow.js index ba987590..0f32e802 100644 --- a/Development/src/pages/devices/DevicesShow.js +++ b/Development/src/pages/devices/DevicesShow.js @@ -11,12 +11,25 @@ import { SimpleShowLayout, SingleFieldList, TextField, + useNotify, useRecordContext, + useRefresh, useShowController, } from 'react-admin'; -import { Paper, Tab, Tabs, Typography } from '@material-ui/core'; +import { + Button, + Paper, + Tab, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Tabs, + Typography, +} from '@material-ui/core'; import { Link, Route } from 'react-router-dom'; -import get from 'lodash/get'; +import { get, isEmpty, map } from 'lodash'; import { useTheme } from '@material-ui/styles'; import LinkChipField from '../../components/LinkChipField'; import ObjectField from '../../components/ObjectField'; @@ -32,7 +45,9 @@ import SanitizedDivider from '../../components/SanitizedDivider'; import TAIField from '../../components/TAIField'; import UnsortableDatagrid from '../../components/UnsortableDatagrid'; import UrlField from '../../components/URLField'; +import { CancelScheduledActivationIcon } from '../../icons'; import labelize from '../../components/labelize'; +import dataProvider from '../../dataProvider'; import { buildIs12BrowserLaunchUrl, is12BrowserUrl, @@ -41,6 +56,12 @@ import { import MappingShowActions from '../../components/MappingShowActions'; import ChannelMappingMatrix from './ChannelMappingMatrix'; +// Channel Mapping tabs, and the Channel Mapping API data each one needs +const channelMappingTabs = { + active_map: '$io', + activations: '$activations', +}; + export const DevicesShow = props => { 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; 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 new file mode 100644 index 00000000..ea38e8bd --- /dev/null +++ b/Documents/channelmapping-edit-plan.md @@ -0,0 +1,164 @@ +# Design plan: Editable IS-08 Channel Mapping in nmos-js + +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 + +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 (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). +- 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 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`), + 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). +- 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. 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). + +## Soft validation (Connect-tab analogy) + +On the Connect tab, receiver caps filter / warn without always forbidding +connect. Same idea here: + +- 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 +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 | 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 + +- 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`) diff --git a/README.md b/README.md index a176ac59..762ba3fa 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 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