diff --git a/client/app.js b/client/app.js index d368804..9a9ef0d 100644 --- a/client/app.js +++ b/client/app.js @@ -8,6 +8,7 @@ import * as overviewPage from "./overview-page"; import * as devicesPage from "./devices-page"; import * as devicePage from "./device-page"; import * as errorPage from "./error-page"; +import * as faultsPage from "./faults-page"; let state; @@ -65,5 +66,6 @@ function pagify(pageName, page) { m.route(document.body, "/overview", { "/overview": pagify("overview", overviewPage), "/devices": pagify("devices", devicesPage), - "/devices/:id": pagify("devices", devicePage) + "/devices/:id": pagify("devices", devicePage), + "/faults": pagify("faults", faultsPage) }); diff --git a/client/components.js b/client/components.js index 668a94c..bbaf75b 100644 --- a/client/components.js +++ b/client/components.js @@ -6,6 +6,7 @@ import parameterTable from "./components/parameter-table"; import overviewDot from "./components/overview-dot"; import container from "./components/container"; import summonButton from "./components/summon-button"; +import deviceFaults from "./components/device-faults"; function get(name) { if (name === "parameter") return parameter; @@ -14,6 +15,7 @@ function get(name) { else if (name === "overview-dot") return overviewDot; else if (name === "container") return container; else if (name === "summon-button") return summonButton; + else if (name === "device-faults") return deviceFaults; else throw new Error(`No such component '${name}'`); } diff --git a/client/components/device-faults.js b/client/components/device-faults.js new file mode 100644 index 0000000..7c9d928 --- /dev/null +++ b/client/components/device-faults.js @@ -0,0 +1,71 @@ +"use strict"; + +import m from "mithril"; +import * as store from "../store"; +import Filter from "../../common/filter"; +import * as notifications from "../notifications"; + +const component = { + view: vnode => { + const device = vnode.attrs.device; + const deviceId = device["DeviceID.ID"].value[0]; + const faults = store.fetch( + "faults", + new Filter([ + "AND", + [">", "_id", `${deviceId}:`], + ["<", "_id", `${deviceId}:zzzz`] + ]) + ); + + const headers = ["Channel", "Code", "Message", "Retries", "Timestamp"].map( + l => m("th", l) + ); + const thead = m("thead", m("tr", headers)); + + const rows = []; + for (let f of faults.value) + rows.push([ + m("td", f["channel"]), + m("td", f["code"]), + m("td", f["message"]), + m("td", f["retries"]), + m("td", f["timestamp"]), + m( + "td", + m( + "button", + { + title: "Delete fault", + onclick: e => { + e.redraw = false; + store + .deleteResource("faults", f["_id"]) + .then(() => { + notifications.push("success", "Fault deleted"); + store.fulfill(Date.now(), Date.now()); + m.redraw(); + }) + .catch(err => { + notifications.push("error", err.message); + }); + } + }, + "✕" + ) + ) + ]); + + let tbody; + if (rows.length) tbody = m("tbody", rows.map(r => m("tr", r))); + else + tbody = m( + "tbody", + m("tr.empty", m("td", { colspan: headers.length }, "No faults")) + ); + + return [m("h2", "Faults"), m("table.table", thead, tbody)]; + } +}; + +export default component; diff --git a/client/components/parameter-table.js b/client/components/parameter-table.js index 40f9b5d..b11fb29 100644 --- a/client/components/parameter-table.js +++ b/client/components/parameter-table.js @@ -43,8 +43,9 @@ const component = { m( "td", m( - "a.delete", + "button", { + title: "Delete this instance", onclick: () => { taskQueue.queueTask({ name: "deleteObject", @@ -53,12 +54,18 @@ const component = { }); } }, - "−" + "✕" ) ) ); rows.push(m("tr", row)); } + + if (!rows.length) + rows.push( + m("tr.empty", m("td", { colspan: headers.length }, "No instances")) + ); + if (device[object].writable === true) rows.push( m( @@ -67,8 +74,9 @@ const component = { m( "td", m( - "a.add", + "button", { + title: "Create a new instance", onclick: () => { taskQueue.queueTask({ name: "addObject", @@ -77,12 +85,12 @@ const component = { }); } }, - "+" + "🞢" ) ) ) ); - return m("table.parameter-table", thead, m("tbody", rows)); + return m("table.table", thead, m("tbody", rows)); } }; diff --git a/client/components/parameter.js b/client/components/parameter.js index 1511784..7a8e520 100644 --- a/client/components/parameter.js +++ b/client/components/parameter.js @@ -14,8 +14,9 @@ const component = { let edit; if (param.writable) edit = m( - "a.edit", + "button", { + title: "Edit parameter value", onclick: () => { taskQueue.stageSpv({ name: "setParameterValues", diff --git a/client/components/summon-button.js b/client/components/summon-button.js index 2e75669..e814e20 100644 --- a/client/components/summon-button.js +++ b/client/components/summon-button.js @@ -8,9 +8,11 @@ import * as notifications from "../notifications"; const component = { view: vnode => { return m( - "a.summon", + "button.primary", { - onclick: () => { + title: "Initiate session and refresh basic parameters", + onclick: e => { + e.target.disabled = true; const params = Object.values(vnode.attrs.parameters); const task = { name: "getParameterValues", @@ -37,7 +39,8 @@ const component = { else notifications.push("success", `${deviceId}: Summoned`); }) .then(() => { - store.fulfill(Date.now(), Date.now()); + e.target.disabled = false; + store.fulfill(0, Date.now()); }); } }, diff --git a/client/css/app.css b/client/css/app.css index b775d0e..77bd893 100644 --- a/client/css/app.css +++ b/client/css/app.css @@ -11,6 +11,8 @@ --color5: #002f2f; --fade: 0.2s; + + --disabled: #666; } body, @@ -83,6 +85,7 @@ i { } & > .status { + display: flex; & > span { margin-right: 1em; } @@ -95,7 +98,7 @@ i { & > .stale { color: #777; } - & > .faulty { + & > .fault { color: #700; } & > .active { @@ -103,14 +106,8 @@ i { } & > .actions { - float: right; - & a { - float: right; - margin-left: 0.5em; - } - & > .commit { - font-weight: bold; - } + flex-grow: 1; + text-align: right; } } @@ -132,28 +129,17 @@ i { } & .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 { + & > .fault { color: #700; } & .parameter { @@ -172,6 +158,13 @@ i { vertical-align: text-bottom; white-space: nowrap; } + & > div { + display: flex; + & > .actions { + flex-grow: 1; + text-align: right; + } + } } } & > .notifications-wrapper { @@ -263,24 +256,29 @@ table.table { & td { margin: 0; padding: 0.2em 0.5em; - border-bottom: 1px dotted var(--color1); - & .action a { - visibility: hidden; - } - } - - & tbody tr:hover td { - & .action { - visibility: visible; - } - - transition: background-color var(--fade); - background-color: var(--color3); } & tfoot td > a { margin-left: 1em; } + + &.highlight > tbody > tr { + & > td { + border-bottom: 1px dotted var(--color1); + } + transition: background-color var(--fade); + &:hover { + background-color: var(--color3); + } + } + + & tbody > tr.empty { + text-align: center; + color: var(--disabled); + &:hover { + background-color: initial; + } + } } h1 { @@ -314,40 +312,28 @@ input::-moz-focus-inner { input[type="button"], button { - display: inline-block; - min-width: 6em; - position: relative; - top: 0px; - vertical-align: baseline; - margin: 0 0.1em; - outline: none; - cursor: pointer; - text-align: center; - text-decoration: none; - padding: 0.1em 0.6em; - border-top-left-radius: 1em; - border-bottom-right-radius: 1em; - border-top-right-radius: 0.2em; - border-bottom-left-radius: 0.2em; - + background: transparent; + border: 0; color: var(--color4); + border-radius: 4px; transition: background-color var(--fade), color var(--fade); - - &:link, - &:visited { - color: var(--color4); - background-color: var(--color1); + &.primary { + border: 1px solid var(--color1); + background-color: var(--color3); } - - &:hover, - &:active { - color: var(--color1); + &.critical { + border: 1px solid var(--color1); + background-color: var(--color3); + font-weight: bold; + color: #900; + } + &:hover:enabled { background-color: var(--color4); + color: var(--color3); } - &:disabled { - background-color: var(--color1); - color: var(--color2); + color: var(--disabled); + cursor: not-allowed; } } @@ -399,23 +385,6 @@ table.parameter-list { } } -table.parameter-table { - border-spacing: 0; - text-align: left; - - & th { - color: var(--color4); - border-bottom: 2px solid var(--color4); - margin: 0; - padding: 0.2em 0.5em; - } - - & td { - margin: 0; - padding: 0.2em 0.5em; - } -} - .overview-dot { & > svg { margin-bottom: -0.125em; diff --git a/client/devices-page.js b/client/devices-page.js index 14cb429..354d69d 100644 --- a/client/devices-page.js +++ b/client/devices-page.js @@ -16,51 +16,102 @@ function init(args) { }); } -function renderTable(devices, parameters, total, showMoreCallback) { - let labels = []; +function renderTable( + devicesResponse, + parameters, + total, + selected, + showMoreCallback +) { + const devices = devicesResponse.value; + const selectAll = m("input", { + type: "checkbox", + checked: devices.length && selected.size === devices.length, + onchange: e => { + for (let d of devices) + if (e.target.checked) selected.add(d["DeviceID.ID"].value[0]); + else selected.delete(d["DeviceID.ID"].value[0]); + }, + disabled: !total + }); + + let labels = [m("th", selectAll)]; for (let param of parameters) labels.push(m("th", param.label)); let rows = []; - for (let device of devices) + for (let device of devices) { + let checkbox = m("input", { + type: "checkbox", + checked: selected.has(device["DeviceID.ID"].value[0]), + onchange: e => { + if (e.target.checked) selected.add(device["DeviceID.ID"].value[0]); + else selected.delete(device["DeviceID.ID"].value[0]); + } + }); + rows.push( m( "tr", - parameters - .map(p => { - const attrs = Object.assign({ device: device }, p); - const comp = m(components.get(attrs.type || "parameter"), attrs); - return m("td", comp); - }) - .concat( - m( - "td.table-row-links", - m( - "a", - { - href: `#!/devices/${encodeURIComponent( - device["DeviceID.ID"].value[0] - )}` - }, - "Show" - ) - ) + { + onclick: e => { + if (["INPUT", "BUTTON", "A"].includes(e.target.nodeName)) { + e.redraw = false; + return; + } + + if (!selected.delete(device["DeviceID.ID"].value[0])) + selected.add(device["DeviceID.ID"].value[0]); + } + }, + m("td", checkbox), + parameters.map(p => { + const attrs = Object.assign({ device: device }, p); + const comp = m(components.get(attrs.type || "parameter"), attrs); + return m("td", comp); + }), + m( + "td.table-row-links", + m( + "a", + { + href: `#!/devices/${encodeURIComponent( + device["DeviceID.ID"].value[0] + )}` + }, + "Show" ) + ) ) ); + } + + if (!rows.length) + rows.push( + m("tr.empty", m("td", { colspan: parameters.length + 1 }, "No devices")) + ); 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 }, "More")); + footerElements.push( + m( + "button", + { + title: "Show more devices", + onclick: showMoreCallback, + disabled: devices.length >= total || !devicesResponse.fulfilled + }, + "More" + ) + ); let tfoot = m( "tfoot", m("tr", m("td", { colspan: labels.length }, footerElements)) ); - return m("table.table", [ + return m("table.table.highlight", [ m("thead", m("tr", labels)), m("tbody", rows), tfoot @@ -89,16 +140,25 @@ const component = { ); let count = store.count("devices", vnode.attrs.filter); + let selected = new Set(); + if (vnode.state.selected) + for (let d of devs.value) + if (vnode.state.selected.has(d["DeviceID.ID"].value[0])) + selected.add(d["DeviceID.ID"].value[0]); + vnode.state.selected = selected; + return [ m("h1", "Listing devices"), m(filterComponent, { + predefined: Object.values(config.get("ui.filters")), filter: vnode.attrs.filter ? vnode.attrs.filter.ast : null, onChange: onFilterChanged }), renderTable( - devs.value, + devs, Object.values(config.get("ui.index")), count.value, + selected, showMore ) ]; diff --git a/client/drawer-component.js b/client/drawer-component.js index ee8380a..3af1f3c 100644 --- a/client/drawer-component.js +++ b/client/drawer-component.js @@ -44,9 +44,17 @@ function renderStaging(staging) { vnode.dom.parentNode.parentNode.scrollTop = 0; } }); - const queue = m("a", { onclick: queueFunc }, "Queue"); - const cancel = m("a", { onclick: cancelFunc }, "Cancel"); + const queue = m( + "button.primary", + { title: "Queue task", onclick: queueFunc }, + "Queue" + ); + const cancel = m( + "button", + { title: "Cancel edit", onclick: cancelFunc }, + "Cancel" + ); elements.push( m( @@ -71,22 +79,14 @@ function renderQueue(queue) { 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); - } - }, - "✕" - ) - ]; + let actions = []; + if (t.status === "fault" || t.status === "stale") actions.push( m( - "a.retry", + "button", { + title: "Retry this task", onclick: () => { taskQueue.queueTask(t); } @@ -95,43 +95,73 @@ function renderQueue(queue) { ) ); + actions.push( + m( + "button", + { + title: "Remove this task", + onclick: () => { + taskQueue.deleteTask(t); + } + }, + "✕" + ) + ); + if (t.name === "setParameterValues") details.push( m( `div.${t.status}`, - actions, - "Set ", - mparam(t.parameterValues[0][0]), - " to '", - mval(t.parameterValues[0][1]), - "'" + m( + "span", + "Set ", + mparam(t.parameterValues[0][0]), + " to '", + mval(t.parameterValues[0][1]), + "'" + ), + m(".actions", actions) ) ); else if (t.name === "refreshObject") details.push( - m(`div.${t.status}`, actions, "Refresh ", mparam(t.parameterName)) + m( + `div.${t.status}`, + m("span", "Refresh ", mparam(t.parameterName)), + m(".actions", actions) + ) ); else if (t.name === "reboot") - details.push(m(`div.${t.status}`, actions, "Reboot")); + details.push(m(`div.${t.status}`, "Reboot", m(".actions", actions))); else if (t.name === "factoryReset") - details.push(m(`div.${t.status}`, actions, "Factory reset")); + details.push( + m(`div.${t.status}`, "Factory reset", m(".actions", actions)) + ); else if (t.name === "addObject") details.push( - m(`div.${t.status}`, actions, "Add ", mparam(t.objectName)) + m( + `div.${t.status}`, + m("span", "Add ", mparam(t.objectName)), + m(".actions", actions) + ) ); else if (t.name === "deleteObject") details.push( - m(`div.${t.status}`, actions, "Delete ", mparam(t.objectName)) + m( + `div.${t.status}`, + m("span", "Delete ", mparam(t.objectName)), + m(".actions", actions) + ) ); else if (t.name === "getParameterValues") details.push( m( `div.${t.status}`, - actions, - `Refresh ${t.parameterNames.length} parameters` + `Refresh ${t.parameterNames.length} parameters`, + m(".actions", actions) ) ); - else details.push(m(`div.${t.status}`, actions, t.name)); + else details.push(m(`div.${t.status}`, t.name, m(".actions", actions))); } } @@ -198,71 +228,71 @@ const component = { } if (stagingElements.length + queueElements.length) { - const statusCount = { queued: 0, pending: 0, faulty: 0, stale: 0 }; + const statusCount = { queued: 0, pending: 0, fault: 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; - } + let actions = m( + ".actions", + m( + "button.primary", + { + title: "Commit queued tasks", + disabled: !statusCount.queued, + 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}` + ); + 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") - ); - } + 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(0, Date.now()); + }) + .catch(err => { + notifications.push("error", err.message); + throw err; + }); + } + }, + "Commit" + ), + m( + "button", + { + title: "Clear tasks", + onclick: taskQueue.clear, + disabled: !queueElements.length + }, + "Clear" + ) + ); statusElement = m( ".status", @@ -277,9 +307,9 @@ const component = { `Pending: ${statusCount.pending}` ), m( - "span.faulty", - { class: statusCount.faulty ? "active" : "" }, - `Faulty: ${statusCount.faulty}` + "span.fault", + { class: statusCount.fault ? "active" : "" }, + `Fault: ${statusCount.fault}` ), m( "span.stale", diff --git a/client/faults-page.js b/client/faults-page.js new file mode 100644 index 0000000..571ef10 --- /dev/null +++ b/client/faults-page.js @@ -0,0 +1,188 @@ +"use strict"; + +import m from "mithril"; + +import filterComponent from "./filter-component"; +import * as filterParser from "../common/filter-parser"; +import Filter from "../common/filter"; +import * as store from "./store"; +import * as notifications from "./notifications"; + +function init(args) { + return new Promise(resolve => { + let filter = new Filter(args.filter); + resolve({ filter: filter }); + }); +} + +function renderTable(faultsResponse, total, selected, showMoreCallback) { + const faults = faultsResponse.value; + const selectAll = m("input", { + type: "checkbox", + checked: faults.length && selected.size === faults.length, + onchange: e => { + for (let f of faults) + if (e.target.checked) selected.add(f["_id"]); + else selected.delete(f["_id"]); + }, + disabled: !total + }); + + const labels = [ + selectAll, + "Device", + "Channel", + "Code", + "Message", + "Retries", + "Timestamp" + ].map(l => m("th", l)); + + let rows = []; + for (let f of faults) { + let checkbox = m("input", { + type: "checkbox", + checked: selected.has(f["_id"]), + onchange: e => { + if (e.target.checked) selected.add(f["_id"]); + else selected.delete(f["_id"]); + }, + onclick: e => { + e.stopPropagation(); + e.redraw = false; + } + }); + + const deviceHref = `#!/devices/${encodeURIComponent(f["device"])}`; + + rows.push( + m( + "tr", + { + onclick: e => { + if (["INPUT", "BUTTON", "A"].includes(e.target.nodeName)) { + e.redraw = false; + return; + } + + if (!selected.delete(f["_id"])) selected.add(f["_id"]); + } + }, + m("td", checkbox), + m("td", m("a", { href: deviceHref }, f["device"])), + m("td", f["channel"]), + m("td", f["code"]), + m("td", f["message"]), + m("td", f["retries"]), + m("td", f["timestamp"]) + ) + ); + } + + if (!rows.length) + rows.push(m("tr.empty", m("td", { colspan: 7 }, "No faults"))); + + let footerElements = []; + if (total != null) footerElements.push(`${faults.length}/${total}`); + else footerElements.push(`${faults.length}`); + + footerElements.push( + m( + "button", + { + title: "Show more faults", + onclick: showMoreCallback, + disabled: faults.length >= total || !faultsResponse.fulfilled + }, + "More" + ) + ); + + let tfoot = m( + "tfoot", + m("tr", m("td", { colspan: labels.length }, footerElements)) + ); + + const buttons = [ + m( + "button.primary", + { + title: "Delete selected faults", + disabled: !selected.size, + onclick: e => { + e.redraw = false; + e.target.disabled = true; + Promise.all( + Array.from(selected).map(id => store.deleteResource("faults", id)) + ) + .then(res => { + notifications.push("success", `${res.length} faults deleted`); + store.fulfill(0, Date.now()); + }) + .catch(err => { + notifications.push("error", err.message); + }); + } + }, + "Delete" + ) + ]; + + return [ + m( + "table.table.highlight", + m("thead", m("tr", labels)), + m("tbody", rows), + tfoot + ), + buttons + ]; +} + +const component = { + view: vnode => { + document.title = "Faults - GenieACS"; + + function showMore() { + vnode.state.showCount = (vnode.state.showCount || 10) + 10; + m.redraw(); + } + + function onFilterChanged(filter) { + let params = {}; + if (filter) params.filter = filterParser.stringify(filter); + m.route.set("/faults", params); + } + + let faults = store.fetch( + "faults", + vnode.attrs.filter, + vnode.state.showCount || 10 + ); + let count = store.count("faults", vnode.attrs.filter); + + let selected = new Set(); + if (vnode.state.selected) + for (let f of faults.value) + if (vnode.state.selected.has(f["_id"])) selected.add(f["_id"]); + vnode.state.selected = selected; + + return [ + m("h1", "Listing faults"), + m(filterComponent, { + predefined: [ + { parameter: "device" }, + { parameter: "channel" }, + { parameter: "code" }, + { parameter: "retries" }, + { parameter: "timestamp" } + ], + filter: vnode.attrs.filter ? vnode.attrs.filter.ast : null, + onChange: onFilterChanged + }), + renderTable(faults, count.value, selected, showMore) + ]; + } +}; + +export { init, component }; diff --git a/client/filter-component.js b/client/filter-component.js index 9d1caac..e362da4 100644 --- a/client/filter-component.js +++ b/client/filter-component.js @@ -2,16 +2,14 @@ import m from "mithril"; -import * as config from "./config"; import * as filterParser from "../common/filter-parser"; const component = { view: vnode => { + const predefined = vnode.attrs.predefined || []; let a = m( "datalist#filters", - Object.values(config.get("ui.filters")).map(f => - m("option", { value: `${f.parameter} = ` }) - ) + predefined.map(f => m("option", { value: `${f.parameter} = ` })) ); if (vnode.attrs.filter !== vnode.state.filter) { diff --git a/client/menu.js b/client/menu.js index d7c32bb..6a94754 100644 --- a/client/menu.js +++ b/client/menu.js @@ -18,6 +18,11 @@ const menu = { "li", { class: active["devices"] }, m("a", { href: "#!/devices" }, "Devices") + ), + m( + "li", + { class: active["faults"] }, + m("a", { href: "#!/faults" }, "Faults") ) ]) ); diff --git a/client/store.js b/client/store.js index 9d6e0b2..76333c5 100644 --- a/client/store.js +++ b/client/store.js @@ -20,7 +20,7 @@ const queries = { }; const resources = {}; -for (let r of ["devices"]) +for (let r of ["devices", "faults"]) resources[r] = { objects: new Map(), count: new Map(), @@ -127,12 +127,15 @@ function fulfill(accessTimestamp, _fulfillTimestamp) { const allPromises = []; for (let [resourceType, resource] of Object.entries(resources)) - for (let queryResponse of resource.count.values()) { + for (let [queryResponseKey, queryResponse] of resource.count) { + if (!(queries.accessed.get(queryResponse) >= accessTimestamp)) { + resource.count.delete(queryResponseKey); + continue; + } + if (queries.fulfilling.has(queryResponse)) continue; - if ( - queries.accessed.get(queryResponse) >= accessTimestamp && - !(fulfillTimestamp <= queries.fulfilled.get(queryResponse)) - ) { + + if (!(fulfillTimestamp <= queries.fulfilled.get(queryResponse))) { queries.fulfilling.add(queryResponse); allPromises.push( new Promise((resolve, reject) => { @@ -165,13 +168,15 @@ function fulfill(accessTimestamp, _fulfillTimestamp) { let toFetchAll = {}; for (let [resourceType, resource] of Object.entries(resources)) - for (let queryResponse of resource.fetch.values()) { + for (let [queryResponseKey, queryResponse] of resource.fetch) { + if (!(queries.accessed.get(queryResponse) >= accessTimestamp)) { + resource.fetch.delete(queryResponseKey); + continue; + } + if (queries.fulfilling.has(queryResponse)) continue; - if ( - queries.accessed.get(queryResponse) >= accessTimestamp && - !(fulfillTimestamp <= queries.fulfilled.get(queryResponse)) - ) { + if (!(fulfillTimestamp <= queries.fulfilled.get(queryResponse))) { queries.fulfilling.add(queryResponse); toFetchAll[resourceType] = toFetchAll[resourceType] || []; toFetchAll[resourceType].push(queryResponse); @@ -244,6 +249,9 @@ function fulfill(accessTimestamp, _fulfillTimestamp) { if (!combinedFilter) return resolve(updated); updated = true; + let deleted = new Set(); + if (!resources[resourceType].combinedFilter) + deleted = new Set(resources[resourceType].objects.keys()); let combinedFilterDiff = combinedFilter; if (resources[resourceType].combinedFilter) combinedFilterDiff = combinedFilterDiff.and( @@ -265,13 +273,20 @@ function fulfill(accessTimestamp, _fulfillTimestamp) { }) }) .then(res => { - for (let r of res) - if (resourceType === "devices") - resources.devices.objects.set( - r["DeviceID.ID"].value[0], - r - ); - else resources[resourceType].objects.set(r["_id"], r); + for (let r of res) { + const id = + resourceType === "devices" + ? r["DeviceID.ID"].value[0] + : r["_id"]; + resources[resourceType].objects.set(id, r); + deleted.delete(id); + } + + for (let d of deleted) { + const obj = resources[resourceType].objects.get(d); + if (combinedFilterDiff.test(obj)) + resources[resourceType].objects.delete(d); + } for (let queryResponse of toFetch) { let filter = queries.filter.get(queryResponse); @@ -344,4 +359,19 @@ function postTasks(tasks, callback) { }); } -export { count, fetch, fulfill, unpackFilter, getTimestamp, postTasks }; +function deleteResource(resourceType, id) { + return m.request({ + method: "DELETE", + url: `/api/${resourceType}/${encodeURIComponent(id)}` + }); +} + +export { + count, + fetch, + fulfill, + unpackFilter, + getTimestamp, + postTasks, + deleteResource +}; diff --git a/config.json b/config.json index 24fa0c0..780bb8d 100644 --- a/config.json +++ b/config.json @@ -91,5 +91,6 @@ "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" + "ui.device.2.parameters.3.parameter": "MACAddress", + "ui.device.3.type": "device-faults" } diff --git a/server/api-functions.js b/server/api-functions.js index db685d9..7e066ba 100644 --- a/server/api-functions.js +++ b/server/api-functions.js @@ -265,7 +265,7 @@ function postTask(task, connectionRequest, callback) { if ( res.statusCode === 202 && res.statusMessage !== "Task queued but not processed" && - res.statusMessage !== "Task faulte" + res.statusMessage !== "Task faulted" ) connectionRequestStatus = res.statusMessage; } @@ -305,18 +305,28 @@ function postTasks(deviceId, tasks) { connectionRequest: connectionRequestStatus, tasks: statuses }); - Promise.all( - statuses.map(s => query("tasks", new Filter(["=", "_id", s._id]))) - ) + + let promises2 = []; + for (let s of statuses) { + promises2.push(query("tasks", new Filter(["=", "_id", s._id]))); + promises2.push( + query( + "faults", + new Filter(["=", "_id", `${deviceId}:task_${s._id}`]) + ) + ); + } + + Promise.all(promises2) .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; + for (let [i, r] of statuses.entries()) { + if (res[i * 2].length === 0) { + r.status = "done"; + } else if (res[i * 2 + 1].length === 1) { + r.status = "fault"; + r.fault = res[i * 2 + 1][0]; } - deleteResource("tasks", statuses[i]._id); + deleteResource("tasks", r._id); } resolve({ @@ -333,3 +343,4 @@ function postTasks(deviceId, tasks) { exports.query = query; exports.count = count; exports.postTasks = postTasks; +exports.deleteResource = deleteResource; diff --git a/server/api.js b/server/api.js index 01b84a4..8c6ccc2 100644 --- a/server/api.js +++ b/server/api.js @@ -13,6 +13,7 @@ const resources = { provisions: true, files: true, virtual_parameters: true, + faults: true, tasks: false }; @@ -22,6 +23,12 @@ for (let [resource, update] of Object.entries(resources)) { if (ctx.request.query.filter) filter = new Filter(ctx.request.query.filter); if (ctx.request.query.limit) limit = +ctx.request.query.limit; + // Exclude temporary tasks and faults + if (resource === "tasks" || resource === "faults") { + let f = new Filter(["NOT", ["<", "expiry", Date.now() + 60000]]); + filter = f.and(filter); + } + let count = await apiFunctions.count(resource, filter, limit); ctx.set("X-Total-Count", count); ctx.body = ""; @@ -33,6 +40,12 @@ for (let [resource, update] of Object.entries(resources)) { if (ctx.request.query.limit) limit = +ctx.request.query.limit; if (ctx.request.query.skip) skip = +ctx.request.query.skip; + // Exclude temporary tasks and faults + if (resource === "tasks" || resource === "faults") { + let f = new Filter(["NOT", ["<", "expiry", Date.now() + 60000]]); + filter = f.and(filter); + } + ctx.body = await apiFunctions.query( resource, filter, @@ -56,9 +69,14 @@ for (let [resource, update] of Object.entries(resources)) { ctx.body = res[0]; }); - if (update) { - // TODO add PUT, PATCH, DELETE routes - } + // TODO add PUT, PATCH routes + if (update) + router.delete(`/${resource}/:id`, async ctx => { + let found = await apiFunctions.deleteResource(resource, ctx.params.id); + if (found) ctx.status = 200; + else ctx.status = 404; + ctx.body = ""; + }); } router.post("/devices/:id/tasks", async ctx => {