From e10935c79ab3fc8a5b62b9e606d01e3a917aea57 Mon Sep 17 00:00:00 2001 From: Sven Slootweg Date: Sat, 20 Apr 2019 21:49:32 +0200 Subject: [PATCH] WIP: Various web-y things. --- app.js | 52 ----------- bin/server.js | 48 +--------- lib/disk-store.js | 7 +- lib/errors.js | 16 ---- lib/form-validators/disk-images/add.js | 21 ----- lib/tasks/tracker.js | 2 +- public/css/style.css | 36 +++++++ routes/tasks.js | 112 ---------------------- src/app.js | 66 +++++++++++++ src/auth/middleware.js | 17 ++++ src/client/index.jsx | 3 + src/db/disk-images.js | 24 +++++ {lib => src}/image-store.js | 0 src/pools/storage/file.js | 15 +++ {routes => src/routes}/disk-images.js | 22 ++--- {routes => src/routes}/index.js | 0 {routes => src/routes}/instances.js | 0 src/routes/storage-devices.js | 97 +++++++++++++++++++ src/routes/tasks.js | 114 +++++++++++++++++++++++ {scss => src/scss}/style.scss | 61 ++++++++++++ {lib => src}/tag-message.js | 4 +- {lib => src}/tasks/progress-indicator.js | 0 src/validators/disk-images/add.js | 31 ++++++ views/hardware/storage-devices/list.pug | 70 ++++++++++++++ views/layout.pug | 3 + 25 files changed, 558 insertions(+), 263 deletions(-) delete mode 100644 app.js delete mode 100644 lib/errors.js delete mode 100644 lib/form-validators/disk-images/add.js delete mode 100644 routes/tasks.js create mode 100644 src/app.js create mode 100644 src/auth/middleware.js create mode 100644 src/client/index.jsx create mode 100644 src/db/disk-images.js rename {lib => src}/image-store.js (100%) create mode 100644 src/pools/storage/file.js rename {routes => src/routes}/disk-images.js (68%) rename {routes => src/routes}/index.js (100%) rename {routes => src/routes}/instances.js (100%) create mode 100644 src/routes/storage-devices.js create mode 100644 src/routes/tasks.js rename {scss => src/scss}/style.scss (51%) rename {lib => src}/tag-message.js (79%) rename {lib => src}/tasks/progress-indicator.js (100%) create mode 100644 src/validators/disk-images/add.js create mode 100644 views/hardware/storage-devices/list.pug diff --git a/app.js b/app.js deleted file mode 100644 index 2f92873..0000000 --- a/app.js +++ /dev/null @@ -1,52 +0,0 @@ -'use strict'; - -const express = require("express"); -const expressWs = require("express-ws"); -const knex = require("knex"); -const path = require("path"); -const bodyParser = require("body-parser"); - -let db = knex(require("./knexfile")); -let imageStore = require("./lib/image-store")(path.join(__dirname, "./images")); -let taskTracker = require("./lib/tasks/tracker")(); - -let state = {db, imageStore, taskTracker}; - -let app = express(); -expressWs(app); - -app.set("view engine", "pug"); -app.set("views", path.join(__dirname, "views")); - -app.use((req, res, next) => { - res.locals.isUnderPrefix = function isUnderPrefix(path, resultingClass) { - // FIXME: Proper path segment parsing... - if (req.originalUrl.indexOf(path) === 0) { - return resultingClass; - } else { - return ""; - } - } - - next(); -}); - -app.use(express.static(path.join(__dirname, "public"))); - -app.use(bodyParser.urlencoded({ - extended: true -})); - -app.use(require("./routes/index")); -app.use("/disk-images", require("./routes/disk-images")(state)); -app.use("/instances", require("./routes/instances")(state)); - -app.use((err, req, res, next) => { - res.render("error", { - error: err - }); -}); - -app.listen(3000).on("listening", () => { - console.log("Listening..."); -}); diff --git a/bin/server.js b/bin/server.js index e4c3424..300fc5c 100644 --- a/bin/server.js +++ b/bin/server.js @@ -24,50 +24,4 @@ budoExpress({ }] ] }, -}); - - -// const app = require("../src/app.js")(); - -// if (process.env.NODE_ENV === "development") { -// const budo = require("budo"); - -// function projectPath(targetPath) { -// return path.resolve(__dirname, "..", targetPath); -// } - -// budo(projectPath("src/client/index.jsx"), { -// watchGlob: projectPath("public/css/*.css"), -// live: "**/*.css", -// stream: process.stdout, -// port: 8000, -// dir: projectPath("public"), -// serve: "js/bundle.js", -// debug: true, -// browserify: { -// extensions: [".jsx"], -// plugin: [ -// "browserify-hmr" -// ], -// transform: [ -// ["babelify", { -// presets: ["@babel/preset-env", "@babel/preset-react"], -// plugins: ["react-hot-loader/babel"] -// }] -// ] -// }, -// middleware: function (req, res, next) { -// app.handle(req, res, (err) => { -// if (err != null && err instanceof Error) { -// res.send("
" + err.stack + "
"); -// } else { -// next(err); -// } -// }); -// } -// }); -// } else { -// app.listen(3000).on("listening", () => { -// console.log("Listening..."); -// }); -// } +}); \ No newline at end of file diff --git a/lib/disk-store.js b/lib/disk-store.js index 76a9da4..2d925c4 100644 --- a/lib/disk-store.js +++ b/lib/disk-store.js @@ -2,6 +2,7 @@ const Promise = require("bluebird"); const uuid = require("uuid"); +const defaultValue = require("default-value"); const childProcess = Promise.promisifyAll(require("child-process"), {multiArgs: true}); module.exports = function(storePath) { @@ -13,14 +14,16 @@ module.exports = function(storePath) { getPath: function getDisk(id) { return getPath(id); }, - create: function createDisk(size, {type} = {type: "qcow2"}) { + create: function createDisk(size, options = {}) { return Promise.try(() => { + let imageFormat = defaultValue(options.format, "qcow2"); + let diskId = uuid.v4(); return Promise.try(() => { childProcess.execFileAsync("qemu-img", [ "create", - "-f", type, + "-f", imageFormat, getPath(diskId), size ]); diff --git a/lib/errors.js b/lib/errors.js deleted file mode 100644 index 71eb5ea..0000000 --- a/lib/errors.js +++ /dev/null @@ -1,16 +0,0 @@ -'use strict'; - -const createError = require("create-error"); - -let HttpError = createError("HttpError", { - exposeToUser: true -}); - -module.exports = { - UnauthorizedError: createError(HttpError, "UnauthorizedError", { - statusCode: 401 - }), - ForbiddenError: createError(HttpError, "ForbiddenError", { - statusCode: 403 - }) -} diff --git a/lib/form-validators/disk-images/add.js b/lib/form-validators/disk-images/add.js deleted file mode 100644 index b29704c..0000000 --- a/lib/form-validators/disk-images/add.js +++ /dev/null @@ -1,21 +0,0 @@ -'use strict'; - -const checkit = require("checkit"); -const oneOf = require("../../validators/one-of"); - -module.exports = checkit({ - name: "string", - description: "string", - source: ["required", "string", oneOf([ - "local", - "http" - ])] -}).maybe({ - url: ["required", "string"] -}, (input) => { - return (input.source === "http"); -}).maybe({ - path: ["required", "string"] -}, (input) => { - return (input.source === "local"); -}); diff --git a/lib/tasks/tracker.js b/lib/tasks/tracker.js index 1f6df79..b09608d 100644 --- a/lib/tasks/tracker.js +++ b/lib/tasks/tracker.js @@ -9,7 +9,7 @@ const findAndSplice = require("../find-and-splice"); module.exports = function createTaskTracker() { let tasks = []; - return createEventEmitter{ + return createEventEmitter({ addTask: function addTask(name, indicator, userIds, roleIds, options) { let mergedOptions = Object.assign({ removeWhenDone: true diff --git a/public/css/style.css b/public/css/style.css index 0b0e72e..44384a0 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -34,3 +34,39 @@ table { border: 1px solid black; } table th { text-align: left; } + table td.hidden { + border: none; } + +table.drives td.smart.healthy { + background-color: #00a500; } + +table.drives td.smart.deteriorating { + background-color: #ff9100; } + +table.drives td.smart.failing { + background-color: #e60000; } + +table.drives .hasPartitions td:not(.smart), table.drives .partition:not(.last) td:not(.smart) { + border-bottom-color: transparent; } + +table.drives .partition { + font-style: italic; + font-size: .8em; } + table.drives .partition td { + padding: 4px 9px; } + table.drives .partition .notMounted { + color: gray; } + +table.drives tr.smartStatus { + font-size: .85em; } + table.drives tr.smartStatus td { + padding: 4px 9px; } + +table.drives th.healthy { + color: #006100; } + +table.drives th.atRisk { + color: #7c4600; } + +table.drives th.failing { + color: #c20000; } diff --git a/routes/tasks.js b/routes/tasks.js deleted file mode 100644 index e02c1d9..0000000 --- a/routes/tasks.js +++ /dev/null @@ -1,112 +0,0 @@ -'use strict'; - -const rfr = require("rfr"); - -const errors = rfr("lib/errors"); -const tagWebsocket = rfr("lib/tag-websocket"); -const tagMessage = rfr("lib/tag-message"); -const websocketTracker = rfr("lib/websockets/tracker"); -const websocketDeduplicator = rfr("lib/deduplicator"); -const fakeTask = rfr("lib/tasks/fake-task"); - -module.exports = function({taskTracker}) { - let router = require("express-promise-router")(); - - /* We manually create a deduplicator, so that we can share it across all the user-level websocket trackers. This does mean that we have to manually remove disconnected clients from our shared deduplicator. */ - let deduplicator = websocketDeduplicator(); - - let allClients = websocketTracker({deduplicator: deduplicator}); - let userClients = websocketTracker({deduplicator: deduplicator, namespaced: true}); - let roleClients = websocketTracker({deduplicator: deduplicator, namespaced: true}); - - /* The adminClient tracker is a special case, and can use its own internal deduplicator if needed */ - let adminClients = websocketTracker(); - - function emitTask(task, message) { - task.userIds.forEach((userId) => { - userClients.emit(userId, message); - }); - - task.roleIds.forEach((roleId) => { - roleClients.emit(roleId, message); - }); - - adminClients.emit(message); - } - - taskTracker.on("newTask", (task) => { - emitTask(task, tagMessage({ - // FIXME: Task state? pending/started/paused/cancelled/etc. - taskId: task.id, - type: "newTask", - name: task.name, - progress: task.progress, - started: task.started, - lastUpdated: task.lastUpdated, - lastOperation: task.lastOperation - })); - }); - - taskTracker.on("progress", (task, progress, lastOperation) => { - emitTask(task, tagMessage({ - taskId: task.id, - type: "progress", - progress: progress, - lastOperation: lastOperation - })); - }); - - taskTracker.on("completed", (task) => { - emitTask(tagMessage({ - taskId: task.id, - type: "completed" - })); - }); - - // FIXME: Initial task list - - router.ws("/feed", (ws, req) => { - // FIXME: Auth user? - if (req.session.user == null) { - throw new errors.UnauthorizedError("You must be authenticated to obtain a task feed"); - } else { - tagWebsocket(ws); - // We ignore client messages for now - - allClients.add(client); - userClients.add(req.session.user.id, client); - - req.session.user.roles.forEach((role) => { - roleClients.add(role.id, client); - }); - - ws.on("close", () => { - allClients.remove(client); - userClients.remove(client); - roleClients.remove(client); - deduplicator.forgetClient(client); - }); - } - }); - - router.ws("/feed/all", (ws, req) => { - // FIXME: Require admin access - tagWebsocket(ws); - // We ignore client messages for now - - adminClients.add(client); - - ws.on("close", () => { - adminClients.remove(client); - }); - }); - - router.get("/fake-task", (req, res) => { - let task = fakeTask(5000); - addTask("Fake Task", task, [req.session.user.id], [], {}); - - res.send("Fake task added!"); - }) - - return router; -} diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..0ba7796 --- /dev/null +++ b/src/app.js @@ -0,0 +1,66 @@ +'use strict'; + +const express = require("express"); +// const expressWs = require("express-ws"); +const knex = require("knex"); +const path = require("path"); +const bodyParser = require("body-parser"); + +function projectPath(targetPath) { + return path.join(__dirname, "..", targetPath); +} + +module.exports = function () { + let db = knex(require("../knexfile")); + let imageStore = require("./image-store")(projectPath("./images")); + let taskTracker = require("../lib/tasks/tracker")(); + + let state = {db, imageStore, taskTracker}; + + let app = express(); + // expressWs(app); + + app.set("view engine", "pug"); + app.set("views", projectPath("views")); + + app.use((req, res, next) => { + res.locals.isUnderPrefix = function isUnderPrefix(path, resultingClass) { + // FIXME: Proper path segment parsing... + if (req.originalUrl.indexOf(path) === 0) { + return resultingClass; + } else { + return ""; + } + } + + next(); + }); + + app.use(express.static(projectPath("public"))); + + app.use(bodyParser.urlencoded({ + extended: true + })); + + app.use(require("./routes/index")); + app.use("/disk-images", require("./routes/disk-images")(state)); + app.use("/instances", require("./routes/instances")(state)); + app.use("/hardware/storage-devices", require("./routes/storage-devices")(state)); + + app.use((err, req, res, next) => { + if (err.showChain != null) { + console.log(err.showChain()); + console.log("#####################"); + console.log(err.getAllContext()); + + } else { + console.log(err.stack); + } + + res.render("error", { + error: err + }); + }); + + return app; +}; \ No newline at end of file diff --git a/src/auth/middleware.js b/src/auth/middleware.js new file mode 100644 index 0000000..b23cb69 --- /dev/null +++ b/src/auth/middleware.js @@ -0,0 +1,17 @@ +"use strict"; + +const errors = require("../errors"); + +module.exports = { + isAuthenticated: function (req, res, next) { + if (req.session.user != null) { + next(); + } else { + throw new errors.UnauthorizedError("You must be authenticated to view this page"); + } + }, + isAdministrator: function (req, res, next) { + /* FIXME */ + next(); + } +}; \ No newline at end of file diff --git a/src/client/index.jsx b/src/client/index.jsx new file mode 100644 index 0000000..70884ec --- /dev/null +++ b/src/client/index.jsx @@ -0,0 +1,3 @@ +"use strict"; + +console.log("Hello world!"); diff --git a/src/db/disk-images.js b/src/db/disk-images.js new file mode 100644 index 0000000..0ea4f5d --- /dev/null +++ b/src/db/disk-images.js @@ -0,0 +1,24 @@ +"use strict"; + +const Promise = require("bluebird"); + +module.exports = function ({db}) { + return { + getAll: function () { + return db("images"); + }, + addInstallISO: function ({userId, fileId, name, description, sourceType, source, isPublic}) { + return db("images").insert({ + userId: userId, + fileId: fileId, + name: name, + description: description, + sourceType: sourceType, + source: source, + public: isPublic, + imageType: "disk", + isInstallMedium: true + }); + } + }; +}; \ No newline at end of file diff --git a/lib/image-store.js b/src/image-store.js similarity index 100% rename from lib/image-store.js rename to src/image-store.js diff --git a/src/pools/storage/file.js b/src/pools/storage/file.js new file mode 100644 index 0000000..8ec3805 --- /dev/null +++ b/src/pools/storage/file.js @@ -0,0 +1,15 @@ +"use strict"; + +module.exports = function createFileStoragePool({ storePath }) { + return { + createDisk: function ({ size, format }) { + + }, + resizeDisk: function ({ id, newSize }) { + + }, + removeDisk: function ({ id }) { + + } + }; +}; \ No newline at end of file diff --git a/routes/disk-images.js b/src/routes/disk-images.js similarity index 68% rename from routes/disk-images.js rename to src/routes/disk-images.js index 76b0f98..156de29 100644 --- a/routes/disk-images.js +++ b/src/routes/disk-images.js @@ -1,17 +1,18 @@ 'use strict'; const Promise = require("bluebird"); -const checkit = require("checkit"); -const debounce = require("debounce"); +const functionRateLimit = require("function-rate-limit"); -const validatorAdd = require("../lib/form-validators/disk-images/add"); +const validatorAdd = require("../validators/disk-images/add"); module.exports = function({db, imageStore}) { + const dbDiskImages = require("../db/disk-images")({db}); + let router = require("express-promise-router")(); router.get("/", (req, res) => { return Promise.try(() => { - return db("images"); + return dbDiskImages.getAll(); }).then((results) => { res.render("disk-images/list", { images: results @@ -25,16 +26,17 @@ module.exports = function({db, imageStore}) { router.post("/add", (req, res) => { return Promise.try(() => { - return validatorAdd.run(req.body); + return validatorAdd.validate(req.body); }).then((result) => { /* FIXME: Only allow 'local' for administrators! */ if (req.body.source === "http") { return Promise.try(() => { return imageStore.addFromUrl(req.body.url); }).then((downloadTracker) => { - downloadTracker.on("progress", debounce((progress, description) => { + downloadTracker.on("progress", functionRateLimit(1, 200, (progress, description) => { + /* FIXME: This is getting debounced to a point of pointlessness as it will only fire when the download is completed. Needs to be replaced by SSE events anyway, and rate-limited instead of debounced. */ console.log("Download progress:", (Math.round(progress * 10000) / 100), "%"); - }, 200)); + })); return downloadTracker.await(); }); @@ -50,16 +52,14 @@ module.exports = function({db, imageStore}) { source = req.body.path; } - return db("images").insert({ + return dbDiskImages.addInstallISO({ userId: 0, fileId: imageId, name: req.body.name, description: req.body.description, sourceType: req.body.source, source: source, - imageType: "disk", - public: true, - isInstallMedium: true + isPublic: true }); }).then(() => { res.redirect("/disk-images"); diff --git a/routes/index.js b/src/routes/index.js similarity index 100% rename from routes/index.js rename to src/routes/index.js diff --git a/routes/instances.js b/src/routes/instances.js similarity index 100% rename from routes/instances.js rename to src/routes/instances.js diff --git a/src/routes/storage-devices.js b/src/routes/storage-devices.js new file mode 100644 index 0000000..90ac99a --- /dev/null +++ b/src/routes/storage-devices.js @@ -0,0 +1,97 @@ +'use strict'; + +const Promise = require("bluebird"); + +const lsblk = require("../wrappers/lsblk"); +const smartctl = require("../wrappers/smartctl"); +const lvm = require("../wrappers/lvm"); +const {B} = require("../units/bytes/iec"); + +/* FIXME: Move this to GraphQL API */ +function getSmartStatus(smartData) { + let failed = smartData.filter((item) => { + return (item.failingNow === true || item.failedBefore === true); + }); + + let deteriorating = smartData.filter((item) => { + return (item.type === "preFail" && item.worstValueSeen < 100); + }); + + if (failed.length > 0) { + return "failed"; + } else if (deteriorating.length > 0) { + return "deteriorating"; + } else { + return "healthy"; + } +} + +function getStorageDevices() { + return Promise.try(() => { + return lsblk(); + }).filter((device) => { + return (device.type === "disk"); + }).map((device) => { + return Object.assign({}, device, { + path: `/dev/${device.name}` + }); + }).map((device) => { + /* FIXME: Check whether we need to iterate through child disks as well, when dealing with eg. RAID arrays */ + return Promise.try(() => { + return Promise.all([ + smartctl.info({ devicePath: device.path }), + smartctl.attributes({ devicePath: device.path }) + ]); + }).then(([info, attributes]) => { + return Object.assign({}, device, { + information: info, + smartData: attributes, + smartStatus: getSmartStatus(attributes) + }); + }); + }).then((blockDevices) => { + console.log(blockDevices); + return blockDevices; + }); +} + +function sumDriveSizes(drives) { + return drives.reduce((total, device) => { + return total + device.size.toB().amount; + }, 0); +} + +function roundUnit(unit) { + return Object.assign(unit, { + amount: Math.round(unit.amount * 100) / 100 + }); +} + +module.exports = function({db}) { + let router = require("express-promise-router")(); + + router.get("/", (req, res) => { + return Promise.try(() => { + return getStorageDevices(); + }).then((devices) => { + /* FIXME: Auto-formatting of total sizes and units */ + let fixedDrives = devices.filter((drive) => drive.removable === false); + let removableDrives = devices.filter((drive) => drive.removable === true); + + let healthyFixedDrives = fixedDrives.filter((drive) => drive.smartStatus === "healthy"); + let deterioratingFixedDrives = fixedDrives.filter((drive) => drive.smartStatus === "deteriorating"); + let failingFixedDrives = fixedDrives.filter((drive) => drive.smartStatus === "failing"); + + res.render("hardware/storage-devices/list", { + devices: devices, + totalFixedStorage: roundUnit(B(sumDriveSizes(fixedDrives)).toTiB()), + totalHealthyFixedStorage: roundUnit(B(sumDriveSizes(healthyFixedDrives)).toTiB()), + totalDeterioratingFixedStorage: roundUnit(B(sumDriveSizes(deterioratingFixedDrives)).toTiB()), + totalFailingFixedStorage: roundUnit(B(sumDriveSizes(failingFixedDrives)).toTiB()), + totalRemovableStorage: roundUnit(B(sumDriveSizes(removableDrives)).toGiB()) + }); + }); + }); + + return router; +} diff --git a/src/routes/tasks.js b/src/routes/tasks.js new file mode 100644 index 0000000..66359b8 --- /dev/null +++ b/src/routes/tasks.js @@ -0,0 +1,114 @@ +'use strict'; + +const sseChannel = require("@joepie91/sse-channel"); + +const errors = require("../errors"); +// const tagWebsocket = require("lib/tag-websocket"); +const tagMessage = require("../tag-message"); +// const websocketTracker = require("lib/websockets/tracker"); +// const websocketDeduplicator = require("lib/deduplicator"); +const fakeTask = require("lib/tasks/fake-task"); + +const authMiddleware = require("../auth/middleware"); + +module.exports = function({taskTracker}) { + let router = require("express-promise-router")(); + + // /* We manually create a deduplicator, so that we can share it across all the user-level websocket trackers. This does mean that we have to manually remove disconnected clients from our shared deduplicator. */ + // let deduplicator = websocketDeduplicator(); + + // let allClients = websocketTracker({deduplicator: deduplicator}); + // let userClients = websocketTracker({deduplicator: deduplicator, namespaced: true}); + // let roleClients = websocketTracker({deduplicator: deduplicator, namespaced: true}); + + // /* The adminClient tracker is a special case, and can use its own internal deduplicator if needed */ + // let adminClients = websocketTracker(); + + // function emitTask(task, message) { + // task.userIds.forEach((userId) => { + // userClients.emit(userId, message); + // }); + + // task.roleIds.forEach((roleId) => { + // roleClients.emit(roleId, message); + // }); + + // adminClients.emit(message); + // } + + taskTracker.on("newTask", (task) => { + emitTask(task, tagMessage({ + // FIXME: Task state? pending/started/paused/cancelled/etc. + taskId: task.id, + type: "newTask", + name: task.name, + progress: task.progress, + started: task.started, + lastUpdated: task.lastUpdated, + lastOperation: task.lastOperation + })); + }); + + taskTracker.on("progress", (task, progress, lastOperation) => { + emitTask(task, tagMessage({ + taskId: task.id, + type: "progress", + progress: progress, + lastOperation: lastOperation + })); + }); + + taskTracker.on("completed", (task) => { + emitTask(tagMessage({ + taskId: task.id, + type: "completed" + })); + }); + + // FIXME: Initial task list + + router.get("/feed", authMiddleware.isAuthenticated, (req, res) => { + // FIXME: Auth user? + if (req.session.user == null) { + throw new errors.UnauthorizedError("You must be authenticated to obtain a task feed"); + } else { + // tagWebsocket(ws); + // // We ignore client messages for now + + // allClients.add(client); + // userClients.add(req.session.user.id, client); + + // req.session.user.roles.forEach((role) => { + // roleClients.add(role.id, client); + // }); + + // ws.on("close", () => { + // allClients.remove(client); + // userClients.remove(client); + // roleClients.remove(client); + // deduplicator.forgetClient(client); + // }); + } + }); + + router.get("/feed/all", authMiddleware.isAdministrator, (req, res) => { + // FIXME: Require admin access + tagWebsocket(ws); + // We ignore client messages for now + + adminClients.add(client); + + ws.on("close", () => { + adminClients.remove(client); + }); + }); + + router.get("/fake-task", (req, res) => { + let task = fakeTask(5000); + addTask("Fake Task", task, [req.session.user.id], [], {}); + + res.send("Fake task added!"); + }); + + return router; +} diff --git a/scss/style.scss b/src/scss/style.scss similarity index 51% rename from scss/style.scss rename to src/scss/style.scss index 352a841..7944e67 100644 --- a/scss/style.scss +++ b/src/scss/style.scss @@ -62,4 +62,65 @@ table { th { text-align: left; } + + td.hidden { + border: none; + } } + +table.drives { + td.smart { + &.healthy { + background-color: rgb(0, 165, 0); + } + + &.deteriorating { + background-color: rgb(255, 145, 0); + } + + &.failing { + background-color: rgb(230, 0, 0); + } + } + + .hasPartitions, .partition:not(.last) { + td:not(.smart) { + border-bottom-color: transparent; + } + } + + .partition { + font-style: italic; + font-size: .8em; + + td { + padding: 4px 9px; + } + + .notMounted { + color: gray; + } + } + + tr.smartStatus { + font-size: .85em; + + td { + padding: 4px 9px; + } + } + + th { + &.healthy { + color: rgb(0, 97, 0); + } + + &.atRisk { + color: rgb(124, 70, 0); + } + + &.failing { + color: rgb(194, 0, 0); + } + } +} \ No newline at end of file diff --git a/lib/tag-message.js b/src/tag-message.js similarity index 79% rename from lib/tag-message.js rename to src/tag-message.js index cd27428..4a2a4c8 100644 --- a/lib/tag-message.js +++ b/src/tag-message.js @@ -2,9 +2,11 @@ const uuid = require("uuid"); +let i = 0; + module.exports = function tagMessage(message) { if (message.messageId == null) { - message.messageId = uuid.v4(); + message.messageId = i++; } return message; diff --git a/lib/tasks/progress-indicator.js b/src/tasks/progress-indicator.js similarity index 100% rename from lib/tasks/progress-indicator.js rename to src/tasks/progress-indicator.js diff --git a/src/validators/disk-images/add.js b/src/validators/disk-images/add.js new file mode 100644 index 0000000..c24849a --- /dev/null +++ b/src/validators/disk-images/add.js @@ -0,0 +1,31 @@ +'use strict'; + +const joi = require("joi"); + +// const checkit = require("checkit"); +// const oneOf = require("../../validators/one-of"); + +module.exports = joi.object({ + name: joi.string().required(), + description: joi.string(), + source: joi.string().required(), + url: joi.when("source", { is: "http", then: joi.string().required() }), + path: joi.when("source", { is: "local", then: joi.string().required() }) +}); + +// module.exports = checkit({ +// name: "string", +// description: "string", +// source: ["required", "string", oneOf([ +// "local", +// "http" +// ])] +// }).maybe({ +// url: ["required", "string"] +// }, (input) => { +// return (input.source === "http"); +// }).maybe({ +// path: ["required", "string"] +// }, (input) => { +// return (input.source === "local"); +// }); diff --git a/views/hardware/storage-devices/list.pug b/views/hardware/storage-devices/list.pug new file mode 100644 index 0000000..bcb5e65 --- /dev/null +++ b/views/hardware/storage-devices/list.pug @@ -0,0 +1,70 @@ +extends ../../layout + +block content + + h2 Fixed drives + + //- FIXME: Partitions with mountpoints + table.drives + tr + th SMART + th Device + th Total size + th RPM + th Serial number + th Model + th Family + th Firmware version + for device in devices.filter((device) => device.removable === false) + tr(class=(device.children.length > 0 ? "hasPartitions" : null)) + td(class=`smart ${device.smartStatus}`, rowspan=(1 + device.children.length)) + td= device.name + td= device.size + td #{device.information.rpm} RPM + td= device.information.serialNumber + td= device.information.model + td= device.information.modelFamily + td= device.information.firmwareVersion + + for partition, i in device.children + tr.partition(class=(i === device.children.length - 1) ? "last" : null) + td= partition.name + td= partition.size + td(colspan=5) + if partition.mountpoint != null + = partition.mountpoint + else + span.notMounted (not mounted) + + + //- tr.partition + //- td(colspan=8)= JSON.stringify(partition) + tr + th(colspan=2) Total + td= totalFixedStorage + td(colspan=5).hidden + tr.smartStatus + th(colspan=2).healthy Healthy + td= totalHealthyFixedStorage + td(colspan=5).hidden + tr.smartStatus + th(colspan=2).atRisk At-risk + td= totalDeterioratingFixedStorage + td(colspan=5).hidden + tr.smartStatus + th(colspan=2).failing Failing + td= totalFailingFixedStorage + td(colspan=5).hidden + + h2 Removable drives + + table + tr + th Path + th Total size + th Mounted at + for device in devices.filter((device) => device.type === "loopDevice") + tr + td= device.path + td= device.size + td= device.mountpoint \ No newline at end of file diff --git a/views/layout.pug b/views/layout.pug index dac0f80..bb4ba26 100644 --- a/views/layout.pug +++ b/views/layout.pug @@ -15,3 +15,6 @@ body .content block content + + script(src="/js/bundle.js") + script(src="/budo/livereload.js")