Compare commits

...

5 Commits

@ -0,0 +1,78 @@
module.exports = {
"env": {
"browser": true,
"commonjs": true,
"es6": true,
"node": true
},
"parserOptions": {
"ecmaFeatures": {
"experimentalObjectRestSpread": true,
"jsx": true
}
},
"plugins": [
"react"
],
"rules": {
/* Things that should effectively be syntax errors. */
"indent": [ "error", "tab", {
SwitchCase: 1
}],
"linebreak-style": [ "error", "unix" ],
"semi": [ "error", "always" ],
/* Things that are always mistakes. */
"getter-return": [ "error" ],
"no-compare-neg-zero": [ "error" ],
"no-dupe-args": [ "error" ],
"no-dupe-keys": [ "error" ],
"no-duplicate-case": [ "error" ],
"no-empty": [ "error" ],
"no-empty-character-class": [ "error" ],
"no-ex-assign": [ "error" ],
"no-extra-semi": [ "error" ],
"no-func-assign": [ "error" ],
"no-invalid-regexp": [ "error" ],
"no-irregular-whitespace": [ "error" ],
"no-obj-calls": [ "error" ],
"no-sparse-arrays": [ "error" ],
"no-undef": [ "error" ],
"no-unreachable": [ "error" ],
"no-unsafe-finally": [ "error" ],
"use-isnan": [ "error" ],
"valid-typeof": [ "error" ],
"curly": [ "error" ],
"no-caller": [ "error" ],
"no-fallthrough": [ "error" ],
"no-extra-bind": [ "error" ],
"no-extra-label": [ "error" ],
"array-callback-return": [ "error" ],
"prefer-promise-reject-errors": [ "error" ],
"no-with": [ "error" ],
"no-useless-concat": [ "error" ],
"no-unused-labels": [ "error" ],
"no-unused-expressions": [ "error" ],
"no-unused-vars": [ "error" , { argsIgnorePattern: "^_" } ],
"no-return-assign": [ "error" ],
"no-self-assign": [ "error" ],
"no-new-wrappers": [ "error" ],
"no-redeclare": [ "error" ],
"no-loop-func": [ "error" ],
"no-implicit-globals": [ "error" ],
"strict": [ "error", "global" ],
/* Make JSX not cause 'unused variable' errors. */
"react/jsx-uses-react": ["error"],
"react/jsx-uses-vars": ["error"],
/* Development code that should be removed before deployment. */
"no-console": [ "warn" ],
"no-constant-condition": [ "warn" ],
"no-debugger": [ "warn" ],
"no-alert": [ "warn" ],
"no-warning-comments": ["warn", {
terms: ["fixme"]
}],
/* Common mistakes that can *occasionally* be intentional. */
"no-template-curly-in-string": ["warn"],
"no-unsafe-negation": [ "warn" ],
}
};

1
.gitignore vendored

@ -1,3 +1,4 @@
config.json
node_modules
images
disks

@ -0,0 +1,37 @@
# CVM
A non-commercial, fully open-source VPS management panel.
## Current status
Definitely not ready for production use yet. Check back later!
## Roadmap
TBD. Initially, only QEMU/KVM will be supported. Support for other virtualization and/or containerization technologies may follow later, if there's enough demand.
## Contributing
Contributions are very welcome! Please keep the following things in mind:
- "Contributions" don't just include code commits, they also include bug reports, translations, documentation fixes, triaging, and so on.
- Anybody is welcome (and encouraged!) to leave code reviews on Pull Requests. You don't need to be a maintainer!
- You agree that any contributions you make, are licensed under both the WTFPL and the CC0 (which more or less means they are public domain). You keep copyright ownership over your work.
- If you're making a Pull Request, make sure that each PR has its own branch in your fork, so that commits between PRs don't get mixed up.
- Because of the security-sensitive nature of CVM, we very tightly control who can directly commit to the repository, even for regular contributors. This is not because we don't trust the quality of your commits, but as an extra layer of security and review.
We welcome contributions from developers of any experience level, *but* we do have a very high code quality standard. Don't worry if you're not sure how to reach that standard; when you submit a PR, we'll help you get there by doing a code review.
This does mean that you should only create a Pull Request if you're willing to work on incrementally improving it before it gets merged! "Hit-and-run" PRs will very likely be closed.
## Code of Conduct
Quite simple, really:
- The goal is for this project to be collaborative, and welcoming to contributors of any demographic and experience level. Arrogance, elitism, etc. are therefore not welcome.
- Don't be an asshole. Treat people respectfully. Assume good faith.
- Constructive criticism is fine, personal attacks are not. Don't just tell somebody that they're doing it wrong, help them understand *why*, and how to do it better.
- No bigoted or discriminatory behaviour or remarks. That includes "jokes".
- Anything related to the alt-right or other hateful ideologies, is banned on sight. If you feel the need to explain how your thing "isn't related", it's probably related.
Bans and other repercussions are at the discretion of maintainers.

@ -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...");
});

@ -0,0 +1,27 @@
"use strict";
const budoExpress = require("budo-express");
const path = require("path");
budoExpress({
port: 8000,
debug: true,
expressApp: require("../src/app")(),
basePath: path.resolve(__dirname, ".."),
entryPath: "src/client/index.jsx",
publicPath: "public",
bundlePath: "js/bundle.js",
livereload: "**/*.{css,html}",
browserify: {
extensions: [".jsx"],
plugin: [
"browserify-hmr"
],
transform: [
["babelify", {
presets: ["@babel/preset-env", "@babel/preset-react"],
// plugins: ["react-hot-loader/babel"]
}]
]
},
});

@ -0,0 +1,13 @@
"use strict";
const buildThing = require("build-thing");
let scssTask = buildThing.core.defineTask(() => {
return buildThing.core.pipeline([
buildThing.core.watch("src/scss/**/*.scss"),
buildThing.scssTransform(),
buildThing.core.saveToDirectory("public/css")
]);
});
buildThing.runner(scssTask);

@ -1,132 +0,0 @@
'use strict';
const Promise = require("bluebird");
const path = require("path");
const gulp = require("gulp");
const webpackStream = require("webpack-stream");
const webpack = require("webpack");
const gulpCached = require("gulp-cached");
const gulpRename = require("gulp-rename");
const gulpNodemon = require("gulp-nodemon");
const gulpLivereload = require("gulp-livereload");
const gulpNamedLog = require("gulp-named-log");
const presetSCSS = require("@joepie91/gulp-preset-scss");
const partialPatchLivereload = require("@joepie91/gulp-partial-patch-livereload-logger");
const serverWaiter = require("./lib/build/wait-for-server")(3000);
const nodemonLogger = gulpNamedLog("nodemon");
const livereloadLogger = gulpNamedLog("livereload");
partialPatchLivereload(gulpLivereload);
let nodemon;
let sources = {
scss: ["./scss/**/*.scss"]
}
/* The following resolves JacksonGariety/gulp-nodemon#33 */
process.once("SIGINT", function() {
process.exit(0);
});
function tryReload(target) {
if (!serverWaiter.isWaiting()) {
if (typeof target === "string") {
gulpLivereload.changed(target);
} else if (target.path != null) {
if (Array.isArray(target.path)) {
gulpLivereload.changed(target.path[0]);
} else {
gulpLivereload.changed(target.path);
}
}
}
}
function queueFullReload() {
return Promise.try(() => {
nodemonLogger.log("Waiting for server to start listening...")
return serverWaiter.wait();
}).then(() => {
nodemonLogger.log("Server started!")
tryReload("*");
});
}
gulp.task("scss", () => {
return gulp.src(sources.scss)
.pipe(presetSCSS({
basePath: __dirname
}))
.pipe(gulp.dest("./public/css"));
});
gulp.task("webpack", () => {
return gulp.src("./frontend/index.js")
.pipe(webpackStream({
watch: true,
module: {
preLoaders: [{
test: /\.tag$/,
loader: "riotjs-loader",
exclude: /node_modules/,
query: {
type: "babel",
template: "pug",
parserOptions: {
js: {
presets: ["es2015-riot"]
}
}
}
}],
loaders: [
{ test: /\.js$/, loader: "babel-loader" },
{ test: /\.json$/, loader: "json-loader" }
]
},
plugins: [ new webpack.ProvidePlugin({riot: "riot"}) ],
resolveLoader: { root: path.join(__dirname, "node_modules") },
resolve: {
extensions: [
"",
".tag",
".web.js", ".js",
".web.json", ".json"
]
},
debug: false
}))
.pipe(gulpRename("bundle.js"))
.pipe(gulp.dest("./public"));
});
gulp.task("nodemon", ["scss"], () => {
nodemon = gulpNodemon({
script: "./app.js",
delay: 500,
ignore: ["node_modules", "public", "gulpfile.js"],
quiet: true
}).on("restart", (cause) => {
nodemonLogger.log("Restarting... caused by:", cause.join(" / "));
serverWaiter.reportRestart();
}).on("start", () => {
return queueFullReload()
});
});
gulp.task("watch-server", ["nodemon"], () => {
gulpLivereload.listen({
quiet: true
});
gulp.watch(sources.scss, ["scss"]);
gulp.watch("./public/**/*", tryReload);
});
gulp.task("watch", ["watch-server", "webpack"]);
gulp.task("default", ["watch"]);

@ -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
]);

@ -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
})
}

@ -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");
});

@ -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

@ -504,3 +504,121 @@ http://wiki.qemu.org/download/qemu-doc.html
"name": "quit"
}]
}
-----------------
# Storage pools
- LVM
- Preallocated
- Sparse ("thin provisioning")
- File
- Preallocated (raw)
- Sparse (qcow2)
# Image pools
Make synchronization settable per image pool?
# Network pools?
--------------
Consider how to check disk space (total, used, allocated) and how to display overcommitting to the user
------
Reading filesystems:
/proc/self/mountinfo
https://github.com/coreutils/gnulib/blob/master/lib/mountlist.c
https://github.com/coreutils/coreutils/blob/master/src/df.c
Reading system info in general:
https://github.com/uutils/coreutils (Rust)
------
Schema stuff
Storage pools:
- node ID
- path (for file)
- volume group name (for LVM)
Storage space/volume:
- pool ID
- VM ID
-------
Tools
- resize2fs: resizing filesystems
------
Safety/reliability
- Lock a storage pool when a pvmove is in progress? How to detect that when it isn't done through the CVM interface?
------
MARKER:
- LVM tools abstraction (CLI wrapper)
- Storage pool implementation for LVM and file-based (test LVM via loopback devices)
--------- LVM stuff
To display available report fields:
<command, eg. pvs> -o help
To return data in JSON format:
<command, eg. pvs> --reportformat json
---------
error-chain
- exposed predicate function for "has error type somewhere in chain", for Bluebird .catch
----------
smartctl flags
PO--CK 0x0033 51 0 0 1 1 0 0 1 1
-O--CK 0x0032 50 0 0 1 1 0 0 1 0
----CK 0x0030 48 0 0 1 1 0 0 0 0
POSR-K 0x002f 47 0 0 1 0 1 1 1 1
-OSR-K 0x002e 46 0 0 1 0 1 1 1 0
POS--K 0x0027 39 0 0 1 0 0 1 1 1
-O---K 0x0022 34 0 0 1 0 0 0 1 0
---R-- 0x0008 8 0 0 0 0 1 0 0 0
K C R S O P
| | | | | |_ P prefailure warning
| | | | |__ O updated online
| | | |___ S speed/performance
| | |____ R error rate
| |_____ C event count
|______ K auto-keep
------
FIXME: Do a self-test after installation, to verify that all the parsing code works on the user's system (wrt possibly different versions of tools), and write an UnexpectedOutput/ExpectedOutputMissing handler that lets the user report any failures.
------
Security stuff:
- CSRF?
- Helmet?
- Zeroing volumes after/before use
------
# Stuff to add
Hardware -> Storage Devices
- Disk space used
- Disk space allocated (can be over 100% with thin provisioning!)
- Allocation:usage ratio
- IOPS
- Read/Write traffic
- Read/Write latency

@ -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; }

@ -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;
}

@ -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;
};

@ -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();
}
};

@ -0,0 +1,3 @@
"use strict";
console.log("Hello world!");

@ -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
});
}
};
};

@ -0,0 +1,15 @@
"use strict";
module.exports = function createFileStoragePool({ storePath }) {
return {
createDisk: function ({ size, format }) {
},
resizeDisk: function ({ id, newSize }) {
},
removeDisk: function ({ id }) {
}
};
};

@ -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");

@ -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;
}

@ -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;
}

@ -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);
}
}
}

@ -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;

@ -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");
// });

@ -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

@ -15,3 +15,6 @@ body
.content
block content
script(src="/js/bundle.js")
script(src="/budo/livereload.js")

10207
yarn.lock

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save