diff --git a/client/components.js b/client/components.js index 43dd888..668a94c 100644 --- a/client/components.js +++ b/client/components.js @@ -4,12 +4,16 @@ import parameter from "./components/parameter"; import parameterList from "./components/parameter-list"; import parameterTable from "./components/parameter-table"; import overviewDot from "./components/overview-dot"; +import container from "./components/container"; +import summonButton from "./components/summon-button"; function get(name) { if (name === "parameter") return parameter; else if (name === "parameter-list") return parameterList; else if (name === "parameter-table") return parameterTable; else if (name === "overview-dot") return overviewDot; + else if (name === "container") return container; + else if (name === "summon-button") return summonButton; else throw new Error(`No such component '${name}'`); } diff --git a/client/components/container.js b/client/components/container.js new file mode 100644 index 0000000..e988e70 --- /dev/null +++ b/client/components/container.js @@ -0,0 +1,17 @@ +"use strict"; + +import m from "mithril"; +import * as components from "../components"; + +const component = { + view: vnode => { + let children = Object.values(vnode.attrs.components).map(c => { + const attrs = Object.assign({}, vnode.attrs, c); + return m(components.get(attrs.type), attrs); + }); + if (vnode.attrs.element) return m(vnode.attrs.element, children); + else return children; + } +}; + +export default component; diff --git a/client/components/parameter-list.js b/client/components/parameter-list.js index ee36952..d0e1af5 100644 --- a/client/components/parameter-list.js +++ b/client/components/parameter-list.js @@ -16,7 +16,10 @@ const component = { m("th", p.label), m( "td", - m(components.get("parameter"), Object.assign({ device: device }, p)) + m( + components.get(p.type || "parameter"), + Object.assign({ device: device }, p) + ) ) ) ); diff --git a/client/components/parameter-table.js b/client/components/parameter-table.js index cbdd0d8..40f9b5d 100644 --- a/client/components/parameter-table.js +++ b/client/components/parameter-table.js @@ -2,6 +2,7 @@ import m from "mithril"; import * as components from "../components"; +import * as taskQueue from "../task-queue"; const component = { view: vnode => { @@ -12,14 +13,18 @@ const component = { if (!device[object]) return null; const instances = new Set(); + const prefix = `${object}.`; for (let p in device) - if (p.startsWith(object)) - instances.add(p.slice(0, p.indexOf(".", object.length + 1))); + if (p.startsWith(prefix)) { + let i = p.indexOf(".", prefix.length); + if (i === -1) instances.add(p); + else instances.add(p.slice(0, i)); + } + + const headers = Object.values(parameters).map(p => m("th", p.label)); + + const thead = m("thead", m("tr", headers)); - const thead = m( - "thead", - m("tr", Object.values(parameters).map(p => m("th", p.label))) - ); const rows = []; for (let i of instances) { const row = Object.values(parameters).map(p => @@ -33,8 +38,50 @@ const component = { ) ) ); + if (device[i].writable === true) + row.push( + m( + "td", + m( + "a.delete", + { + onclick: () => { + taskQueue.queueTask({ + name: "deleteObject", + device: device["DeviceID.ID"].value[0], + objectName: i + }); + } + }, + "−" + ) + ) + ); rows.push(m("tr", row)); } + if (device[object].writable === true) + rows.push( + m( + "tr", + m("td", { colspan: headers.length }), + m( + "td", + m( + "a.add", + { + onclick: () => { + taskQueue.queueTask({ + name: "addObject", + device: device["DeviceID.ID"].value[0], + objectName: object + }); + } + }, + "+" + ) + ) + ) + ); return m("table.parameter-table", thead, m("tbody", rows)); } }; diff --git a/client/components/parameter.js b/client/components/parameter.js index 6c6d47d..1511784 100644 --- a/client/components/parameter.js +++ b/client/components/parameter.js @@ -1,7 +1,7 @@ "use strict"; import m from "mithril"; -import * as components from "../components"; +import * as taskQueue from "../task-queue"; const component = { view: vnode => { @@ -11,19 +11,30 @@ const component = { if (param.value[1] === "xsd:dateTime") value = new Date(value).toISOString(); - let meta; - if (vnode.attrs.meta) { - const metaAttrs = Object.assign( - { device: vnode.attrs.device, parameter: vnode.attrs.parameter }, - vnode.attrs.meta + let edit; + if (param.writable) + edit = m( + "a.edit", + { + onclick: () => { + taskQueue.stageSpv({ + name: "setParameterValues", + device: vnode.attrs.device["DeviceID.ID"].value[0], + parameterValues: [ + [vnode.attrs.parameter, param.value[0], param.value[1]] + ] + }); + } + }, + "✎" ); - meta = m("span", [ - " (", - m(components.get(metaAttrs.type), metaAttrs), - ")" - ]); - } - return [m("span", { title: param.valueTimestamp }, value), meta]; + + return m( + "span.parameter-value", + { title: param.valueTimestamp }, + value, + edit + ); } }; diff --git a/client/components/summon-button.js b/client/components/summon-button.js new file mode 100644 index 0000000..2e75669 --- /dev/null +++ b/client/components/summon-button.js @@ -0,0 +1,49 @@ +"use strict"; + +import m from "mithril"; +import * as taskQueue from "../task-queue"; +import * as store from "../store"; +import * as notifications from "../notifications"; + +const component = { + view: vnode => { + return m( + "a.summon", + { + onclick: () => { + const params = Object.values(vnode.attrs.parameters); + const task = { + name: "getParameterValues", + parameterNames: params, + device: vnode.attrs.device["DeviceID.ID"].value[0] + }; + + taskQueue + .commit([task], (deviceId, connectionRequestStatus, tasks2) => { + for (let t of tasks2) + if (t.status === "stale") taskQueue.deleteTask(t); + if (connectionRequestStatus !== "OK") + notifications.push( + "error", + `${deviceId}: ${connectionRequestStatus}` + ); + else if (tasks2[0].status === "stale") + notifications.push( + "error", + `${deviceId}: No contact from device` + ); + else if (tasks2[0].status === "fault") + notifications.push("error", `${deviceId}: Refresh faulted`); + else notifications.push("success", `${deviceId}: Summoned`); + }) + .then(() => { + store.fulfill(Date.now(), Date.now()); + }); + } + }, + "Summon" + ); + } +}; + +export default component; diff --git a/client/css/app.css b/client/css/app.css index 4371e91..b775d0e 100644 --- a/client/css/app.css +++ b/client/css/app.css @@ -45,12 +45,157 @@ i { font-style: italic; } +@keyframes blink { + from { + opacity: 1; + } + to { + opacity: 0.3; + } +} + #header { background-color: #f2eddf; border-bottom: 1px solid var(--color1); overflow: visible; position: relative; + & > .drawer-wrapper { + position: fixed; + width: 720px; + margin-left: -360px; + top: 0; + left: 50%; + & > .drawer { + background-color: rgba(255, 255, 255, 0.8); + border-bottom-left-radius: 6px; + border-bottom-right-radius: 6px; + border-bottom: 1px solid var(--color1); + border-left: 1px solid var(--color1); + border-right: 1px solid var(--color1); + margin-top: -1px; + box-shadow: 0 0 10px #ccc; + overflow: hidden; + transition: height var(--fade), opacity var(--fade); + + & > div { + padding: 0.3em; + } + + & > .status { + & > span { + margin-right: 1em; + } + & > .pending { + color: #770; + &.active { + animation: 0.5s linear 0s infinite alternate blink; + } + } + & > .stale { + color: #777; + } + & > .faulty { + color: #700; + } + & > .active { + font-weight: bold; + } + + & > .actions { + float: right; + & a { + float: right; + margin-left: 0.5em; + } + & > .commit { + font-weight: bold; + } + } + } + + & > .staging { + & .parameter { + font-style: italic; + max-width: 500px; + text-overflow: ellipsis; + display: inline-block; + overflow: hidden; + vertical-align: text-bottom; + } + + & input { + width: 100%; + padding: 0.3em; + margin-top: 0.3em; + margin-bottom: 0.3em; + } + & .actions { + text-align: right; + & a { + margin-left: 0.5em; + } + } + } + + & > .queue { + & a.retry { + float: right; + margin-left: 0.5em; + } + & a.cancel { + float: right; + margin-left: 0.5em; + } + & > .pending { + color: #770; + } + & > .stale { + color: #777; + } + & > .faulty { + color: #700; + } + & .parameter { + font-style: italic; + max-width: 400px; + text-overflow: ellipsis; + display: inline-block; + overflow: hidden; + vertical-align: text-bottom; + } + & .value { + max-width: 200px; + text-overflow: ellipsis; + display: inline-block; + overflow: hidden; + vertical-align: text-bottom; + white-space: nowrap; + } + } + } + & > .notifications-wrapper { + & > .notification { + padding: 0.3em; + left: 0; + right: 0; + background-color: rgba(255, 255, 255, 0.8); + border: 1px solid var(--color1); + border-radius: 6px; + box-shadow: 0 0 10px #ccc; + transition: opacity var(--fade), top var(--fade); + + &.error { + background-color: rgba(255, 200, 200, 0.8); + border: 1px solid rgb(255, 200, 200); + } + &.success { + background-color: rgba(200, 255, 200, 0.8); + border: 1px solid rgb(200, 255, 200); + } + } + } + } & > nav { display: inline-block; overflow: visible; @@ -206,26 +351,29 @@ button { } } +input { + display: block; + border: 1px solid var(--color1); + background-color: var(--color3); + border-radius: 4px; + transition: border-color var(--fade), background-color var(--fade); + &.error { + background-color: #efcbcf; + border-color: #e6b2b7; + } + &:focus, + &:hover { + border-color: var(--color4); + } +} + .filter { margin-bottom: 0.5em; & > input { - padding: 2px; - width: 30em; font-size: 90%; - display: block; - border: 1px solid var(--color1); - background-color: var(--color3); + width: 30em; margin: 2px; - border-radius: 4px; - transition: border-color var(--fade), background-color var(--fade); - &.error { - background-color: #efcbcf; - border-color: #e6b2b7; - } - &:focus, - &:hover { - border-color: var(--color4); - } + padding: 2px; } } @@ -280,6 +428,12 @@ table.parameter-table { } } +span.inform { + & > * { + margin-right: 0.5em; + } +} + .pie-chart { margin: 20px; text-align: center; diff --git a/client/devices-page.js b/client/devices-page.js index 55bbfde..14cb429 100644 --- a/client/devices-page.js +++ b/client/devices-page.js @@ -19,7 +19,6 @@ function init(args) { function renderTable(devices, parameters, total, showMoreCallback) { let labels = []; for (let param of parameters) labels.push(m("th", param.label)); - labels.push(m("th")); let rows = []; for (let device of devices) @@ -29,7 +28,7 @@ function renderTable(devices, parameters, total, showMoreCallback) { parameters .map(p => { const attrs = Object.assign({ device: device }, p); - const comp = m(components.get("parameter"), attrs); + const comp = m(components.get(attrs.type || "parameter"), attrs); return m("td", comp); }) .concat( @@ -49,14 +48,16 @@ function renderTable(devices, parameters, total, showMoreCallback) { ) ); - let footerElements = [`${devices.length}/${total}`]; + let footerElements = []; + if (total != null) footerElements.push(`${devices.length}/${total}`); + else footerElements.push(`${devices.length}`); if (devices.length < total) - footerElements.push(m("a", { onclick: showMoreCallback }, "Show more")); + footerElements.push(m("a", { onclick: showMoreCallback }, "More")); let tfoot = m( "tfoot", - m("tr", m("td", { colspan: labels.length + 1 }, footerElements)) + m("tr", m("td", { colspan: labels.length }, footerElements)) ); return m("table.table", [ diff --git a/client/drawer-component.js b/client/drawer-component.js new file mode 100644 index 0000000..ee8380a --- /dev/null +++ b/client/drawer-component.js @@ -0,0 +1,344 @@ +"use strict"; + +import m from "mithril"; + +import * as taskQueue from "./task-queue"; +import * as store from "./store"; +import * as notifications from "./notifications"; + +function mparam(param) { + return m("span.parameter", { title: param }, `${param}`); +} + +function mval(val) { + return m("span.value", { title: val }, `${val}`); +} + +function renderStaging(staging) { + let elements = []; + for (let s of staging) { + const queueFunc = () => { + staging.delete(s); + taskQueue.queueTask(s); + }; + const cancelFunc = () => { + staging.delete(s); + }; + + const input = m("input", { + value: s.parameterValues[0][1], + oninput: e => { + e.redraw = false; + s.parameterValues[0][1] = input.dom.value; + }, + onkeydown: e => { + if (e.keyCode === 13) queueFunc(); + else if (e.keyCode === 27) cancelFunc(); + else e.redraw = false; + }, + oncreate: vnode => { + vnode.dom.focus(); + vnode.dom.select(); + // Need to prevent scrolling on focus because + // we're animating height and using overflow: hidden + vnode.dom.parentNode.parentNode.scrollTop = 0; + } + }); + const queue = m("a", { onclick: queueFunc }, "Queue"); + + const cancel = m("a", { onclick: cancelFunc }, "Cancel"); + + elements.push( + m( + ".staging", + m("span", "Editing ", mparam(s.parameterValues[0][0])), + input, + m("div.actions", queue, cancel) + ) + ); + } + return elements; +} + +function renderQueue(queue) { + let details = []; + let devices = {}; + for (let t of queue) { + devices[t.device] = devices[t.device] || []; + devices[t.device].push(t); + } + + for (let [k, v] of Object.entries(devices)) { + details.push(m("strong", k)); + for (let t of v) { + let actions = [ + m( + "a.cancel", + { + onclick: () => { + taskQueue.deleteTask(t); + } + }, + "✕" + ) + ]; + if (t.status === "fault" || t.status === "stale") + actions.push( + m( + "a.retry", + { + onclick: () => { + taskQueue.queueTask(t); + } + }, + "↺" + ) + ); + + if (t.name === "setParameterValues") + details.push( + m( + `div.${t.status}`, + actions, + "Set ", + mparam(t.parameterValues[0][0]), + " to '", + mval(t.parameterValues[0][1]), + "'" + ) + ); + else if (t.name === "refreshObject") + details.push( + m(`div.${t.status}`, actions, "Refresh ", mparam(t.parameterName)) + ); + else if (t.name === "reboot") + details.push(m(`div.${t.status}`, actions, "Reboot")); + else if (t.name === "factoryReset") + details.push(m(`div.${t.status}`, actions, "Factory reset")); + else if (t.name === "addObject") + details.push( + m(`div.${t.status}`, actions, "Add ", mparam(t.objectName)) + ); + else if (t.name === "deleteObject") + details.push( + m(`div.${t.status}`, actions, "Delete ", mparam(t.objectName)) + ); + else if (t.name === "getParameterValues") + details.push( + m( + `div.${t.status}`, + actions, + `Refresh ${t.parameterNames.length} parameters` + ) + ); + else details.push(m(`div.${t.status}`, actions, t.name)); + } + } + + return details; +} + +function renderNotifications(notifs) { + const notificationElements = []; + + for (let n of notifs) + notificationElements.push( + m( + "div.notification", + { + class: n.type, + style: "position: absolute;opacity: 0", + oncreate: vnode => { + vnode.dom.style.opacity = 1; + }, + onbeforeremove: vnode => { + vnode.dom.style.opacity = 0; + return new Promise(resolve => { + setTimeout(() => { + resolve(); + }, 500); + }); + }, + key: n.timestamp + }, + n.message + ) + ); + return notificationElements; +} + +const component = { + view: vnode => { + const queue = taskQueue.getQueue(); + const staging = taskQueue.getStaging(); + const notifs = notifications.getNotifications(); + + let drawerElement, statusElement; + let notificationElements = renderNotifications(notifs); + let stagingElements = renderStaging(staging); + let queueElements = renderQueue(queue); + + function repositionNotifications() { + let top = 10; + for (let c of notificationElements) { + c.dom.style.top = top; + top += c.dom.offsetHeight + 10; + } + } + + function resizeDrawer() { + let height = statusElement.dom.offsetTop + statusElement.dom.offsetHeight; + if (stagingElements.length) + for (let s of stagingElements) + height = Math.max(height, s.dom.offsetTop + s.dom.offsetHeight); + else if (vnode.state.mouseIn) + for (let c of drawerElement.children) + height = Math.max(height, c.dom.offsetTop + c.dom.offsetHeight); + drawerElement.dom.style.height = height; + } + + if (stagingElements.length + queueElements.length) { + const statusCount = { queued: 0, pending: 0, faulty: 0, stale: 0 }; + for (let t of queue) statusCount[t.status] += 1; + + let actions; + if (queueElements.length) + if (statusCount.queued) { + actions = m( + ".actions", + m("a.clear", { onclick: taskQueue.clear }, "Clear"), + m( + "a.commit", + { + onclick: () => { + let tasks = Array.from(taskQueue.getQueue()).filter( + t => t.status === "queued" + ); + taskQueue + .commit( + tasks, + (deviceId, connectionRequestStatus, tasks2) => { + if (connectionRequestStatus !== "OK") + notifications.push( + "error", + `${deviceId}: ${connectionRequestStatus}` + ); + else + for (let t of tasks2) + if (t.status === "stale") { + notifications.push( + "error", + `${deviceId}: No contact from device` + ); + return; + } else if (t.status === "fault") { + notifications.push( + "error", + `${deviceId}: Task(s) faulted` + ); + return; + } + + notifications.push( + "success", + `${deviceId}: Task(s) committed` + ); + } + ) + .then(() => { + store.fulfill(Date.now(), Date.now()); + }) + .catch(err => { + notifications.push("error", err.message); + throw err; + }); + } + }, + "Commit" + ) + ); + } else { + actions = m( + ".actions", + m("a.clear", { onclick: taskQueue.clear }, "Clear") + ); + } + + statusElement = m( + ".status", + m( + "span.queued", + { class: statusCount.queued ? "active" : "" }, + `Queued: ${statusCount.queued}` + ), + m( + "span.pending", + { class: statusCount.pending ? "active" : "" }, + `Pending: ${statusCount.pending}` + ), + m( + "span.faulty", + { class: statusCount.faulty ? "active" : "" }, + `Faulty: ${statusCount.faulty}` + ), + m( + "span.stale", + { class: statusCount.stale ? "active" : "" }, + `Stale: ${statusCount.stale}` + ), + actions + ); + + drawerElement = m( + ".drawer", + { + key: "drawer", + style: "opacity: 0;height: 0;", + oncreate: vnode2 => { + vnode.state.mouseIn = false; + vnode2.dom.style.opacity = 1; + resizeDrawer(); + }, + onmouseover: e => { + vnode.state.mouseIn = true; + resizeDrawer(); + e.redraw = false; + }, + onmouseleave: e => { + vnode.state.mouseIn = false; + resizeDrawer(); + e.redraw = false; + }, + onupdate: resizeDrawer, + onbeforeremove: vnode2 => { + vnode2.dom.onmouseover = vnode2.dom.onmouseleave = null; + vnode2.dom.style.opacity = 0; + vnode2.dom.style.height = 0; + return new Promise(resolve => { + setTimeout(resolve, 500); + }); + } + }, + statusElement, + stagingElements.length ? stagingElements : m(".queue", queueElements) + ); + } + + return m( + "div.drawer-wrapper", + drawerElement, + m( + "div.notifications-wrapper", + { + key: "notifications", + style: "position: relative;", + onupdate: repositionNotifications, + oncreate: repositionNotifications + }, + notificationElements + ) + ); + } +}; + +export default component; diff --git a/client/filter-component.js b/client/filter-component.js index f1555e5..9d1caac 100644 --- a/client/filter-component.js +++ b/client/filter-component.js @@ -54,7 +54,7 @@ const component = { [m("b", "Filter"), a].concat( vnode.state.filterList.map((fltr, idx) => { return m("input", { - type: "string", + type: "text", list: "filters", class: `${(vnode.state.filterInvalid >> idx) & 1 ? "error" : ""}`, value: fltr, diff --git a/client/layout.js b/client/layout.js index 47d7fb9..8177219 100644 --- a/client/layout.js +++ b/client/layout.js @@ -3,13 +3,15 @@ import m from "mithril"; import menu from "./menu"; +import drawerComponent from "./drawer-component"; const layout = { view: vnode => { return [ m("#header", [ m("img.logo", { src: "logo.svg" }), - m(menu, { page: vnode.attrs.page }) + m(menu, { page: vnode.attrs.page }), + m(drawerComponent) ]), m("#content", { class: `page-${vnode.attrs.page}` }, [vnode.children]) ]; diff --git a/client/notifications.js b/client/notifications.js new file mode 100644 index 0000000..48c3827 --- /dev/null +++ b/client/notifications.js @@ -0,0 +1,21 @@ +"use strict"; + +import m from "mithril"; + +const notifications = new Set(); + +function push(type, message) { + const n = { type: type, message: message, timestamp: Date.now() }; + notifications.add(n); + m.redraw(); + setTimeout(() => { + notifications.delete(n); + m.redraw(); + }, 4000); +} + +function getNotifications() { + return notifications; +} + +export { push, getNotifications }; diff --git a/client/store.js b/client/store.js index 991ec19..9d6e0b2 100644 --- a/client/store.js +++ b/client/store.js @@ -304,4 +304,44 @@ function getTimestamp() { return fulfillTimestamp; } -export { count, fetch, fulfill, unpackFilter, getTimestamp }; +function postTasks(tasks, callback) { + return new Promise((resolve, reject) => { + let devices = {}; + for (let t of tasks) { + t.status = "pending"; + devices[t.device] = devices[t.device] || []; + devices[t.device].push(t); + } + + let promises = []; + for (let [deviceId, tasks2] of Object.entries(devices)) + promises.push( + m.request({ + method: "POST", + url: `/api/devices/${encodeURIComponent(deviceId)}/tasks`, + data: tasks2, + extract: xhr => { + const connectionRequestStatus = xhr.getResponseHeader( + "Connection-Request" + ); + let st = JSON.parse(xhr.response); + for (let [i, t] of st.entries()) { + tasks2[i]._id = t._id; + tasks2[i].status = t.status; + tasks2[i].fault = t.fault; + } + if (callback) callback(deviceId, connectionRequestStatus, tasks2); + } + }) + ); + + Promise.all(promises) + .then(() => resolve(tasks)) + .catch(err => { + callback = null; + reject(err); + }); + }); +} + +export { count, fetch, fulfill, unpackFilter, getTimestamp, postTasks }; diff --git a/client/task-queue.js b/client/task-queue.js new file mode 100644 index 0000000..76a8d41 --- /dev/null +++ b/client/task-queue.js @@ -0,0 +1,62 @@ +"use strict"; + +import * as store from "./store"; + +const queue = new Set(); +const staging = new Set(); + +function queueTask(task) { + staging.delete(task); + task.status = "queued"; + queue.add(task); +} + +function deleteTask(task) { + staging.delete(task); + queue.delete(task); +} + +function getQueue() { + return queue; +} + +function clear() { + queue.clear(); +} + +function getStaging() { + return staging; +} + +function clearStaging() { + staging.clear(); +} + +function stageSpv(task) { + staging.add(task); +} + +function commit(tasks, callback) { + for (let t of tasks) { + t.status = "pending"; + queue.add(t); + } + + return store.postTasks(tasks, (deviceId, connectionRequestStatus, tasks2) => { + for (let t of tasks2) + if (t.status === "pending") t.status = "stale"; + else if (t.status === "done") queue.delete(t); + if (callback) callback(deviceId, connectionRequestStatus, tasks2); + }); +} + +export { + queueTask, + deleteTask, + clear, + getQueue, + getStaging, + clearStaging, + stageSpv, + commit +}; diff --git a/config.json b/config.json index 6759c01..24fa0c0 100644 --- a/config.json +++ b/config.json @@ -25,8 +25,11 @@ "ui.index.5.parameter": "InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID", "ui.index.6.label": "Last inform", "ui.index.6.parameter": "Events.Inform", - "ui.index.6.meta.type": "overview-dot", - "ui.index.6.meta.chart": "online", + "ui.index.6.type": "container", + "ui.index.6.element": "span.inform", + "ui.index.6.components.1.type": "parameter", + "ui.index.6.components.2.type": "overview-dot", + "ui.index.6.components.2.chart": "online", "ui.overview.groups.online.label": "", "ui.overview.groups.online.charts.1": "online", "ui.overview.charts.online.label": "Online status", @@ -40,43 +43,53 @@ "ui.overview.charts.online.slices.others.color": "#f7fcb9", "ui.overview.charts.online.slices.others.filter": "Events.Inform < (NOW() - 5 * 60 * 1000) - (24 * 60 * 60 * 1000)", "ui.device.1.type": "parameter-list", + "ui.device.1.parameters.1.type": "container", "ui.device.1.parameters.1.label": "Last inform", + "ui.device.1.parameters.1.element": "span.inform", "ui.device.1.parameters.1.parameter": "Events.Inform", - "ui.device.1.parameters.1.meta.type": "overview-dot", - "ui.device.1.parameters.1.meta.chart": "online", + "ui.device.1.parameters.1.components.1.type": "parameter", + "ui.device.1.parameters.1.components.2.type": "overview-dot", + "ui.device.1.parameters.1.components.2.chart": "online", + "ui.device.1.parameters.1.components.3.type": "summon-button", + "ui.device.1.parameters.1.components.3.parameters.1": "InternetGatewayDevice.DeviceInfo.HardwareVersion", + "ui.device.1.parameters.1.components.3.parameters.2": "InternetGatewayDevice.DeviceInfo.SoftwareVersion", + "ui.device.1.parameters.1.components.3.parameters.3": "InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.MACAddress", + "ui.device.1.parameters.1.components.3.parameters.4": "InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress", + "ui.device.1.parameters.1.components.3.parameters.5": "InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID", + "ui.device.1.parameters.1.components.3.parameters.6": "InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.KeyPassphrase", + "ui.device.1.parameters.1.components.3.parameters.7": "InternetGatewayDevice.LANDevice.1.Hosts.Host", "ui.device.1.parameters.2.label": "Last boot", "ui.device.1.parameters.2.parameter": "Events.1_BOOT", "ui.device.1.parameters.3.label": "Last bootstrap", "ui.device.1.parameters.3.parameter": "Events.0_BOOTSTRAP", "ui.device.1.parameters.4.label": "Registered", "ui.device.1.parameters.4.parameter": "Events.Registered", - "ui.device.2.type": "parameter-list", - "ui.device.2.parameters.1.label": "Serial number", - "ui.device.2.parameters.1.parameter": "InternetGatewayDevice.DeviceInfo.SerialNumber", - "ui.device.2.parameters.2.label": "Product class", - "ui.device.2.parameters.2.parameter": "InternetGatewayDevice.DeviceInfo.ProductClass", - "ui.device.2.parameters.3.label": "OUI", - "ui.device.2.parameters.3.parameter": "InternetGatewayDevice.DeviceInfo.ManufacturerOUI", - "ui.device.2.parameters.4.label": "Manufacturer", - "ui.device.2.parameters.4.parameter": "InternetGatewayDevice.DeviceInfo.Manufacturer", - "ui.device.2.parameters.5.label": "Hardware version", - "ui.device.2.parameters.5.parameter": "InternetGatewayDevice.DeviceInfo.HardwareVersion", - "ui.device.2.parameters.6.label": "Software version", - "ui.device.2.parameters.6.parameter": "InternetGatewayDevice.DeviceInfo.SoftwareVersion", - "ui.device.2.parameters.7.label": "MAC", - "ui.device.2.parameters.7.parameter": "InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.MACAddress", - "ui.device.2.parameters.8.label": "IP", - "ui.device.2.parameters.8.parameter": "InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress", - "ui.device.2.parameters.8.label": "WLAN SSID", - "ui.device.2.parameters.8.parameter": "InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID", - "ui.device.2.parameters.9.label": "WLAN passphrase", - "ui.device.2.parameters.9.parameter": "InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.KeyPassphrase", - "ui.device.3.type": "parameter-table", - "ui.device.3.object": "InternetGatewayDevice.LANDevice.1.Hosts.Host", - "ui.device.3.parameters.1.label": "Host name", - "ui.device.3.parameters.1.parameter": "HostName", - "ui.device.3.parameters.2.label": "IP address", - "ui.device.3.parameters.2.parameter": "IPAddress", - "ui.device.3.parameters.3.label": "MAC address", - "ui.device.3.parameters.3.parameter": "MACAddress" + "ui.device.1.parameters.2.label": "Serial number", + "ui.device.1.parameters.2.parameter": "InternetGatewayDevice.DeviceInfo.SerialNumber", + "ui.device.1.parameters.3.label": "Product class", + "ui.device.1.parameters.3.parameter": "InternetGatewayDevice.DeviceInfo.ProductClass", + "ui.device.1.parameters.4.label": "OUI", + "ui.device.1.parameters.4.parameter": "InternetGatewayDevice.DeviceInfo.ManufacturerOUI", + "ui.device.1.parameters.5.label": "Manufacturer", + "ui.device.1.parameters.5.parameter": "InternetGatewayDevice.DeviceInfo.Manufacturer", + "ui.device.1.parameters.6.label": "Hardware version", + "ui.device.1.parameters.6.parameter": "InternetGatewayDevice.DeviceInfo.HardwareVersion", + "ui.device.1.parameters.7.label": "Software version", + "ui.device.1.parameters.7.parameter": "InternetGatewayDevice.DeviceInfo.SoftwareVersion", + "ui.device.1.parameters.8.label": "MAC", + "ui.device.1.parameters.8.parameter": "InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.MACAddress", + "ui.device.1.parameters.9.label": "IP", + "ui.device.1.parameters.9.parameter": "InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress", + "ui.device.1.parameters.10.label": "WLAN SSID", + "ui.device.1.parameters.10.parameter": "InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID", + "ui.device.1.parameters.11.label": "WLAN passphrase", + "ui.device.1.parameters.11.parameter": "InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.KeyPassphrase", + "ui.device.2.type": "parameter-table", + "ui.device.2.object": "InternetGatewayDevice.LANDevice.1.Hosts.Host", + "ui.device.2.parameters.1.label": "Host name", + "ui.device.2.parameters.1.parameter": "HostName", + "ui.device.2.parameters.2.label": "IP address", + "ui.device.2.parameters.2.parameter": "IPAddress", + "ui.device.2.parameters.3.label": "MAC address", + "ui.device.2.parameters.3.parameter": "MACAddress" } diff --git a/package.json b/package.json index c0c4b8b..407203c 100644 --- a/package.json +++ b/package.json @@ -10,20 +10,22 @@ }, "author": "Zaid Abdulla ", "dependencies": { - "koa": "^2.4.1", + "koa": "^2.5.0", + "koa-body": "^2.5.0", "koa-bodyparser": "^4.2.0", - "koa-router": "^7.3.0", + "koa-compress": "^2.0.0", + "koa-router": "^7.4.0", "koa-static": "^4.0.2", - "mithril": "^1.1.5", - "parsimmon": "^1.6.2" + "mithril": "^1.1.6", + "parsimmon": "^1.7.2" }, "devDependencies": { "cssnano": "^3.10.0", - "eslint": "^4.12.1", + "eslint": "^4.19.1", "postcss-cli": "^4.1.1", - "postcss-cssnext": "^3.0.2", - "postcss-import": "^11.0.0", - "prettier": "^1.8.2", - "webpack": "^3.9.1" + "postcss-cssnext": "^3.1.0", + "postcss-import": "^11.1.0", + "prettier": "^1.11.1", + "webpack": "^3.11.0" } } diff --git a/server/api-functions.js b/server/api-functions.js index e4b60e2..db685d9 100644 --- a/server/api-functions.js +++ b/server/api-functions.js @@ -8,6 +8,7 @@ const querystring = require("querystring"); const config = require("./config"); const device = require("./device"); const filterParser = require("../common/filter-parser"); +const Filter = require("../common/filter"); const commaAscii = ",".charCodeAt(0); const newLineAscii = "\n".charCodeAt(0); @@ -205,5 +206,130 @@ function query(resource, filter, limit, skip, projection, callback) { }); } +function deleteResource(resource, id) { + return new Promise((resolve, reject) => { + let options = url.parse( + `${config.get("server.nbi")}${resource}/${encodeURIComponent(id)}` + ); + + options.method = "DELETE"; + + let _http = options.protocol === "https:" ? https : http; + + _http + .request(options, res => { + res.resume(); + if (res.statusCode === 200) resolve(true); + else if (res.statusCode === 404) resolve(false); + else reject(new Error(`Unexpected status code ${res.statusCode}`)); + }) + .end(); + }); +} + +function postTask(task, connectionRequest, callback) { + let options = url.parse( + `${config.get("server.nbi")}devices/${task.device}/tasks${ + connectionRequest ? "?connection_request" : "" + }` + ); + options.method = "POST"; + let _http = options.protocol === "https:" ? https : http; + + _http + .request(options, res => { + if (res.statusCode !== 200 && res.statusCode !== 202) { + callback(new Error(`Unexpected status code ${res.statusCode}`)); + res.resume(); + return; + } + const chunks = []; + let bytes = 0; + res.on("data", chunk => { + chunks.push(chunk); + bytes += chunk.length; + }); + + res.on("end", () => { + let buf = new Buffer(bytes); + let o = 0; + for (let c of chunks) { + c.copy(buf, o, 0, c.length); + o += c.length; + } + try { + let t = JSON.parse(buf); + let connectionRequestStatus; + if (connectionRequest) { + connectionRequestStatus = "OK"; + if ( + res.statusCode === 202 && + res.statusMessage !== "Task queued but not processed" && + res.statusMessage !== "Task faulte" + ) + connectionRequestStatus = res.statusMessage; + } + callback(null, t, connectionRequestStatus); + } catch (err) { + callback(err); + } + }); + }) + .end(JSON.stringify(task)); +} + +function postTasks(deviceId, tasks) { + return new Promise((resolve, reject) => { + let connectionRequestStatus; + let promises = []; + for (let [idx, task] of tasks.entries()) { + task.device = deviceId; + promises.push( + new Promise((res, rej) => { + let conReq = idx === tasks.length - 1; + delete task._id; + task.expiry = 5; + postTask(task, conReq, (err, t, crs) => { + if (err) return rej(err); + if (conReq) connectionRequestStatus = crs; + res({ _id: t._id, status: "pending" }); + }); + }) + ); + } + + Promise.all(promises) + .then(statuses => { + if (connectionRequestStatus !== "OK") + return resolve({ + connectionRequest: connectionRequestStatus, + tasks: statuses + }); + Promise.all( + statuses.map(s => query("tasks", new Filter(["=", "_id", s._id]))) + ) + .then(res => { + for (let [i, r] of res.entries()) { + if (!r[0]) { + statuses[i].status = "done"; + } else if (r[0].fault) { + statuses[i].status = "fault"; + statuses[i].fault = r[0].fault; + } + deleteResource("tasks", statuses[i]._id); + } + + resolve({ + connectionRequest: connectionRequestStatus, + tasks: statuses + }); + }) + .catch(reject); + }) + .catch(reject); + }); +} + exports.query = query; exports.count = count; +exports.postTasks = postTasks; diff --git a/server/api.js b/server/api.js index 890c025..01b84a4 100644 --- a/server/api.js +++ b/server/api.js @@ -12,7 +12,8 @@ const resources = { presets: true, provisions: true, files: true, - virtual_parameters: true + virtual_parameters: true, + tasks: false }; for (let [resource, update] of Object.entries(resources)) { @@ -60,4 +61,11 @@ for (let [resource, update] of Object.entries(resources)) { } } +router.post("/devices/:id/tasks", async ctx => { + let res = await apiFunctions.postTasks(ctx.params.id, ctx.request.body); + // TODO 404 if no such device + ctx.set("Connection-Request", res.connectionRequest); + ctx.body = res.tasks; +}); + module.exports = router; diff --git a/server/device.js b/server/device.js index 048a572..99470f2 100644 --- a/server/device.js +++ b/server/device.js @@ -114,12 +114,12 @@ function recursive(input, root, output, timestamp) { attrs["valueTimestamp"] = childrenTimestamp; attrs["object"] = false; attrs["objectTimestamp"] = childrenTimestamp; - } else if (tree["_object"]) { + } else if (tree["_object"] != null) { attrs["object"] = tree["_object"]; attrs["objectTimestamp"] = childrenTimestamp; } - if (tree["_writable"]) { + if (tree["_writable"] != null) { attrs["writable"] = tree["_writable"]; attrs["writableTimestamp"] = childrenTimestamp; } diff --git a/server/server.js b/server/server.js index c3e09e6..a39489f 100644 --- a/server/server.js +++ b/server/server.js @@ -3,6 +3,8 @@ const Koa = require("koa"); const Router = require("koa-router"); const koaStatic = require("koa-static"); +const koaCompress = require("koa-compress"); +const koaBody = require("koa-body"); const config = require("./config"); const api = require("./api"); @@ -10,6 +12,7 @@ const api = require("./api"); const koa = new Koa(); const router = new Router(); +koa.use(koaBody()); router.use("/api", api.routes(), api.allowedMethods()); router.get("/", async ctx => { @@ -30,6 +33,11 @@ router.get("/", async ctx => { `; }); +koa.use( + koaCompress({ + flush: require("zlib").Z_SYNC_FLUSH + }) +); koa.use(router.routes()); koa.use(koaStatic("./public"));