diff options
author | herbert <herbert.eiselt@highstreet-technologies.com> | 2019-12-14 01:05:47 +0100 |
---|---|---|
committer | Herbert Eiselt <herbert.eiselt@highstreet-technologies.com> | 2019-12-16 12:52:11 +0000 |
commit | e6d0d67fdbe3fc70c996c8df33bd65d3b151dfad (patch) | |
tree | 0d2da7d1da74c6ebca6b53039741617d35f65d96 /sdnr/wt/odlux/apps/configurationApp/src | |
parent | 6b98928b7b1b0ebc28d2ef286e8c932fca67c305 (diff) |
update odlux and featureaggregator
v2 update odlux and featureaggregator bundles
Issue-ID: SDNC-1008
Signed-off-by: herbert <herbert.eiselt@highstreet-technologies.com>
Change-Id: I0018d7bfa3a0e6896c1b210b539a574af9808e22
Signed-off-by: herbert <herbert.eiselt@highstreet-technologies.com>
Diffstat (limited to 'sdnr/wt/odlux/apps/configurationApp/src')
16 files changed, 2540 insertions, 0 deletions
diff --git a/sdnr/wt/odlux/apps/configurationApp/src/actions/deviceActions.ts b/sdnr/wt/odlux/apps/configurationApp/src/actions/deviceActions.ts new file mode 100644 index 000000000..fc0665325 --- /dev/null +++ b/sdnr/wt/odlux/apps/configurationApp/src/actions/deviceActions.ts @@ -0,0 +1,380 @@ +import { Action } from '../../../../framework/src/flux/action'; +import { Dispatch } from '../../../../framework/src/flux/store'; +import { IApplicationStoreState } from "../../../../framework/src/store/applicationStore"; +import { PushAction, ReplaceAction } from "../../../../framework/src/actions/navigationActions"; + +import { restService } from "../services/restServices"; +import { YangParser } from "../yang/yangParser"; +import { Module } from "../models/yang"; +import { ViewSpecification, ViewElement, isViewElementReference, isViewElementList, isViewElementObjectOrList } from "../models/uiModels"; +import { AddErrorInfoAction } from "../../../../framework/src/actions/errorActions"; + +export class EnableValueSelector extends Action { + constructor(public listSpecification: ViewSpecification, public listData: any[], public keyProperty: string, public onValueSelected : (value: any) => void ) { + super(); + } +} + +export class SetCollectingSelectionData extends Action { + constructor(public busy: boolean) { + super(); + } +} + +export class SetSelectedValue extends Action { + constructor(public value: any) { + super(); + } +} + +export class UpdateDeviceDescription extends Action { + constructor( public nodeId: string, public modules: { [name:string]: Module}, public views: ViewSpecification[]) { + super(); + } +} + +export class UpdatViewDescription extends Action { + constructor(public vPath: string, public view: ViewSpecification, public viewData: any, public displayAsList: boolean = false, public key?: string ) { + super(); + } +} + +export const updateNodeIdAsyncActionCreator = (nodeId: string) => async (dispatch: Dispatch, getState: () => IApplicationStoreState ) => { + const { configuration: { connectedNetworkElements : { rows }} } = getState(); + dispatch(new SetCollectingSelectionData(true)); + const networkElement = rows.find(r => r.nodeId === nodeId) || await restService.getMountedNetworkElementByMountId(nodeId); + if (!networkElement) { + console.error(new Error(`NetworkElement : [${nodeId}] does not exist.`)); + return dispatch(new UpdateDeviceDescription("", { }, [ ])); + } + + if (!networkElement.nodeDetails || !networkElement.nodeDetails.availableCapabilities) { + throw new Error(`NetworkElement : [${nodeId}] has no capabilities.`); + } + const parser = new YangParser(); + + const capParser = /^\(.*\?revision=(\d{4}-\d{2}-\d{2})\)(\S+)$/i; + for (let i = 0; i < networkElement.nodeDetails.availableCapabilities.length; ++i){ + const capRaw = networkElement.nodeDetails.availableCapabilities[i]; + const capMatch = capRaw && capParser.exec(capRaw); + try { + capMatch && await parser.addCapability(capMatch[2], capMatch[1]); + } catch (err) { + console.error(err); + } + } + + parser.postProcess(); + + console.log(parser.modules, parser.views) + + return dispatch(new UpdateDeviceDescription(nodeId, parser.modules, parser.views)); +} + +export const splitVPath = (vPath: string, vPathParser : RegExp): [string, string?][] => { + const pathParts: [string, string?][] = []; + let partMatch: RegExpExecArray | null; + if (vPath) do { + partMatch = vPathParser.exec(vPath); + if (partMatch) { + pathParts.push([partMatch[1], partMatch[2] || undefined]); + } + } while (partMatch) + return pathParts; +} + +const getReferencedDataList = async (refPath: string, dataPath: string, modules: { [name: string]: Module }, views: ViewSpecification[]) => { + const pathParts = splitVPath(refPath, /(?:(?:([^\/\:]+):)?([^\/]+))/g); // 1 = opt: namespace / 2 = property + let referencedModule = modules[pathParts[0][0]]; + + let dataMember: string; + let view: ViewSpecification; + let currentNS: string | null = null; + let dataUrls = [dataPath]; + let data: any; + + for (let i = 0; i < pathParts.length; ++i) { + const [pathPartNS, pathPart] = pathParts[i]; + const namespace = pathPartNS != null ? (currentNS = pathPartNS) : currentNS; + + const viewElement = i === 0 + ? views[0].elements[`${referencedModule.name}:${pathPart}`] + : view!.elements[`${pathPart}`] || view!.elements[`${namespace}:${pathPart}`]; + + if (!viewElement) throw new Error(`Could not find ${pathPart} in ${refPath}`); + if (i < pathParts.length - 1) { + if (!isViewElementObjectOrList(viewElement)) { + throw Error(`Module: [${referencedModule.name}].[${viewElement.label}]. Viewelement is not list or object.`); + } + view = views[+viewElement.viewId]; + const resultingDataUrls : string[] = []; + if (isViewElementList(viewElement)) { + for (let j = 0; j < dataUrls.length; ++j) { + const dataUrl = dataUrls[j]; + const restResult = (await restService.getConfigData(dataUrl)); + if (restResult.data == null || restResult.status < 200 || restResult.status > 299) { + const message = restResult.data && restResult.data.errors && restResult.data.errors.error && restResult.data.errors.error[0] && restResult.data.errors.error[0]["error-message"] || ""; + throw new Error(`Server Error. Status: [${restResult.status}]\n${message || restResult.message || ''}`); + } + + let dataRaw = restResult.data[dataMember!]; + dataRaw = dataRaw instanceof Array + ? dataRaw[0] + : dataRaw; + + data = dataRaw && dataRaw[viewElement.label] || []; + const keys: string[] = data.map((entry: { [key: string]: any } )=> entry[viewElement.key!]); + resultingDataUrls.push(...keys.map(key => `${dataUrl}/${viewElement.label.replace(/\//ig, "%2F")}/${key.replace(/\//ig, "%2F")}`)); + } + dataMember = viewElement.label; + } else { + // just a member, not a list + const pathSegment = (i === 0 + ? `/${referencedModule.name}:${viewElement.label.replace(/\//ig, "%2F")}` + : `/${viewElement.label.replace(/\//ig, "%2F")}`); + resultingDataUrls.push(...dataUrls.map(dataUrl => dataUrl + pathSegment)); + dataMember = viewElement.label; + } + dataUrls = resultingDataUrls; + } else { + data = []; + for (let j = 0; j < dataUrls.length; ++j) { + const dataUrl = dataUrls[j]; + const restResult = (await restService.getConfigData(dataUrl)); + if (restResult.data == null || restResult.status < 200 || restResult.status > 299) { + const message = restResult.data && restResult.data.errors && restResult.data.errors.error && restResult.data.errors.error[0] && restResult.data.errors.error[0]["error-message"] || ""; + throw new Error(`Server Error. Status: [${restResult.status}]\n${message || restResult.message || ''}`); + } + let dataRaw = restResult.data[dataMember!]; + dataRaw = dataRaw instanceof Array + ? dataRaw[0] + : dataRaw; + data.push(dataRaw); + } + // BUG UUID ist nicht in den elements enthalten !!!!!! + const key = viewElement && viewElement.label || pathPart; + return { + view: view!, + data: data, + key: key, + }; + } + } + return null; +} + +const resolveViewDescription = (defaultNS: string | null, vPath: string, view: ViewSpecification, viewData: any, displayAsList: boolean = false, key?: string): UpdatViewDescription =>{ + + // check if-feature | when | and resolve all references. + view = { ...view }; + view.elements = Object.keys(view.elements).reduce<{ [name: string]: ViewElement }>((acc, cur) => { + const elm = view.elements[cur]; + const key = defaultNS && cur.replace(new RegExp(`^${defaultNS}:`, "i"),"") || cur; + if (isViewElementReference(elm)) { + acc[key] = { ...(elm.ref(vPath) || elm), id: key }; + } else { + acc[key] = { ...elm, id: key }; + } + return acc; + }, {}); + return new UpdatViewDescription(vPath, view, viewData, displayAsList, key); +} + +export const updateViewActionAsyncCreator = (vPath: string) => async (dispatch: Dispatch, getState: () => IApplicationStoreState) => { + const pathParts = splitVPath(vPath, /(?:([^\/\["]+)(?:\[([^\]]*)\])?)/g); // 1 = property / 2 = optional key + const { configuration: { deviceDescription: { nodeId, modules, views } }, framework: { navigationState } } = getState(); + let dataPath = `/restconf/config/network-topology:network-topology/topology/topology-netconf/node/${nodeId}/yang-ext:mount`; + let viewSpecification: ViewSpecification = views[0]; + let viewElement: ViewElement; + + let dataMember: string; + let extractList: boolean = false; + + let currentNS : string | null = null; + let defaultNS : string | null = null; + + dispatch(new SetCollectingSelectionData(true)); + try { + for (let ind = 0; ind < pathParts.length; ++ind) { + const [property, key] = pathParts[ind]; + const namespaceInd = property && property.indexOf(":") || -1; + const namespace : string | null = namespaceInd > -1 ? (currentNS = property.slice(0, namespaceInd)) : currentNS; + + if (ind === 0) { defaultNS = namespace }; + + viewElement = viewSpecification.elements[property]; + if (!viewElement) throw Error("Property [" + property + "] does not exist."); + + if (viewElement.isList && !key) { + if (pathParts.length - 1 > ind) { + dispatch(new SetCollectingSelectionData(false)); + throw new Error("No key for list [" + property + "]"); + } else if (vPath.endsWith("[]") && pathParts.length - 1 === ind) { + // empty key is used for new element + if (viewElement && "viewId" in viewElement) viewSpecification = views[+viewElement.viewId]; + const data = Object.keys(viewSpecification.elements).reduce<{ [name: string]: any }>((acc, cur) => { + const elm = viewSpecification.elements[cur]; + if (elm.default) { + acc[elm.id] = elm.default || "" + } + return acc; + }, {}); + return dispatch(resolveViewDescription(defaultNS, vPath, viewSpecification, data, false, isViewElementList(viewElement!) && viewElement.key || undefined)); + } + if (viewElement && isViewElementList(viewElement) && viewSpecification.parentView === "0") { + // check if there is a reference as key + const listSpecification = views[+viewElement.viewId]; + const keyElement = viewElement.key && listSpecification.elements[viewElement.key]; + if (keyElement && isViewElementReference(keyElement)) { + const refList = await getReferencedDataList(keyElement.referencePath, dataPath, modules, views); + if (!refList) { + throw new Error(`Could not find refList for [${keyElement.referencePath}].`); + } + if (!refList.key) { + throw new Error(`Key property not found for [${keyElement.referencePath}].`); + } + dispatch(new EnableValueSelector(refList.view, refList.data, refList.key, (refKey) => { + window.setTimeout(() => dispatch(new PushAction(`${vPath}[${refKey.replace(/\//ig, "%2F")}]`))); + })); + } else { + dispatch(new SetCollectingSelectionData(false)); + throw new Error("Found a list at root level of a module w/o a refenrece key."); + } + return; + } + extractList = true; + } else { + dataPath += `/${property}${key ? `/${key.replace(/\//ig, "%2F")}` : ""}`; + dataMember = namespace === defaultNS + ? viewElement.label + : `${namespace}:${viewElement.label}`; + extractList = false; + } + + if (viewElement && "viewId" in viewElement) viewSpecification = views[+viewElement.viewId]; + } + + let data: any = {}; + if (viewSpecification && viewSpecification.id !== "0") { + const restResult = (await restService.getConfigData(dataPath)); + if (!restResult.data) { + // special case: if this is a list without any response + if (extractList && restResult.status === 404) { + if (!isViewElementList(viewElement!)) { + throw new Error(`vPath: [${vPath}]. ViewElement has no key.`); + } + return dispatch(resolveViewDescription(defaultNS, vPath, viewSpecification, [], extractList, viewElement.key)); + } + throw new Error(`Did not get response from Server. Status: [${restResult.status}]`); + } else if (restResult.status < 200 || restResult.status > 299) { + const message = restResult.data.errors && restResult.data.errors.error && restResult.data.errors.error[0] && restResult.data.errors.error[0]["error-message"] || ""; + throw new Error(`Server Error. Status: [${restResult.status}]\n${message}`); + } else { + data = restResult.data[dataMember!]; // extract dataMember + } + + // extract the first element list[key] + data = data instanceof Array + ? data[0] + : data; + + // extract the list -> key: list + data = extractList + ? data[viewElement!.label] || [] // if the list is empty, it does not exist + : data; + } + + return dispatch(resolveViewDescription(defaultNS, vPath, viewSpecification, data, extractList, isViewElementList(viewElement!) && viewElement.key || undefined)); + // https://beta.just-run.it/#/configuration/Sim12600/core-model:network-element/ltp[LTP-MWPS-TTP-01] + // https://beta.just-run.it/#/configuration/Sim12600/core-model:network-element/ltp[LTP-MWPS-TTP-01]/lp + } catch (error) { + history.back(); + dispatch(new AddErrorInfoAction({ title: "Problem", message: error.message || `Could not process ${dataPath}` })); + dispatch(new SetCollectingSelectionData(false)); + } finally { + return; + } +} + +export const updateDataActionAsyncCreator = (vPath: string, data: any) => async (dispatch: Dispatch, getState: () => IApplicationStoreState) => { + const pathParts = splitVPath(vPath, /(?:([^\/\["]+)(?:\[([^\]]*)\])?)/g); // 1 = property / 2 = optional key + const { configuration: { deviceDescription: { nodeId, views } } } = getState(); + let dataPath = `/restconf/config/network-topology:network-topology/topology/topology-netconf/node/${nodeId}/yang-ext:mount`; + let viewSpecification: ViewSpecification = views[0]; + let viewElement: ViewElement; + let dataMember: string; + let embedList: boolean = false; + let isNew: string | false = false; + + let currentNS: string | null = null; + let defaultNS: string | null = null; + + dispatch(new SetCollectingSelectionData(true)); + try { + for (let ind = 0; ind < pathParts.length; ++ind) { + let [property, key] = pathParts[ind]; + const namespaceInd = property && property.indexOf(":") || -1; + const namespace: string | null = namespaceInd > -1 ? (currentNS = property.slice(0, namespaceInd)) : currentNS; + + if (ind === 0) { defaultNS = namespace }; + viewElement = viewSpecification.elements[property]; + if (!viewElement) throw Error("Property [" + property + "] does not exist."); + + if (isViewElementList(viewElement) && !key) { + embedList = true; + if (viewElement && viewElement.isList && viewSpecification.parentView === "0") { + throw new Error("Found a list at root level of a module w/o a refenrece key."); + } + if (pathParts.length - 1 > ind) { + dispatch(new SetCollectingSelectionData(false)); + throw new Error("No key for list [" + property + "]"); + } else if (vPath.endsWith("[]") && pathParts.length - 1 === ind) { + // handle new element + key = viewElement.key && String(data[viewElement.key]) || ""; + isNew = key; + if (!key) { + dispatch(new SetCollectingSelectionData(false)); + throw new Error("No value for key [" + viewElement.key +"] in list [" + property + "]"); + } + } + } + + dataPath += `/${property}${key ? `/${key.replace(/\//ig, "%2F")}` : ""}`; + dataMember = viewElement.label; + embedList = false; + + if (viewElement && "viewId" in viewElement) { + viewSpecification = views[+viewElement.viewId]; + } + } + + // embed the list -> key: list + data = embedList + ? { [viewElement!.label]: data } + : data; + + // embed the first element list[key] + data = isNew + ? [data] + : data; + + // do not extract root member (0) + if (viewSpecification && viewSpecification.id !== "0") { + const updateResult = await restService.setConfigData(dataPath, { [dataMember!]: data }); // extractDataMember + if (updateResult.status < 200 || updateResult.status > 299) { + const message = updateResult.data && updateResult.data.errors && updateResult.data.errors.error && updateResult.data.errors.error[0] && updateResult.data.errors.error[0]["error-message"] || ""; + throw new Error(`Server Error. Status: [${updateResult.status}]\n${message || updateResult.message || ''}`); + } + } + + return isNew + ? dispatch(new ReplaceAction(`/configuration/${nodeId}/${vPath.replace(/\[\]$/i,`[${isNew}]`)}`)) // navigate to new element + : dispatch(resolveViewDescription(defaultNS, vPath, viewSpecification, data, embedList, isViewElementList(viewElement!) && viewElement.key || undefined)); + } catch (error) { + history.back(); + dispatch(new AddErrorInfoAction({ title: "Problem", message: error.message || `Could not change ${dataPath}` })); + dispatch(new SetCollectingSelectionData(false)); + } finally { + return; + } +}
\ No newline at end of file diff --git a/sdnr/wt/odlux/apps/configurationApp/src/handlers/configurationAppRootHandler.ts b/sdnr/wt/odlux/apps/configurationApp/src/handlers/configurationAppRootHandler.ts new file mode 100644 index 000000000..04b63d39b --- /dev/null +++ b/sdnr/wt/odlux/apps/configurationApp/src/handlers/configurationAppRootHandler.ts @@ -0,0 +1,29 @@ +import { combineActionHandler } from '../../../../framework/src/flux/middleware'; + +import { IConnectedNetworkElementsState, connectedNetworkElementsActionHandler } from './connectedNetworkElementsHandler'; +import { IDeviceDescriptionState, deviceDescriptionHandler } from "./deviceDescriptionHandler"; +import { IViewDescriptionState, viewDescriptionHandler } from "./viewDescriptionHandler"; +import { IValueSelectorState, valueSelectorHandler } from "./valueSelectorHandler"; + +interface IConfigurationAppStoreState { + connectedNetworkElements: IConnectedNetworkElementsState; // used for ne selection + deviceDescription: IDeviceDescriptionState; // contains ui and device descriptions + viewDescription: IViewDescriptionState; // contains current ui description + valueSelector: IValueSelectorState; +} + +declare module '../../../../framework/src/store/applicationStore' { + interface IApplicationStoreState { + configuration: IConfigurationAppStoreState, + } +} + +const actionHandlers = { + connectedNetworkElements: connectedNetworkElementsActionHandler, + deviceDescription: deviceDescriptionHandler, + viewDescription: viewDescriptionHandler, + valueSelector: valueSelectorHandler, +}; + +export const configurationAppRootHandler = combineActionHandler<IConfigurationAppStoreState>(actionHandlers); +export default configurationAppRootHandler; diff --git a/sdnr/wt/odlux/apps/configurationApp/src/handlers/connectedNetworkElementsHandler.ts b/sdnr/wt/odlux/apps/configurationApp/src/handlers/connectedNetworkElementsHandler.ts new file mode 100644 index 000000000..6a68242eb --- /dev/null +++ b/sdnr/wt/odlux/apps/configurationApp/src/handlers/connectedNetworkElementsHandler.ts @@ -0,0 +1,19 @@ +import { createExternal, IExternalTableState } from '../../../../framework/src/components/material-table/utilities'; +import { createSearchDataHandler } from '../../../../framework/src/utilities/elasticSearch'; + +import { NetworkElementConnection } from '../models/networkElementConnection'; + +export interface IConnectedNetworkElementsState extends IExternalTableState<NetworkElementConnection> { } + +// create eleactic search material data fetch handler +const connectedNetworkElementsSearchHandler = createSearchDataHandler<NetworkElementConnection>('network-element-connection', { status: "Connected" }); + +export const { + actionHandler: connectedNetworkElementsActionHandler, + createActions: createConnectedNetworkElementsActions, + createProperties: createConnectedNetworkElementsProperties, + reloadAction: connectedNetworkElementsReloadAction, + + // set value action, to change a value +} = createExternal<NetworkElementConnection>(connectedNetworkElementsSearchHandler, appState => appState.configuration.connectedNetworkElements); +
\ No newline at end of file diff --git a/sdnr/wt/odlux/apps/configurationApp/src/handlers/deviceDescriptionHandler.ts b/sdnr/wt/odlux/apps/configurationApp/src/handlers/deviceDescriptionHandler.ts new file mode 100644 index 000000000..3cc27aa95 --- /dev/null +++ b/sdnr/wt/odlux/apps/configurationApp/src/handlers/deviceDescriptionHandler.ts @@ -0,0 +1,30 @@ +import { Module } from "../models/yang"; +import { ViewSpecification } from "../models/uiModels"; +import { IActionHandler } from "../../../../framework/src/flux/action"; +import { UpdateDeviceDescription } from "../actions/deviceActions"; + +export interface IDeviceDescriptionState { + nodeId: string, + modules: { + [name: string]: Module + }, + views: ViewSpecification[], +} + +const deviceDescriptionStateInit: IDeviceDescriptionState = { + nodeId: "", + modules: {}, + views: [] +}; + +export const deviceDescriptionHandler: IActionHandler<IDeviceDescriptionState> = (state = deviceDescriptionStateInit, action) => { + if (action instanceof UpdateDeviceDescription) { + state = { + ...state, + nodeId: action.nodeId, + modules: action.modules, + views: action.views + }; + } + return state; +}; diff --git a/sdnr/wt/odlux/apps/configurationApp/src/handlers/valueSelectorHandler.ts b/sdnr/wt/odlux/apps/configurationApp/src/handlers/valueSelectorHandler.ts new file mode 100644 index 000000000..c9e7dd2cc --- /dev/null +++ b/sdnr/wt/odlux/apps/configurationApp/src/handlers/valueSelectorHandler.ts @@ -0,0 +1,58 @@ +import { IActionHandler } from "../../../../framework/src/flux/action"; +import { ViewSpecification } from "../models/uiModels"; +import { EnableValueSelector, SetSelectedValue, UpdateDeviceDescription, SetCollectingSelectionData, UpdatViewDescription } from "../actions/deviceActions"; + +export interface IValueSelectorState { + collectingData: boolean; + keyProperty: string | undefined; + listSpecification: ViewSpecification | null; + listData: any[]; + onValueSelected: (value: any) => void; +} + +const nc = (val: React.SyntheticEvent) => { }; +const valueSelectorStateInit: IValueSelectorState = { + collectingData: false, + keyProperty: undefined, + listSpecification: null, + listData: [], + onValueSelected: nc, +}; + +export const valueSelectorHandler: IActionHandler<IValueSelectorState> = (state = valueSelectorStateInit, action) => { + if (action instanceof SetCollectingSelectionData) { + state = { + ...state, + collectingData: action.busy, + }; + } else if (action instanceof EnableValueSelector) { + state = { + ...state, + collectingData: false, + keyProperty: action.keyProperty, + listSpecification: action.listSpecification, + onValueSelected: action.onValueSelected, + listData: action.listData, + }; + } else if (action instanceof SetSelectedValue) { + state.keyProperty && state.onValueSelected(action.value[state.keyProperty]); + state = { + ...state, + collectingData: false, + keyProperty: undefined, + listSpecification: null, + onValueSelected: nc, + listData: [], + }; + } else if (action instanceof UpdateDeviceDescription || action instanceof UpdatViewDescription) { + state = { + ...state, + collectingData: false, + keyProperty: undefined, + listSpecification: null, + onValueSelected: nc, + listData: [], + }; + } + return state; +}; diff --git a/sdnr/wt/odlux/apps/configurationApp/src/handlers/viewDescriptionHandler.ts b/sdnr/wt/odlux/apps/configurationApp/src/handlers/viewDescriptionHandler.ts new file mode 100644 index 000000000..48155ee1e --- /dev/null +++ b/sdnr/wt/odlux/apps/configurationApp/src/handlers/viewDescriptionHandler.ts @@ -0,0 +1,42 @@ +import { IActionHandler } from "../../../../framework/src/flux/action"; + +import { UpdatViewDescription } from "../actions/deviceActions"; +import { ViewSpecification } from "../models/uiModels"; + +export interface IViewDescriptionState { + vPath: string | null; + keyProperty: string | undefined; + displayAsList: boolean; + viewSpecification: ViewSpecification; + viewData: any +} + +const viewDescriptionStateInit: IViewDescriptionState = { + vPath: null, + keyProperty: undefined, + displayAsList: false, + viewSpecification: { + id: "empty", + canEdit: false, + parentView: "", + name: "emplty", + language: "en-US", + title: "empty", + elements: {} + }, + viewData: null +}; + +export const viewDescriptionHandler: IActionHandler<IViewDescriptionState> = (state = viewDescriptionStateInit, action) => { + if (action instanceof UpdatViewDescription) { + state = { + ...state, + vPath: action.vPath, + keyProperty: action.key, + displayAsList: action.displayAsList, + viewSpecification: action.view, + viewData: action.viewData, + } + } + return state; +}; diff --git a/sdnr/wt/odlux/apps/configurationApp/src/index.html b/sdnr/wt/odlux/apps/configurationApp/src/index.html new file mode 100644 index 000000000..759b7b535 --- /dev/null +++ b/sdnr/wt/odlux/apps/configurationApp/src/index.html @@ -0,0 +1,27 @@ +<!DOCTYPE html> +<html lang="en"> + +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <meta http-equiv="X-UA-Compatible" content="ie=edge"> + <!-- <link rel="stylesheet" href="./vendor.css"> --> + <title>Configuration App</title> +</head> + +<body> + <div id="app"></div> + <script type="text/javascript" src="./require.js"></script> + <script type="text/javascript" src="./config.js"></script> + <script> + // run the application + require(["app", "connectApp", "maintenanceApp", "configurationApp"], function (app, connectApp, maintenanceApp, configurationApp) { + connectApp.register(); + configurationApp.register(); + maintenanceApp.register(); + app("./app.tsx").runApplication(); + }); + </script> +</body> + +</html>
\ No newline at end of file diff --git a/sdnr/wt/odlux/apps/configurationApp/src/models/networkElementConnection.ts b/sdnr/wt/odlux/apps/configurationApp/src/models/networkElementConnection.ts new file mode 100644 index 000000000..2575500a3 --- /dev/null +++ b/sdnr/wt/odlux/apps/configurationApp/src/models/networkElementConnection.ts @@ -0,0 +1,19 @@ +export type NetworkElementConnection = { + id?: string; + nodeId: string; + host: string; + port: number; + username?: string; + password?: string; + isRequired?: boolean; + status?: "connected" | "mounted" | "unmounted" | "connecting" | "disconnected" | "idle"; + coreModelCapability?: string; + deviceType?: string; + nodeDetails?: { + availableCapabilities: string[]; + unavailableCapabilities: { + failureReason: string; + capability: string; + }[]; + } +} diff --git a/sdnr/wt/odlux/apps/configurationApp/src/models/uiModels.ts b/sdnr/wt/odlux/apps/configurationApp/src/models/uiModels.ts new file mode 100644 index 000000000..441d1281d --- /dev/null +++ b/sdnr/wt/odlux/apps/configurationApp/src/models/uiModels.ts @@ -0,0 +1,142 @@ +export type ViewElementBase = { + "id": string; + "label": string; + "config": boolean; + "ifFeature"?: string; + "when"?: string; + "mandatory"?: boolean; + "description"?: string; + "isList"?: boolean; + "default"?: string; + "status"?: "current" | "deprecated" | "obsolete", + "reference"?: string, // https://tools.ietf.org/html/rfc7950#section-7.21.4 +} + +// https://tools.ietf.org/html/rfc7950#section-9.8 +export type ViewElementBinary = ViewElementBase & { + "uiType": "binary"; + "length"?: number; // number of octets +} + +// https://tools.ietf.org/html/rfc7950#section-9.7.4 +export type ViewElementBits = ViewElementBase & { + "uiType": "bits"; + "flags": { + [name: string]: number | undefined; // 0 - 4294967295 + } +} + +// https://tools.ietf.org/html/rfc7950#section-9 +export type ViewElementString = ViewElementBase & { + "uiType": "string"; + "pattern"?: string[]; + "length"?: string; + "invertMatch"?: true; +} + +// https://tools.ietf.org/html/rfc7950#section-9.3 +export type ViewElementNumber = ViewElementBase & { + "uiType": "number"; + "min"?: number; + "max"?: number; + "units"?: string; + "format"?: string; + "fDigits"?: number; +} + +// https://tools.ietf.org/html/rfc7950#section-9.5 +export type ViewElementBoolean = ViewElementBase & { + "uiType": "boolean"; + "trueValue"?: string; + "falseValue"?: string; +} + +// https://tools.ietf.org/html/rfc7950#section-9.6.4 +export type ViewElementSelection = ViewElementBase & { + "uiType": "selection"; + "multiSelect"?: boolean + "options": { + "key": string; + "value": string; + "description"?: string, + "status"?: "current" | "deprecated" | "obsolete", + "reference"?: string, + }[]; +} + +// is a list if isList is true ;-) +export type ViewElementObject = ViewElementBase & { + "uiType": "object"; + "isList"?: false; + "viewId": string; +} + +// Hint: read only lists do not need a key +export type ViewElementList = (ViewElementBase & { + "uiType": "object"; + "isList": true; + "viewId": string; + "key"?: string; +}); + +export type ViewElementReference = ViewElementBase & { + "uiType": "reference"; + "referencePath": string; + "ref": (currentPath: string) => ViewElement | null; +} + +export type ViewElement = + | ViewElementBits + | ViewElementBinary + | ViewElementString + | ViewElementNumber + | ViewElementBoolean + | ViewElementObject + | ViewElementList + | ViewElementSelection + | ViewElementReference; + +export const isViewElementString = (viewElement: ViewElement): viewElement is ViewElementString => { + return viewElement && viewElement.uiType === "string"; +} + +export const isViewElementNumber = (viewElement: ViewElement): viewElement is ViewElementNumber => { + return viewElement && viewElement.uiType === "number" ; +} + +export const isViewElementBoolean = (viewElement: ViewElement): viewElement is ViewElementBoolean => { + return viewElement && viewElement.uiType === "boolean"; +} + +export const isViewElementObject = (viewElement: ViewElement): viewElement is ViewElementObject => { + return viewElement && viewElement.uiType === "object" && viewElement.isList === false; +} + +export const isViewElementList = (viewElement: ViewElement): viewElement is ViewElementList => { + return viewElement && viewElement.uiType === "object" && viewElement.isList === true; +} + +export const isViewElementObjectOrList = (viewElement: ViewElement): viewElement is ViewElementObject | ViewElementList => { + return viewElement && viewElement.uiType === "object"; +} + +export const isViewElementSelection = (viewElement: ViewElement): viewElement is ViewElementSelection => { + return viewElement && viewElement.uiType === "selection"; +} + +export const isViewElementReference = (viewElement: ViewElement): viewElement is ViewElementReference => { + return viewElement && viewElement.uiType === "reference"; +} + +export type ViewSpecification = { + "id": string; + "name": string; + "title"?: string; + "parentView"?: string; + "language": string; + "ifFeature"?: string; + "when"?: string; + "uses"?: string[]; + "elements": { [name: string]: ViewElement }; + readonly "canEdit": boolean; +} diff --git a/sdnr/wt/odlux/apps/configurationApp/src/models/yang.ts b/sdnr/wt/odlux/apps/configurationApp/src/models/yang.ts new file mode 100644 index 000000000..57edf803f --- /dev/null +++ b/sdnr/wt/odlux/apps/configurationApp/src/models/yang.ts @@ -0,0 +1,44 @@ +import { ViewElement, ViewSpecification } from "./uiModels"; + +export type Token = { + name: string; + value: string; + start: number; + end: number; +} + +export type Statement = { + key: string; + arg?: string; + sub?: Statement[]; +} + +export type Identity = { + id: string, + label: string, + base?: string, + description?: string, + reference?: string, + children?: Identity[], + values?: Identity[], +} + +export type Revision = { + description?: string, + reference?: string +}; + +export type Module = { + name: string; + namespace?: string; + prefix?: string; + identities: { [name: string]: Identity }; + revisions: { [version: string]: Revision } ; + imports: { [prefix: string]: string }; + features: { [feature: string]: { description?: string } }; + typedefs: { [type: string]: ViewElement }; + augments: { [path: string]: ViewSpecification[] }; + groupings: { [group: string]: ViewSpecification }; + views: { [view: string]: ViewSpecification }; + elements: { [view: string]: ViewElement }; +}
\ No newline at end of file diff --git a/sdnr/wt/odlux/apps/configurationApp/src/pluginConfiguration.tsx b/sdnr/wt/odlux/apps/configurationApp/src/pluginConfiguration.tsx new file mode 100644 index 000000000..7fd3a97f9 --- /dev/null +++ b/sdnr/wt/odlux/apps/configurationApp/src/pluginConfiguration.tsx @@ -0,0 +1,77 @@ +import * as React from "react"; +import { withRouter, RouteComponentProps, Route, Switch, Redirect } from 'react-router-dom'; + +import { faAdjust } from '@fortawesome/free-solid-svg-icons'; // select app icon + +import connect, { Connect, IDispatcher } from '../../../framework/src/flux/connect'; +import applicationManager from '../../../framework/src/services/applicationManager'; +import { IApplicationStoreState } from "../../../framework/src/store/applicationStore"; +import { configurationAppRootHandler } from "./handlers/configurationAppRootHandler"; +import { NetworkElementSelector } from "./views/networkElementSelector"; + +import ConfigurationApplication from "./views/configurationApplication"; +import { updateNodeIdAsyncActionCreator, updateViewActionAsyncCreator } from "./actions/deviceActions"; + +let currentNodeId: string | null | undefined = undefined; +let currentVirtualPath: string | null | undefined = undefined; +let lastUrl: string | undefined = undefined; + +const mapProps = (state: IApplicationStoreState) => ({ + // currentProblemsProperties: createCurrentProblemsProperties(state), +}); + +const mapDisp = (dispatcher: IDispatcher) => ({ + updateNodeId: (nodeId: string) => dispatcher.dispatch(updateNodeIdAsyncActionCreator(nodeId)), + updateView: (vPath: string) => dispatcher.dispatch(updateViewActionAsyncCreator(vPath)), +}); + +const ConfigurationApplicationRouteAdapter = connect(mapProps, mapDisp)((props: RouteComponentProps<{ nodeId?: string, 0: string }> & Connect<typeof mapProps, typeof mapDisp>) => { + React.useEffect(() => { + return () => { + lastUrl = undefined; + currentNodeId = undefined; + currentVirtualPath = undefined; + } + },[]); + if (props.location.pathname !== lastUrl) { + // ensure the asynchronus update will only be called once per path + lastUrl = props.location.pathname; + window.setTimeout(async () => { + + // check if the nodeId has changed + if (currentNodeId !== props.match.params.nodeId) { + currentNodeId = props.match.params.nodeId || undefined; + currentVirtualPath = null; + currentNodeId && await props.updateNodeId(currentNodeId); + } + + if (currentVirtualPath !== props.match.params[0]) { + currentVirtualPath = props.match.params[0]; + await props.updateView(currentVirtualPath); + } + + }); + } + return ( + <ConfigurationApplication /> + ); +}); + +const App = withRouter((props: RouteComponentProps) => ( + <Switch> + <Route path={`${props.match.url}/:nodeId/*`} component={ ConfigurationApplicationRouteAdapter } /> + <Route path={`${props.match.url}/:nodeId`} component={ ConfigurationApplicationRouteAdapter } /> + <Route path={`${props.match.url}`} component={ NetworkElementSelector } /> + <Redirect to={`${props.match.url}`} /> + </Switch> +)); + +export function register() { + applicationManager.registerApplication({ + name: "configuration", + icon: faAdjust, + rootComponent: App, + rootActionHandler: configurationAppRootHandler, + menuEntry: "Configuration" + }); +} diff --git a/sdnr/wt/odlux/apps/configurationApp/src/services/restServices.ts b/sdnr/wt/odlux/apps/configurationApp/src/services/restServices.ts new file mode 100644 index 000000000..061be05ec --- /dev/null +++ b/sdnr/wt/odlux/apps/configurationApp/src/services/restServices.ts @@ -0,0 +1,38 @@ +import { requestRest, requestRestExt } from "../../../../framework/src/services/restService"; +import { convertPropertyNames, replaceHyphen } from "../../../../framework/src/utilities/yangHelper"; + +import { NetworkElementConnection } from "../models/networkElementConnection"; + +class RestService { + public async getMountedNetworkElementByMountId(nodeId: string): Promise<NetworkElementConnection | null> { + // const path = 'restconf/operational/network-topology:network-topology/topology/topology-netconf/node/' + nodeId; + // const connectedNetworkElement = await requestRest<NetworkElementConnection>(path, { method: "GET" }); + // return connectedNetworkElement || null; + + const path = "/restconf/operations/data-provider:read-network-element-connection-list"; + const body = { "input": { "filter": [{ "property": "node-id", "filtervalue": nodeId }], "sortorder": [], "pagination": { "size": 1, "page": 1 } } }; + const networkElementResult = await requestRest<{ output: { data: NetworkElementConnection[] } }>(path, { method: "POST", body: JSON.stringify(body) }); + return networkElementResult && networkElementResult.output && networkElementResult.output.data && + networkElementResult.output.data.map(obj => convertPropertyNames(obj, replaceHyphen))[0] || null; + } + + /** Reads the config data by restconf path. + * @param path The restconf path to be used for read. + * @returns The data. + */ + public getConfigData(path: string) { + return requestRestExt<{ [key: string]: any }>(path, { method: "GET" }); + } + + /** Updates or creates the config data by restconf path using data. + * @param path The restconf path to identify the note to update. + * @param data The data to be updated. + * @returns The written data. + */ + public setConfigData(path: string, data: any) { + return requestRestExt<{ [key: string]: any }>(path, { method: "PUT", body: JSON.stringify(data) }); + } + } + +export const restService = new RestService(); +export default restService;
\ No newline at end of file diff --git a/sdnr/wt/odlux/apps/configurationApp/src/services/yangService.ts b/sdnr/wt/odlux/apps/configurationApp/src/services/yangService.ts new file mode 100644 index 000000000..17a4e43a7 --- /dev/null +++ b/sdnr/wt/odlux/apps/configurationApp/src/services/yangService.ts @@ -0,0 +1,25 @@ +type YangInfo = [string, (string | null | undefined)]; + +const cache: { [path: string]: string } = { + +}; + +class YangService { + + public async getCapability(capability: string, version?: string) { + const url = `/yang-schema/${capability}${version ? `/${version}` : ""}`; + + const cacheHit = cache[url]; + if (cacheHit) return cacheHit; + + const res = await fetch(url); + const yangFile = res.ok && await res.text(); + if (yangFile !== false && yangFile !== null) { + cache[url] = yangFile; + } + return yangFile; + } +} + +export const yangService = new YangService(); +export default yangService;
\ No newline at end of file diff --git a/sdnr/wt/odlux/apps/configurationApp/src/views/configurationApplication.tsx b/sdnr/wt/odlux/apps/configurationApp/src/views/configurationApplication.tsx new file mode 100644 index 000000000..24a4af8b2 --- /dev/null +++ b/sdnr/wt/odlux/apps/configurationApp/src/views/configurationApplication.tsx @@ -0,0 +1,464 @@ +/** + * ============LICENSE_START======================================================================== + * ONAP : ccsdk feature sdnr wt odlux + * ================================================================================================= + * Copyright (C) 2019 highstreet technologies GmbH Intellectual Property. All rights reserved. + * ================================================================================================= + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + * ============LICENSE_END========================================================================== + */ + +import * as React from 'react'; +import { RouteComponentProps, withRouter } from 'react-router-dom'; + +import { WithStyles, withStyles, createStyles, Theme } from '@material-ui/core/styles'; + +import connect, { IDispatcher, Connect } from "../../../../framework/src/flux/connect"; +import { IApplicationStoreState } from "../../../../framework/src/store/applicationStore"; +import MaterialTable, { ColumnModel, ColumnType, MaterialTableCtorType } from "../../../../framework/src/components/material-table"; +import { Loader } from "../../../../framework/src/components/material-ui/loader"; + +import { SetSelectedValue, splitVPath, updateDataActionAsyncCreator } from "../actions/deviceActions"; +import { ViewSpecification, isViewElementString, isViewElementNumber, isViewElementBoolean, isViewElementObjectOrList, isViewElementSelection } from "../models/uiModels"; + +import Fab from '@material-ui/core/Fab'; +import AddIcon from '@material-ui/icons/Add'; +import RemoveIcon from '@material-ui/icons/RemoveCircleOutline'; +import SaveIcon from '@material-ui/icons/Save'; +import EditIcon from '@material-ui/icons/Edit'; +import Tooltip from "@material-ui/core/Tooltip"; +import TextField from "@material-ui/core/TextField"; +import FormControl from "@material-ui/core/FormControl"; +import IconButton from "@material-ui/core/IconButton"; +import Button from "@material-ui/core/Button"; +import InputAdornment from "@material-ui/core/InputAdornment"; +import InputLabel from "@material-ui/core/InputLabel"; +import Select from "@material-ui/core/Select"; +import MenuItem from "@material-ui/core/MenuItem"; +import Breadcrumbs from "@material-ui/core/Breadcrumbs"; +import Link from "@material-ui/core/Link"; +import FormHelperText from '@material-ui/core/FormHelperText'; + +const styles = (theme: Theme) => createStyles({ + header: { + "display": "flex", + "justifyContent": "space-between", + }, + leftButton: { + "justifyContent": "left" + }, + outer: { + "flex": "1", + "heigh": "100%", + "display": "flex", + "alignItems": "center", + "justifyContent": "center", + }, + inner: { + + }, + "icon": { + "marginRight": theme.spacing(0.5), + "width": 20, + "height": 20, + }, + "fab": { + "margin": theme.spacing(1), + }, + button: { + margin: 0, + padding: "6px 6px", + minWidth: 'unset' + }, + readOnly: { + '& label.Mui-focused': { + color: 'green', + }, + '& .MuiInput-underline:after': { + borderBottomColor: 'green', + }, + '& .MuiOutlinedInput-root': { + '& fieldset': { + borderColor: 'red', + }, + '&:hover fieldset': { + borderColor: 'yellow', + }, + '&.Mui-focused fieldset': { + borderColor: 'green', + }, + }, + }, +}); + +const mapProps = (state: IApplicationStoreState) => ({ + collectingData: state.configuration.valueSelector.collectingData, + listKeyProperty: state.configuration.valueSelector.keyProperty, + listSpecification: state.configuration.valueSelector.listSpecification, + listData: state.configuration.valueSelector.listData, + vPath: state.configuration.viewDescription.vPath, + nodeId: state.configuration.deviceDescription.nodeId, + viewData: state.configuration.viewDescription.viewData, + viewSpecification: state.configuration.viewDescription.viewSpecification, + displayAsList: state.configuration.viewDescription.displayAsList, + keyProperty: state.configuration.viewDescription.keyProperty, +}); + +const mapDispatch = (dispatcher: IDispatcher) => ({ + onValueSelected: (value: any) => dispatcher.dispatch(new SetSelectedValue(value)), + onUpdateData: (vPath: string, data: any) => dispatcher.dispatch(updateDataActionAsyncCreator(vPath, data)), +}); + +const SelectElementTable = MaterialTable as MaterialTableCtorType<{ [key: string]: any }>; + +type ConfigurationApplicationComponentProps = RouteComponentProps & Connect<typeof mapProps, typeof mapDispatch> & WithStyles<typeof styles>; + +type ConfigurationApplicationComponentState = { + isNew: boolean; + editMode: boolean; + canEdit: boolean; + viewData: { [key: string]: any } | null; +} + +const OldProps = Symbol("OldProps"); +class ConfigurationApplicationComponent extends React.Component<ConfigurationApplicationComponentProps, ConfigurationApplicationComponentState> { + + /** + * + */ + constructor (props: ConfigurationApplicationComponentProps) { + super(props); + + this.state = { + isNew: false, + canEdit: false, + editMode: false, + viewData: null + } + } + + static getDerivedStateFromProps(nextProps: ConfigurationApplicationComponentProps, prevState: ConfigurationApplicationComponentState & { [OldProps]: ConfigurationApplicationComponentProps }) { + + if (!prevState || !prevState[OldProps] || (prevState[OldProps].viewData !== nextProps.viewData)) { + const isNew: boolean = nextProps.vPath?.endsWith("[]") || false; + const state = { + ...prevState, + isNew: isNew, + editMode: isNew, + viewData: nextProps.viewData || null, + [OldProps]: nextProps, + } + return state; + } + return null; + } + + private navigate = (path: string) => { + this.props.history.push(`${this.props.match.url}${path}`); + } + + private changeValueFor = (property: string, value: any) => { + this.setState({ + viewData: { + ...this.state.viewData, + [property]: value + } + }); + } + + private renderUIElement = (viewSpecification: ViewSpecification, viewData: { [key: string]: any }, keyProperty: string | undefined, editMode: boolean, isNew: boolean) => { + const elements = viewSpecification.elements; + return ( + Object.keys(elements).sort((a, b) => { + const vsA = elements[a]; + const vsB = elements[b]; + if (keyProperty) { + // if (vsA.label === vsB.label) return 0; + if (vsA.label === keyProperty) return -1; + if (vsB.label === keyProperty) return +1; + } + + if (vsA.uiType === vsB.uiType) return 0; + if (vsA.uiType !== "object" && vsB.uiType !== "object") return 0; + if (vsA.uiType === "object") return +1; + return -1; + }).map(key => { + const uiElement = elements[key]; + const isKey = (uiElement.label === keyProperty); + const canEdit = editMode && (isNew || (uiElement.config && !isKey)); + if (isViewElementSelection(uiElement)) { + let error = "" + const value = String(viewData[uiElement.id]).toLowerCase(); + if (uiElement.mandatory && !!value) { + error = "Error"; + } + return (canEdit || viewData[uiElement.id] != null + ? (<FormControl key={uiElement.id} style={{ width: 485, marginLeft: 20, marginRight: 20 }}> + <InputLabel htmlFor={`select-${uiElement.id}`} >{uiElement.label}</InputLabel> + <Select + required={!!uiElement.mandatory} + error={!!error} + onChange={(e) => { this.changeValueFor(uiElement.id, e.target.value) }} + readOnly={!canEdit} + disabled={editMode && !canEdit} + value={(viewData[uiElement.id] || '').toString().toLowerCase()} + inputProps={{ + name: uiElement.id, + id: `select-${uiElement.id}`, + }} + > + {uiElement.options.map(option => (<MenuItem key={option.key} title={option.description} value={option.value}>{option.key}</MenuItem>))} + </Select> + <FormHelperText>{error}</FormHelperText> + </FormControl>) + : null + ); + } else if (isViewElementBoolean(uiElement)) { + let error = "" + const value = String(viewData[uiElement.id]).toLowerCase(); + if (uiElement.mandatory && value !== "true" && value !== "false") { + error = "Error"; + } + return (canEdit || viewData[uiElement.id] != null + ? (<FormControl key={uiElement.id} style={{ width: 485, marginLeft: 20, marginRight: 20 }}> + <InputLabel htmlFor={`select-${uiElement.id}`} >{uiElement.label}</InputLabel> + <Select + required={!!uiElement.mandatory} + error={!!error} + onChange={(e) => { this.changeValueFor(uiElement.id, e.target.value) }} + readOnly={!canEdit} + disabled={editMode && !canEdit} + value={value} + inputProps={{ + name: uiElement.id, + id: `select-${uiElement.id}`, + }} + > + <MenuItem value={'true'}>{uiElement.trueValue || 'True'}</MenuItem> + <MenuItem value={'false'}>{uiElement.falseValue || 'False'}</MenuItem> + + </Select> + <FormHelperText>{error}</FormHelperText> + </FormControl>) + : null + ); + } else if (isViewElementString(uiElement)) { + return ( + <Tooltip key={uiElement.id} title={uiElement.description || ''}> + <TextField InputProps={{ readOnly: !canEdit, disabled: editMode && !canEdit }} spellCheck={false} autoFocus margin="dense" + id={uiElement.id} label={isKey ? "🔑 " + uiElement.label : uiElement.label} type="text" value={viewData[uiElement.id] || ''} + style={{ width: 485, marginLeft: 20, marginRight: 20 }} + onChange={(e) => { this.changeValueFor(uiElement.id, e.target.value) }} + /> + </Tooltip> + ); + } else if (isViewElementNumber(uiElement)) { + return ( + <Tooltip key={uiElement.id} title={uiElement.description || ''}> + <TextField InputProps={{ readOnly: !canEdit, disabled: editMode && !canEdit, startAdornment: uiElement.units != null ? <InputAdornment position="start">{uiElement.units}</InputAdornment> : undefined }} spellCheck={false} autoFocus margin="dense" + id={uiElement.id} label={uiElement.label} type="text" value={viewData[uiElement.id] == null ? '' : viewData[uiElement.id]} + style={{ width: 485, marginLeft: 20, marginRight: 20 }} + onChange={(e) => { this.changeValueFor(uiElement.id, e.target.value) }} + /> + </Tooltip> + ); + } else if (isViewElementObjectOrList(uiElement)) { + return ( + <FormControl key={uiElement.id} style={{ width: 485, marginLeft: 20, marginRight: 20 }}> + <Tooltip title={uiElement.description || ''}> + <Button className={this.props.classes.leftButton} color="secondary" disabled={this.state.editMode} onClick={() => { + this.navigate(`/${uiElement.id}`); + }}>{uiElement.label}</Button> + </Tooltip> + </FormControl> + ); + } else { + if (process.env.NODE_ENV !== "production") { + console.error(`Unknown type - ${(uiElement as any).uiType} in ${(uiElement as any).id}.`) + } + return null; + } + }) + ); + }; + + private renderUIElementList(listSpecification: ViewSpecification, listKeyProperty: string, listData: { [key: string]: any }[]) { + const listElements = listSpecification.elements; + + const navigate = (path: string) => { + this.props.history.push(`${this.props.match.url}${path}`); + }; + + const addNewElementAction = { + icon: AddIcon, tooltip: 'Add', onClick: () => { + navigate("[]"); // empty key means new element + } + }; + + const { classes } = this.props; + + return ( + <SelectElementTable idProperty={listKeyProperty} rows={listData} customActionButtons={[addNewElementAction]} columns={ + Object.keys(listElements).reduce<ColumnModel<{ [key: string]: any }>[]>((acc, cur) => { + const elm = listElements[cur]; + if (elm.uiType !== "object" && listData.every(entry => entry[elm.label] != null)) { + if (elm.label !== listKeyProperty) { + acc.push({ property: elm.label, type: elm.uiType === "number" ? ColumnType.numeric : ColumnType.text }); + } else { + acc.unshift({ property: elm.label, type: elm.uiType === "number" ? ColumnType.numeric : ColumnType.text }); + } + } + return acc; + }, []).concat([{ + property: "Actions", disableFilter: true, disableSorting: true, type: ColumnType.custom, customControl: (row => { + return ( + <Tooltip title={"Remove"} > + <IconButton className={classes.button} onClick={event => { + + }} > + <RemoveIcon /> + </IconButton> + </Tooltip> + ) + }) + }]) + } onHandleClick={(ev, row) => { + ev.preventDefault(); + navigate(`[${row[listKeyProperty]}]`); + }} ></SelectElementTable> + ); + } + + private renderBreadCrumps() { + const { editMode } = this.state; + const { viewSpecification, displayAsList } = this.props; + const { vPath, match: { url, path }, nodeId } = this.props; + const pathParts = splitVPath(vPath!, /(?:([^\/\["]+)(?:\[([^\]]*)\])?)/g); // 1 = property / 2 = optional key + let lastPath = `/configuration`; + let basePath = `/configuration/${nodeId}`; + return ( + <div className={this.props.classes.header}> + <div> + <Breadcrumbs aria-label="breadcrumb"> + <Link color="inherit" href="#" onClick={(ev: React.MouseEvent<HTMLElement>) => { + ev.preventDefault(); + this.props.history.push(lastPath); + }}>Back</Link> + <Link color="inherit" href="#" onClick={(ev: React.MouseEvent<HTMLElement>) => { + ev.preventDefault(); + this.props.history.push(`/configuration/${nodeId}`); + }}><span>{nodeId}</span></Link> + { + pathParts.map(([prop, key], ind) => { + const path = `${basePath}/${prop}`; + const keyPath = key && `${basePath}/${prop}[${key}]`; + const ret = ( + <span key={ind}> + <Link color="inherit" href="#" onClick={(ev: React.MouseEvent<HTMLElement>) => { + ev.preventDefault(); + this.props.history.push(path); + }}><span>{prop.replace(/^[^:]+:/, "")}</span></Link> + { + keyPath && <Link color="inherit" href="#" onClick={(ev: React.MouseEvent<HTMLElement>) => { + ev.preventDefault(); + this.props.history.push(keyPath); + }}>{`[${key}]`}</Link> || null + } + </span> + ); + lastPath = basePath; + basePath = keyPath || path; + return ret; + }) + } + </Breadcrumbs> + </div> + { /* do not show edit if this is a list or it can't be edited */ + !displayAsList && viewSpecification.canEdit && (<div> + <Fab color="secondary" aria-label="edit" className={this.props.classes.fab} onClick={() => { + if (this.state.editMode) { + this.props.onUpdateData(this.props.vPath!, this.state.viewData); + } + this.setState({ editMode: !editMode }); + }}> + {editMode + ? <SaveIcon /> + : <EditIcon /> + } + </Fab> + </div> || null) + } + </div> + ); + } + + private renderValueSelector() { + const { listKeyProperty, listSpecification, listData, onValueSelected } = this.props; + if (!listKeyProperty || !listSpecification) { + throw new Error("ListKex ot view not specified."); + } + + return ( + <div> + <SelectElementTable idProperty={listKeyProperty} rows={listData} columns={ + Object.keys(listSpecification.elements).reduce<ColumnModel<{ [key: string]: any }>[]>((acc, cur) => { + const elm = listSpecification.elements[cur]; + if (elm.uiType !== "object" && listData.every(entry => entry[elm.label] != null)) { + if (elm.label !== listKeyProperty) { + acc.push({ property: elm.label, type: elm.uiType === "number" ? ColumnType.numeric : ColumnType.text }); + } else { + acc.unshift({ property: elm.label, type: elm.uiType === "number" ? ColumnType.numeric : ColumnType.text }); + } + } + return acc; + }, []) + } onHandleClick={(ev, row) => { ev.preventDefault(); onValueSelected(row); }} ></SelectElementTable> + </div> + ); + } + + private renderValueEditor() { + const { keyProperty, displayAsList, viewSpecification } = this.props; + const { viewData, editMode, isNew } = this.state; + + return ( + <div> + { this.renderBreadCrumps() } + { displayAsList && viewData instanceof Array + ? this.renderUIElementList(viewSpecification, keyProperty!, viewData) + : this.renderUIElement(viewSpecification, viewData!, keyProperty, editMode, isNew) + } + </div > + ); + } + + private renderCollectingData() { + return ( + <div className={this.props.classes.outer}> + <div className={this.props.classes.inner}> + <Loader /> + <h3>Collecting Data ...</h3> + </div> + </div> + ); + } + + render() { + return this.props.collectingData || !this.state.viewData + ? this.renderCollectingData() + : this.props.listSpecification + ? this.renderValueSelector() + : this.renderValueEditor(); + } +} + +export const ConfigurationApplication = withStyles(styles)(withRouter(connect(mapProps, mapDispatch)(ConfigurationApplicationComponent))); +export default ConfigurationApplication;
\ No newline at end of file diff --git a/sdnr/wt/odlux/apps/configurationApp/src/views/networkElementSelector.tsx b/sdnr/wt/odlux/apps/configurationApp/src/views/networkElementSelector.tsx new file mode 100644 index 000000000..6fd5c8cf0 --- /dev/null +++ b/sdnr/wt/odlux/apps/configurationApp/src/views/networkElementSelector.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; +import { RouteComponentProps, withRouter } from 'react-router-dom'; + +import connect, { IDispatcher, Connect } from "../../../../framework/src/flux/connect"; +import { IApplicationStoreState } from "../../../../framework/src/store/applicationStore"; +import { MaterialTable, MaterialTableCtorType, ColumnType } from "../../../../framework/src/components/material-table"; +import { createConnectedNetworkElementsProperties, createConnectedNetworkElementsActions } from "../../../configurationApp/src/handlers/connectedNetworkElementsHandler"; + +import { NetworkElementConnection } from "../models/networkElementConnection"; +import { Tooltip, Button, IconButton } from "@material-ui/core"; + +const mapProps = (state: IApplicationStoreState) => ({ + connectedNetworkElementsProperties: createConnectedNetworkElementsProperties(state), +}); + +const mapDispatch = (dispatcher: IDispatcher) => ({ + connectedNetworkElementsActions: createConnectedNetworkElementsActions(dispatcher.dispatch), +}); + +const ConnectedElementTable = MaterialTable as MaterialTableCtorType<NetworkElementConnection>; + +type NetworkElementSelectorComponentProps = RouteComponentProps & Connect<typeof mapProps, typeof mapDispatch>; + +class NetworkElementSelectorComponent extends React.Component<NetworkElementSelectorComponentProps> { + + componentDidMount() { + this.props.connectedNetworkElementsActions.onRefresh(); + } + + render() { + return ( + <ConnectedElementTable onHandleClick={(e, row) => { this.props.history.push(`${ this.props.match.path }/${row.nodeId}`) }} columns={[ + { property: "nodeId", title: "Name", type: ColumnType.text }, + { property: "isRequired", title: "Required ?", type: ColumnType.boolean }, + { property: "host", title: "Host", type: ColumnType.text }, + { property: "port", title: "Port", type: ColumnType.numeric }, + { property: "coreModelCapability", title: "Core Model", type: ColumnType.text }, + { property: "deviceType", title: "Type", type: ColumnType.text }, + ]} idProperty="id" {...this.props.connectedNetworkElementsActions} {...this.props.connectedNetworkElementsProperties} asynchronus > + </ConnectedElementTable> + ); + } +} + +export const NetworkElementSelector = withRouter(connect(mapProps, mapDispatch)(NetworkElementSelectorComponent)); +export default NetworkElementSelector; + diff --git a/sdnr/wt/odlux/apps/configurationApp/src/yang/yangParser.ts b/sdnr/wt/odlux/apps/configurationApp/src/yang/yangParser.ts new file mode 100644 index 000000000..c7ab5e4a3 --- /dev/null +++ b/sdnr/wt/odlux/apps/configurationApp/src/yang/yangParser.ts @@ -0,0 +1,1099 @@ + +import { Token, Statement, Module, Identity } from "../models/yang"; +import { ViewSpecification, ViewElement, isViewElementObjectOrList, ViewElementBase, isViewElementReference } from "../models/uiModels"; +import { yangService } from "../services/yangService"; + +export const splitVPath = (vPath: string, vPathParser: RegExp): RegExpMatchArray[] => { + const pathParts: RegExpMatchArray[] = []; + let partMatch: RegExpExecArray | null; + if (vPath) do { + partMatch = vPathParser.exec(vPath); + if (partMatch) { + pathParts.push(partMatch); + } + } while (partMatch) + return pathParts; +} + +class YangLexer { + + private pos: number = 0; + private buf: string = ""; + + constructor(input: string) { + this.pos = 0; + this.buf = input; + } + + private _optable: { [key: string]: string } = { + ';': 'SEMI', + '{': 'L_BRACE', + '}': 'R_BRACE', + }; + + private _isNewline(char: string): boolean { + return char === '\r' || char === '\n'; + } + + private _isWhitespace(char: string): boolean { + return char === ' ' || char === '\t' || this._isNewline(char); + } + + private _isDigit(char: string): boolean { + return char >= '0' && char <= '9'; + } + + private _isAlpha(char: string): boolean { + return (char >= 'a' && char <= 'z') || + (char >= 'A' && char <= 'Z') + } + + private _isAlphanum(char: string): boolean { + return this._isAlpha(char) || this._isDigit(char) || + char === '_' || char === '-' || char === '.'; + } + + private _skipNontokens() { + while (this.pos < this.buf.length) { + const char = this.buf.charAt(this.pos); + if (this._isWhitespace(char)) { + this.pos++; + } else { + break; + } + } + } + + private _processString(terminator: string | null): Token { + // this.pos points at the opening quote. Find the ending quote. + let end_index = this.pos + 1; + while (end_index < this.buf.length) { + const char = this.buf.charAt(end_index); + if (char === "\\") { + end_index += 2; + continue; + }; + if (terminator === null && (this._isWhitespace(char) || this._optable[char] !== undefined) || char === terminator) { + break; + } + end_index++; + } + + if (end_index >= this.buf.length) { + throw Error('Unterminated quote at ' + this.pos); + } else { + const start = this.pos + (terminator ? 1 : 0); + const end = end_index; + const tok = { + name: 'STRING', + value: this.buf.substring(start, end), + start, + end + }; + this.pos = terminator ? end + 1 : end; + return tok; + } + } + + private _processIdentifier(): Token { + let endpos = this.pos + 1; + while (endpos < this.buf.length && + this._isAlphanum(this.buf.charAt(endpos))) { + endpos++; + } + + const tok = { + name: 'IDENTIFIER', + value: this.buf.substring(this.pos, endpos), + start: this.pos, + end: endpos + }; + this.pos = endpos; + return tok; + } + + private _processNumber(): Token { + let endpos = this.pos + 1; + while (endpos < this.buf.length && + this._isDigit(this.buf.charAt(endpos))) { + endpos++; + } + + const tok = { + name: 'NUMBER', + value: this.buf.substring(this.pos, endpos), + start: this.pos, + end: endpos + }; + this.pos = endpos; + return tok; + } + + private _processLineComment() { + var endpos = this.pos + 2; + // Skip until the end of the line + while (endpos < this.buf.length && !this._isNewline(this.buf.charAt(endpos))) { + endpos++; + } + this.pos = endpos + 1; + } + + private _processBlockComment() { + var endpos = this.pos + 2; + // Skip until the end of the line + while (endpos < this.buf.length && !((this.buf.charAt(endpos) === "/" && this.buf.charAt(endpos - 1) === "*"))) { + endpos++; + } + this.pos = endpos + 1; + } + + public tokenize(): Token[] { + const result: Token[] = []; + this._skipNontokens(); + while (this.pos < this.buf.length) { + + const char = this.buf.charAt(this.pos); + const op = this._optable[char]; + + if (op !== undefined) { + result.push({ name: op, value: char, start: this.pos, end: ++this.pos }); + } else if (this._isAlpha(char)) { + result.push(this._processIdentifier()); + this._skipNontokens(); + const peekChar = this.buf.charAt(this.pos); + if (this._optable[peekChar] === undefined) { + result.push((peekChar !== "'" && peekChar !== '"') + ? this._processString(null) + : this._processString(peekChar)); + } + } else if (char === '/' && this.buf.charAt(this.pos + 1) === "/") { + this._processLineComment(); + } else if (char === '/' && this.buf.charAt(this.pos + 1) === "*") { + this._processBlockComment(); + } else { + throw Error('Token error at ' + this.pos + " " + this.buf[this.pos]); + } + this._skipNontokens(); + } + return result; + } + + public tokenize2(): Statement { + let stack: Statement[] = [{ key: "ROOT", sub: [] }]; + let current: Statement | null = null; + + this._skipNontokens(); + while (this.pos < this.buf.length) { + + const char = this.buf.charAt(this.pos); + const op = this._optable[char]; + + if (op !== undefined) { + if (op === "L_BRACE") { + current && stack.unshift(current); + current = null; + } else if (op === "R_BRACE") { + current = stack.shift() || null; + } + this.pos++; + } else if (this._isAlpha(char)) { + const key = this._processIdentifier().value; + this._skipNontokens(); + let peekChar = this.buf.charAt(this.pos); + let arg = undefined; + if (this._optable[peekChar] === undefined) { + arg = (peekChar === '"' || peekChar === "'") + ? this._processString(peekChar).value + : this._processString(null).value; + } + do { + this._skipNontokens(); + peekChar = this.buf.charAt(this.pos); + if (peekChar !== "+") break; + this.pos++; + this._skipNontokens(); + peekChar = this.buf.charAt(this.pos); + arg += (peekChar === '"' || peekChar === "'") + ? this._processString(peekChar).value + : this._processString(null).value; + } while (true); + current = { key, arg, sub: [] }; + stack[0].sub!.push(current); + } else if (char === '/' && this.buf.charAt(this.pos + 1) === "/") { + this._processLineComment(); + } else if (char === '/' && this.buf.charAt(this.pos + 1) === "*") { + this._processBlockComment(); + } else { + throw Error('Token error at ' + this.pos + " " + this.buf.slice(this.pos - 10, this.pos + 10)); + } + this._skipNontokens(); + } + if (stack[0].key !== "ROOT" || !stack[0].sub![0]) { + throw new Error("Internal Perser Error"); + } + return stack[0].sub![0]; + } +} + +export class YangParser { + private _groupingsToResolve: (() => void)[] = []; + private _identityToResolve: (() => void)[] = []; + + private _modules: { [name: string]: Module } = {}; + private _views: ViewSpecification[] = [{ + id: "0", + name: "root", + language: "en-US", + canEdit: false, + parentView: "0", + title: "root", + elements: {}, + }]; + + constructor() { + + } + + public get modules() { + return this._modules; + } + + public get views() { + return this._views; + } + + public async addCapability(capability: string, version?: string) { + // do not add twice + if (this._modules[capability]) { + return; + } + + const data = await yangService.getCapability(capability, version); + if (!data) { + throw new Error(`Could not load yang file for ${capability}.`); + } + + const rootStatement = new YangLexer(data).tokenize2(); + + if (rootStatement.key !== "module") { + throw new Error(`Root element of ${capability} is not a module.`); + } + if (rootStatement.arg !== capability) { + throw new Error(`Root element capability ${rootStatement.arg} does not requested ${capability}.`); + } + + const module = this._modules[capability] = { + name: rootStatement.arg, + revisions: {}, + imports: {}, + features: {}, + identities: {}, + augments: {}, + groupings: {}, + typedefs: {}, + views: {}, + elements: {} + }; + + await this.handleModule(module, rootStatement, capability); + } + + private async handleModule(module: Module, rootStatement: Statement, capability: string) { + + // extract namespace && prefix + module.namespace = this.extractValue(rootStatement, "namespace"); + module.prefix = this.extractValue(rootStatement, "prefix"); + if (module.prefix) { + module.imports[module.prefix] = capability; + } + + // extract revisions + const revisions = this.extractNodes(rootStatement, "revision"); + module.revisions = { + ...module.revisions, + ...revisions.reduce<{ [version: string]: { }}>((acc, version) => { + if (!version.arg) { + throw new Error(`Module [${module.name}] has a version w/o version number.`); + } + const description = this.extractValue(version, "description"); + const reference = this.extractValue(version,"reference"); + acc[version.arg] = { + description, + reference, + }; + return acc; + }, {}) + }; + + // extract features + const features = this.extractNodes(rootStatement, "feature"); + module.features = { + ...module.features, + ...features.reduce<{ [version: string]: {} }>((acc, feature) => { + if (!feature.arg) { + throw new Error(`Module [${module.name}] has a feature w/o name.`); + } + const description = this.extractValue(feature, "description"); + acc[feature.arg] = { + description, + }; + return acc; + }, {}) + }; + + // extract imports + const imports = this.extractNodes(rootStatement, "import"); + module.imports = { + ...module.imports, + ...imports.reduce < { [key: string]: string }>((acc, imp) => { + const prefix = imp.sub && imp.sub.filter(s => s.key === "prefix"); + if (!imp.arg) { + throw new Error(`Module [${module.name}] has an import with neither name nor prefix.`); + } + acc[prefix && prefix.length === 1 && prefix[0].arg || imp.arg] = imp.arg; + return acc; + }, {}) + }; + + // import all required files + if (imports) for (let ind = 0; ind < imports.length; ++ind) { + await this.addCapability(imports[ind].arg!); + } + + this.extractTypeDefinitions(rootStatement, module, ""); + + this.extractIdentites(rootStatement, 0, module, ""); + + const groupings = this.extractGroupings(rootStatement, 0, module, ""); + this._views.push(...groupings); + + const augments = this.extractAugments(rootStatement, 0, module, ""); + this._views.push(...augments); + + // the default for config on module level is config = true; + const [currentView, subViews] = this.extractSubViews(rootStatement, 0, module, ""); + this._views.push(currentView, ...subViews); + + // create the root elements for this module + module.elements = currentView.elements; + Object.keys(module.elements).forEach(key => { + const viewElement = module.elements[key]; + if (!isViewElementObjectOrList(viewElement)) { + throw new Error(`Module: [${module}]. Only List or Object allowed on root level.`); + } + const viewIdIndex = Number(viewElement.viewId); + module.views[key] = this._views[viewIdIndex]; + this._views[0].elements[key] = module.elements[key]; + }); + return module; + } + + public postProcess() { + // process all groupings + // execute all post processes like resolving in propper order + this._groupingsToResolve.forEach(cb => { + try { cb(); } catch (error) { + console.warn(`Error resolving: [${error.message}]`); + } + }); + + // process all augmentations + Object.keys(this.modules).forEach(modKey => { + const module = this.modules[modKey]; + Object.keys(module.augments).forEach(augKey => { + const augments = module.augments[augKey]; + const viewSpec = this.resolveView(augKey); + if (!viewSpec) console.warn(`Could not find view to augment [${augKey}] in [${module.name}].`); + if (augments && viewSpec) { + augments.forEach(augment => Object.keys(augment.elements).forEach(key => { + const elm = augment.elements[key]; + viewSpec.elements[key] = { + ...augment.elements[key], + when: elm.when ? `(${augment.when}) and (${elm.when})` : augment.when, + ifFeature: elm.ifFeature ? `(${augment.ifFeature}) and (${elm.ifFeature})` : augment.ifFeature, + }; + })); + } + }); + }); + + // process Identities + const traverseIdentity = (identities : Identity[]) => { + const result: Identity[] = []; + for (let identity of identities) { + if (identity.children && identity.children.length > 0) { + result.push(...traverseIdentity(identity.children)); + } else { + result.push(identity); + } + } + return result; + } + + + const baseIdentites: Identity[] = []; + Object.keys(this.modules).forEach(modKey => { + const module = this.modules[modKey]; + Object.keys(module.identities).forEach(idKey => { + const identity = module.identities[idKey]; + if (identity.base != null) { + const base = this.resolveIdentity(identity.base, module); + base.children?.push(identity); + } else { + baseIdentites.push(identity); + } + }); + }); + baseIdentites.forEach(identity => { + identity.values = identity.children && traverseIdentity(identity.children) || []; + }); + + this._identityToResolve.forEach(cb => { + try { cb(); } catch (error) { + console.warn(error.message); + } + }); + }; + + + private _nextId = 1; + private get nextId() { + return this._nextId++; + } + + private extractNodes(statement: Statement, key: string): Statement[] { + return statement.sub && statement.sub.filter(s => s.key === key) || []; + } + + private extractValue(statement: Statement, key: string): string | undefined; + private extractValue(statement: Statement, key: string, parser: RegExp): RegExpExecArray | undefined; + private extractValue(statement: Statement, key: string, parser?: RegExp): string | RegExpExecArray | undefined { + const typeNodes = this.extractNodes(statement, key); + const rawValue = typeNodes.length > 0 && typeNodes[0].arg || undefined; + return parser + ? rawValue && parser.exec(rawValue) || undefined + : rawValue; + } + + private extractTypeDefinitions(statement: Statement, module: Module, currentPath: string): void { + const typedefs = this.extractNodes(statement, "typedef"); + typedefs && typedefs.forEach(def => { + if (! def.arg) { + throw new Error(`Module: [${module.name}]. Found typefed without name.`); + } + module.typedefs[def.arg] = this.getViewElement(def, module, 0, currentPath, false); + }); + } + + /** Handles Goupings like named Container */ + private extractGroupings(statement: Statement, parentId: number, module: Module, currentPath: string): ViewSpecification[] { + const subViews: ViewSpecification[] = []; + const groupings = this.extractNodes(statement, "grouping"); + if (groupings && groupings.length > 0) { + subViews.push(...groupings.reduce<ViewSpecification[]>((acc, cur) => { + if (!cur.arg) { + throw new Error(`Module: [${module.name}][${currentPath}]. Found grouping without name.`); + } + const grouping = cur.arg; + + // the default for config on module level is config = true; + const [currentView, subViews] = this.extractSubViews(cur, parentId, module, currentPath); + grouping && (module.groupings[grouping] = currentView); + acc.push(currentView, ...subViews); + return acc; + }, [])); + } + + return subViews; + } + + /** Handles Augmants also like named Container */ + private extractAugments(statement: Statement, parentId: number, module: Module, currentPath: string): ViewSpecification[] { + const subViews: ViewSpecification[] = []; + const augments = this.extractNodes(statement, "augment"); + if (augments && augments.length > 0) { + subViews.push(...augments.reduce<ViewSpecification[]>((acc, cur) => { + if (!cur.arg) { + throw new Error(`Module: [${module.name}][${currentPath}]. Found augment without path.`); + } + const augment = this.resolveReferencePath(cur.arg, module); + + // the default for config on module level is config = true; + const [currentView, subViews] = this.extractSubViews(cur, parentId, module, currentPath); + if (augment) { + module.augments[augment] = module.augments[augment] || []; + module.augments[augment].push(currentView); + } + acc.push(currentView, ...subViews); + return acc; + }, [])); + } + + return subViews; + } + + /** Handles Identities */ + private extractIdentites(statement: Statement, parentId: number, module: Module, currentPath: string) { + const identities = this.extractNodes(statement, "identity"); + module.identities = identities.reduce<{ [name: string]: Identity }>((acc, cur) => { + if (!cur.arg) { + throw new Error(`Module: [${module.name}][${currentPath}]. Found identiy without name.`); + } + acc[cur.arg] = { + id: `${module.name}:${cur.arg}`, + label: cur.arg, + base: this.extractValue(cur, "base"), + description: this.extractValue(cur, "description"), + reference: this.extractValue(cur, "reference"), + children: [] + } + return acc; + }, {}); + } + + private extractSubViews(statement: Statement, parentId: number, module: Module, currentPath: string): [ViewSpecification, ViewSpecification[]] { + const subViews: ViewSpecification[] = []; + const currentId = this.nextId; + let elements: ViewElement[] = []; + + const configValue = this.extractValue(statement, "config"); + const config = configValue == null ? true : configValue.toLocaleLowerCase() !== "false"; + + // extract conditions + const ifFeature = this.extractValue(statement, "if-feature"); + const whenCondition = this.extractValue(statement, "when"); + + // extract all container + const container = this.extractNodes(statement, "container"); + if (container && container.length > 0) { + subViews.push(...container.reduce<ViewSpecification[]>((acc, cur) => { + if (!cur.arg) { + throw new Error(`Module: [${module.name}]. Found container without name.`); + } + const [currentView, subViews] = this.extractSubViews(cur, currentId, module, `${currentPath}/${module.name}:${cur.arg}`); + elements.push({ + id: parentId === 0 ? `${module.name}:${cur.arg}` : cur.arg, + label: cur.arg, + uiType: "object", + viewId: currentView.id, + config: config + }); + acc.push(currentView, ...subViews); + return acc; + }, [])); + } + + // process all lists + // a list is a list of containers with the leafs contained in the list + const lists = this.extractNodes(statement, "list"); + if (lists && lists.length > 0) { + subViews.push(...lists.reduce<ViewSpecification[]>((acc, cur) => { + if (!cur.arg) { + throw new Error(`Module: [${module.name}]. Found list without name.`); + } + const key = this.extractValue(cur, "key") || undefined; + if (config && !key) { + throw new Error(`Module: [${module.name}]. Found configurable list without key.`); + } + const [currentView, subViews] = this.extractSubViews(cur, currentId, module, `${currentPath}/${module.name}:${cur.arg}`); + elements.push({ + id: parentId === 0 ? `${module.name}:${cur.arg}` : cur.arg, + label: cur.arg, + isList: true, + uiType: "object", + viewId: currentView.id, + key: key, + config: config + }); + acc.push(currentView, ...subViews); + return acc; + }, [])); + } + + // process all leaf-lists + // a leaf-list is a list of some type + const leafLists = this.extractNodes(statement, "leaf-list"); + if (leafLists && leafLists.length > 0) { + elements.push(...leafLists.reduce<ViewElement[]>((acc, cur) => { + const element = this.getViewElement(cur, module, parentId, currentPath, true); + element && acc.push(element); + return acc; + }, [])); + } + + // process all leafs + // a leaf is mainly a property of an object + const leafs = this.extractNodes(statement, "leaf"); + if (leafs && leafs.length > 0) { + elements.push(...leafs.reduce<ViewElement[]>((acc, cur) => { + const element = this.getViewElement(cur, module, parentId, currentPath, false); + element && acc.push(element); + return acc; + }, [])); + } + + + const choiceStms = this.extractNodes(statement, "choice"); + if (choiceStms && choiceStms.length > 0) { + for (let i = 0; i < choiceStms.length; ++i) { + const cases = this.extractNodes(choiceStms[i], "case"); + console.warn(`Choice found ${choiceStms[i].arg}::${cases.map(c => c.arg).join(";")}`, choiceStms[i]); + } + } + + const rpcs = this.extractNodes(statement, "rpc"); + if (rpcs && rpcs.length > 0) { + // todo: + } + + if (!statement.arg) { + throw new Error(`Module: [${module.name}]. Found statement without name.`); + } + + const viewSpec: ViewSpecification = { + id: String(currentId), + parentView: String(parentId), + name: statement.arg, + title: statement.arg, + language: "en-us", + canEdit: false, + ifFeature: ifFeature, + when: whenCondition, + elements: elements.reduce<{ [name: string]: ViewElement }>((acc, cur) => { + acc[cur.id] = cur; + return acc; + }, {}), + }; + + // evaluate canEdit depending on all conditions + Object.defineProperty(viewSpec, "canEdit", { + get: () => { + return Object.keys(viewSpec.elements).some(key => { + const elm = viewSpec.elements[key]; + return (!isViewElementObjectOrList(elm) && elm.config); + }); + } + }); + + // merge in all uses references and resolve groupings + const usesRefs = this.extractNodes(statement, "uses"); + if (usesRefs && usesRefs.length > 0) { + + viewSpec.uses = (viewSpec.uses || []); + for (let i = 0; i < usesRefs.length; ++i) { + const groupingName = usesRefs[i].arg; + if (!groupingName) { + throw new Error(`Module: [${module.name}]. Found an uses statement without a grouping name.`); + } + + viewSpec.uses.push(this.resolveReferencePath(groupingName, module)); + + this._groupingsToResolve.push(() => { + const groupingViewSpec = this.resolveGrouping(groupingName, module); + if (groupingViewSpec) { + Object.keys(groupingViewSpec.elements).forEach(key => { + const elm = groupingViewSpec.elements[key]; + viewSpec.elements[key] = { + ...groupingViewSpec.elements[key], + when: elm.when ? `(${groupingViewSpec.when}) and (${elm.when})` : groupingViewSpec.when, + ifFeature: elm.ifFeature ? `(${groupingViewSpec.ifFeature}) and (${elm.ifFeature})` : groupingViewSpec.ifFeature, + }; + }); + } + }); + } + } + + return [viewSpec, subViews]; + } + + /** Extracts the UI View from the type in the cur statement. */ + private getViewElement(cur: Statement, module: Module, parentId: number, currentPath: string, isList: boolean): ViewElement { + + const type = this.extractValue(cur, "type"); + const defaultVal = this.extractValue(cur, "default") || undefined; + const description = this.extractValue(cur, "description") || undefined; + const rangeMatch = this.extractValue(cur, "range", /^(\d+)\.\.(\d+)/) || undefined; + + const configValue = this.extractValue(cur, "config"); + const config = configValue == null ? true : configValue.toLocaleLowerCase() !== "false"; + + const mandatory = this.extractValue(cur, "mandatory") === "true" || false; + + if (!cur.arg) { + throw new Error(`Module: [${module.name}]. Found element without name.`); + } + + if (!type) { + throw new Error(`Module: [${module.name}].[${cur.arg}]. Found element without type.`); + } + + const element: ViewElementBase = { + id: parentId === 0 ? `${module.name}:${cur.arg}`: cur.arg, + label: cur.arg, + config: config, + mandatory: mandatory, + isList: isList, + default: defaultVal, + description: description + }; + + if (type === "string") { + return ({ + ...element, + uiType: "string", + pattern: this.extractNodes(this.extractNodes(cur, "type")[0]!, "pattern").map(p => p.arg!).filter(p => !!p), + }); + } else if (type === "boolean") { + return ({ + ...element, + uiType: "boolean" + }); + } else if (type === "uint8") { + return ({ + ...element, + uiType: "number", + min: rangeMatch ? Number(rangeMatch[0]) : 0, + max: rangeMatch ? Number(rangeMatch[1]) : +255, + units: this.extractValue(cur, "units") || undefined, + format: this.extractValue(cur, "format") || undefined, + }); + } else if (type === "uint16") { + return ({ + ...element, + uiType: "number", + min: rangeMatch ? Number(rangeMatch[0]) : 0, + max: rangeMatch ? Number(rangeMatch[1]) : +65535, + units: this.extractValue(cur, "units") || undefined, + format: this.extractValue(cur, "format") || undefined, + }); + } else if (type === "uint32") { + return ({ + ...element, + uiType: "number", + min: rangeMatch ? Number(rangeMatch[0]) : 0, + max: rangeMatch ? Number(rangeMatch[1]) : +4294967295, + units: this.extractValue(cur, "units") || undefined, + format: this.extractValue(cur, "format") || undefined, + }); + } else if (type === "uint64") { + return ({ + ...element, + uiType: "number", + min: rangeMatch ? Number(rangeMatch[0]) : 0, + max: rangeMatch ? Number(rangeMatch[1]) : +18446744073709551615, + units: this.extractValue(cur, "units") || undefined, + format: this.extractValue(cur, "format") || undefined, + }); + } else if (type === "int8") { + return ({ + ...element, + uiType: "number", + min: rangeMatch ? Number(rangeMatch[0]) : -128, + max: rangeMatch ? Number(rangeMatch[1]) : +127, + units: this.extractValue(cur, "units") || undefined, + format: this.extractValue(cur, "format") || undefined, + }); + } else if (type === "int16") { + return ({ + ...element, + uiType: "number", + min: rangeMatch ? Number(rangeMatch[0]) : -32768, + max: rangeMatch ? Number(rangeMatch[1]) : +32767, + units: this.extractValue(cur, "units") || undefined, + format: this.extractValue(cur, "format") || undefined, + }); + } else if (type === "int32") { + return ({ + ...element, + uiType: "number", + min: rangeMatch ? Number(rangeMatch[0]) : -2147483648, + max: rangeMatch ? Number(rangeMatch[1]) : +2147483647, + units: this.extractValue(cur, "units") || undefined, + format: this.extractValue(cur, "format") || undefined, + }); + } else if (type === "int64") { + return ({ + ...element, + uiType: "number", + min: rangeMatch ? Number(rangeMatch[0]) : 0, + max: rangeMatch ? Number(rangeMatch[1]) : +18446744073709551615, + units: this.extractValue(cur, "units") || undefined, + format: this.extractValue(cur, "format") || undefined, + }); + } else if (type === "decimal16") { + return ({ + ...element, + uiType: "number", + min: rangeMatch ? Number(rangeMatch[0]) : 0, + max: rangeMatch ? Number(rangeMatch[1]) : +18446744073709551615, + units: this.extractValue(cur, "units") || undefined, + format: this.extractValue(cur, "format") || undefined, + fDigits: Number(this.extractValue(this.extractNodes(cur, "type")[0]!, "fraction-digits")) || -1 + }); + } else if (type === "decimal32") { + return ({ + ...element, + uiType: "number", + min: rangeMatch ? Number(rangeMatch[0]) : 0, + max: rangeMatch ? Number(rangeMatch[1]) : +18446744073709551615, + units: this.extractValue(cur, "units") || undefined, + format: this.extractValue(cur, "format") || undefined, + fDigits: Number(this.extractValue(this.extractNodes(cur, "type")[0]!, "fraction-digits")) || -1 + }); + } else if (type === "decimal64") { + return ({ + ...element, + uiType: "number", + min: rangeMatch ? Number(rangeMatch[0]) : 0, + max: rangeMatch ? Number(rangeMatch[1]) : +18446744073709551615, + units: this.extractValue(cur, "units") || undefined, + format: this.extractValue(cur, "format") || undefined, + fDigits: Number(this.extractValue(this.extractNodes(cur, "type")[0]!, "fraction-digits")) || -1 + }); + } else if (type === "enumeration") { + const typeNode = this.extractNodes(cur, "type")[0]!; + const enumNodes = this.extractNodes(typeNode, "enum"); + return ({ + ...element, + uiType: "selection", + options: enumNodes.reduce<{ key: string; value: string; description?: string }[]>((acc, enumNode) => { + if (!enumNode.arg) { + throw new Error(`Module: [${module.name}][${currentPath}][${cur.arg}]. Found option without name.`); + } + const ifClause = this.extractValue(enumNode, "if-feature"); + const value = this.extractValue(enumNode, "value"); + const enumOption = { + key: enumNode.arg, + value: value != null ? value : enumNode.arg, + description: this.extractValue(enumNode, "description") || undefined + }; + // todo: ❗ handle the if clause ⚡ + acc.push(enumOption); + return acc; + }, []) + }); + } else if (type === "leafref") { + const typeNode = this.extractNodes(cur, "type")[0]!; + const vPath = this.extractValue(typeNode, "path"); + if (!vPath) { + throw new Error(`Module: [${module.name}][${currentPath}][${cur.arg}]. Found leafref without path.`); + } + const refPath = this.resolveReferencePath(vPath, module); + const resolve = this.resolveReference.bind(this); + const res : ViewElement = { + ...element, + uiType: "reference", + referencePath: refPath, + ref(this: ViewElement, currentPath: string) { + const resolved = resolve(refPath, currentPath); + return resolved && { + ...resolved, + id: this.id, + label: this.label, + config: this.config, + mandatory: this.mandatory, + isList: this.isList, + default: this.default, + description: this.description, + } as ViewElement; + } + }; + return res; + } else if (type === "identityref") { + const typeNode = this.extractNodes(cur, "type")[0]!; + const base = this.extractValue(typeNode, "base"); + if (!base) { + throw new Error(`Module: [${module.name}][${currentPath}][${cur.arg}]. Found identityref without base.`); + } + const res: ViewElement = { + ...element, + uiType: "selection", + options: [] + }; + this._identityToResolve.push(() => { + const identity : Identity = this.resolveIdentity(base, module); + if (!identity) { + throw new Error(`Module: [${module.name}][${currentPath}][${cur.arg}]. Could not resolve identity [${base}].`); + } + if (!identity.values || identity.values.length === 0) { + throw new Error(`Identity: [${base}] has no values.`); + } + res.options = identity.values.map(val => ({ + key: val.id, + value: val.id, + description: val.description + })); + }); + return res ; + } else if (type === "empty") { + // todo: ❗ handle empty ⚡ + /* 9.11. The empty Built-In Type + The empty built-in type represents a leaf that does not have any + value, it conveys information by its presence or absence. */ + console.warn(`found type: empty in [${module.name}][${currentPath}][${element.label}]`); + return { + ...element, + uiType: "string", + }; + } else if (type === "union") { + // todo: ❗ handle union ⚡ + /* 9.12. The union Built-In Type */ + console.warn(`found type: union in [${module.name}][${currentPath}][${element.label}]`); + return { + ...element, + uiType: "string", + }; + } else if (type === "bits") { + const typeNode = this.extractNodes(cur, "type")[0]!; + const bitNodes = this.extractNodes(typeNode, "bit"); + return { + ...element, + uiType: "bits", + flags: bitNodes.reduce<{[name: string]: number | undefined; }>((acc, bitNode) => { + if (!bitNode.arg) { + throw new Error(`Module: [${module.name}][${currentPath}][${cur.arg}]. Found bit without name.`); + } + const ifClause = this.extractValue(bitNode, "if-feature"); + const pos = Number(this.extractValue(bitNode, "position")); + acc[bitNode.arg] = pos === pos ? pos : undefined; + return acc; + }, {}) + }; + } else if (type === "binary") { + const typeNode = this.extractNodes(cur, "type")[0]!; + const length = Number(this.extractValue(typeNode, "length")); + return { + ...element, + uiType: "binary", + length: length === length ? length : undefined + }; + } else { + // not a build in type, have to resolve type + const typeRef = this.resolveType(type, module); + if (typeRef == null) console.error(new Error(`Could not resolve type ${type} in [${module.name}][${currentPath}].`)); + return ({ + ...typeRef, + ...element, + description: description + }) as ViewElement; + } + } + + private resolveReferencePath(vPath: string, module: Module) { + const vPathParser = /(?:(?:([^\/\:]+):)?([^\/]+))/g // 1 = opt: namespace / 2 = property + return vPath.replace(vPathParser, (_, ns, property) => { + const nameSpace = ns && module.imports[ns] || module.name; + return `${nameSpace}:${property}`; + }); + } + + + private resolveReference(vPath: string, currentPath: string) { + const vPathParser = /(?:(?:([^\/\[\]\:]+):)?([^\/\[\]]+)(\[[^\]]+\])?)/g // 1 = opt: namespace / 2 = property / 3 = opt: indexPath + let element : ViewElement | null = null; + let moduleName = ""; + + const vPathParts = splitVPath(vPath, vPathParser).map(p => ({ ns: p[1], property: p[2], ind: p[3] })); + const resultPathParts = !vPath.startsWith("/") + ? splitVPath(currentPath, vPathParser).map(p => ({ ns: p[1], property: p[2], ind: p[3] })) + : []; + + for (let i = 0; i < vPathParts.length; ++i){ + const vPathPart = vPathParts[i]; + if (vPathPart.property === "..") { + resultPathParts.pop(); + } else if (vPathPart.property !== ".") { + resultPathParts.push(vPathPart); + } + } + + // resolve element by path + for (let j = 0; j < resultPathParts.length;++j){ + const pathPart = resultPathParts[j]; + if (j===0) { + moduleName = pathPart.ns; + const rootModule = this._modules[moduleName]; + if (!rootModule) throw new Error("Could not resolve module [" + moduleName +"].\r\n" + vPath); + element = rootModule.elements[`${pathPart.ns}:${pathPart.property}`]; + } else if (element && isViewElementObjectOrList(element)) { + const view: ViewSpecification = this._views[+element.viewId]; + if (moduleName !== pathPart.ns) { + moduleName = pathPart.ns; + element = view.elements[`${moduleName}:${pathPart.property}`]; + } else { + element = view.elements[pathPart.property] || view.elements[`${moduleName}:${pathPart.property}`]; + } + } else { + throw new Error("Could not resolve reference.\r\n" + vPath); + } + if (!element) throw new Error("Could not resolve path [" + pathPart.property + "] in ["+ currentPath +"] \r\n" + vPath); + } + + return element; + } + + private resolveView(vPath: string) { + const vPathParser = /(?:(?:([^\/\[\]\:]+):)?([^\/\[\]]+)(\[[^\]]+\])?)/g // 1 = opt: namespace / 2 = property / 3 = opt: indexPath + let element: ViewElement | null = null; + let partMatch: RegExpExecArray | null; + let view: ViewSpecification | null = null; + let moduleName = ""; + if (vPath) do { + partMatch = vPathParser.exec(vPath); + if (partMatch) { + if (element === null) { + moduleName = partMatch[1]!; + const rootModule = this._modules[moduleName]; + if (!rootModule) return null; + element = rootModule.elements[`${moduleName}:${partMatch[2]!}`]; + } else if (isViewElementObjectOrList(element)) { + view = this._views[+element.viewId]; + if (moduleName !== partMatch[1]) { + moduleName = partMatch[1]; + element = view.elements[`${moduleName}:${partMatch[2]}`]; + } else { + element = view.elements[partMatch[2]]; + } + } else { + return null; + } + if (!element) return null; + } + } while (partMatch) + return element && isViewElementObjectOrList(element) && this._views[+element.viewId] || null; + } + + private resolveType(type: string, module: Module) { + const collonInd = type.indexOf(":"); + const preFix = collonInd > -1 ? type.slice(0, collonInd) : ""; + const typeName = collonInd > -1 ? type.slice(collonInd + 1) : type; + + const res = preFix + ? this._modules[module.imports[preFix]].typedefs[typeName] + : module.typedefs[typeName]; + return res; + } + + private resolveGrouping(grouping: string, module: Module) { + const collonInd = grouping.indexOf(":"); + const preFix = collonInd > -1 ? grouping.slice(0, collonInd) : ""; + const groupingName = collonInd > -1 ? grouping.slice(collonInd + 1) : grouping; + + return preFix + ? this._modules[module.imports[preFix]].groupings[groupingName] + : module.groupings[groupingName]; + + } + + private resolveIdentity(identity: string, module: Module) { + const collonInd = identity.indexOf(":"); + const preFix = collonInd > -1 ? identity.slice(0, collonInd) : ""; + const identityName = collonInd > -1 ? identity.slice(collonInd + 1) : identity; + + return preFix + ? this._modules[module.imports[preFix]].identities[identityName] + : module.identities[identityName]; + + } +}
\ No newline at end of file |