Add Hardware sub-layout and submenu, implement auto-scaling of units,improve withData API, add filterable allBlockDevices property to drive API, WIP: convert drives page to JSX + GraphQL

feature/node-rewrite
Sven Slootweg 5 years ago
parent 937ab5154d
commit 5be1872be3

@ -4,7 +4,7 @@
"description": "A VPS management panel", "description": "A VPS management panel",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"dev": "NODE_ENV=development nodemon --ext js,pug,jsx --ignore node_modules --ignore src/client bin/server.js" "dev": "NODE_ENV=development nodemon --ext js,pug,jsx,gql --ignore node_modules --ignore src/client bin/server.js"
}, },
"repository": { "repository": {
"type": "git", "type": "git",

@ -11,22 +11,45 @@ label {
.menu { .menu {
background-color: #000424; } background-color: #000424; }
.menu h1, .menu .menu-item { .menu h1, .menu .menuItem {
display: inline-block; display: inline-block;
color: white; } color: white; }
.menu h1 { .menu h1 {
margin: 0px 16px; } margin: 0px 16px; }
.menu .menu-item a { .menu .menuItem a {
color: white; color: white;
text-decoration: none; text-decoration: none;
padding: 15px 9px 5px 9px; } padding: 15px 9px 5px 9px; }
.menu .menu-item.active a { .menu .menuItem.active a {
background-color: #e4e4e4; /* FIXME: Make this lighter when there is no submenu, to match the page background color */
background-color: #dddddd;
color: black; } color: black; }
.menu .menu-item:not(.active) a:hover { .menu .menuItem:not(.active) a:hover {
background-color: #afafaf; background-color: #afafaf;
color: black; } color: black; }
.fakeSubmenu, .submenu {
background: linear-gradient(to bottom, #dddddd, #dddddd 60%, #caccce); }
.fakeSubmenu {
height: 16px; }
.submenu {
padding: .3em .2em 0 .2em;
border-bottom: 1px solid #000424; }
.submenu .menuItem {
display: inline-block;
margin-bottom: -1px;
padding: .3em .7em;
font-size: .95em; }
.submenu .menuItem a {
text-decoration: none;
color: black; }
.submenu .menuItem.active {
background-color: #e4e4e4;
border: 1px solid #000424;
border-bottom: none; }
table { table {
border-collapse: collapse; } border-collapse: collapse; }
table th, table td { table th, table td {
@ -37,13 +60,13 @@ table {
table td.hidden { table td.hidden {
border: none; } border: none; }
table.drives td.smart.healthy { table.drives td.smart.HEALTHY {
background-color: #00a500; } background-color: #00a500; }
table.drives td.smart.deteriorating { table.drives td.smart.DETERIORATING {
background-color: #ff9100; } background-color: #ff9100; }
table.drives td.smart.failing { table.drives td.smart.FAILING {
background-color: #e60000; } background-color: #e60000; }
table.drives .hasPartitions td:not(.smart), table.drives .partition:not(.last) td:not(.smart) { table.drives .hasPartitions td:not(.smart), table.drives .partition:not(.last) td:not(.smart) {

@ -3,27 +3,10 @@
const Promise = require("bluebird"); const Promise = require("bluebird");
const memoizee = require("memoizee"); const memoizee = require("memoizee");
const linearizeTree = require("../../linearize-tree");
const lsblk = require("../../wrappers/lsblk"); const lsblk = require("../../wrappers/lsblk");
const All = require("../../graphql/symbols/all"); const All = require("../../graphql/symbols/all");
function linearizeDevices(devices) {
let linearizedDevices = [];
function add(list) {
for (let device of list) {
linearizedDevices.push(device);
if (device.children != null) {
add(device.children);
}
}
}
add(devices);
return linearizedDevices;
}
module.exports = function () { module.exports = function () {
let lsblkOnce = memoizee(() => { let lsblkOnce = memoizee(() => {
return Promise.try(() => { return Promise.try(() => {
@ -31,7 +14,7 @@ module.exports = function () {
}).then((devices) => { }).then((devices) => {
return { return {
tree: devices, tree: devices,
list: linearizeDevices(devices) list: linearizeTree(devices)
}; };
}); });
}); });
@ -40,6 +23,7 @@ module.exports = function () {
return Promise.try(() => { return Promise.try(() => {
return lsblkOnce(); return lsblkOnce();
}).then(({tree, list}) => { }).then(({tree, list}) => {
return names.map((name) => { return names.map((name) => {
if (name === All) { if (name === All) {
return tree; return tree;

@ -1,15 +1,15 @@
"use strict"; "use strict";
const {createDataObject, LocalProperties, ID} = require("../../graphql/data-object"); const {createDataObject, LocalProperties, ID} = require("../../graphql/data-object");
const matchOrError = require("../../match-or-error"); const deviceNameFromPath = require("../../device-name-from-path");
const mapValue = require("../../map-value");
module.exports = function (_types) { module.exports = function (_types) {
return function BlockDevice({ name, path }) { return function BlockDevice({ name, path }) {
if (name != null) { if (name != null) {
path = `/dev/${name}`; path = `/dev/${name}`;
} else if (path != null) { } else if (path != null) {
let match = matchOrError(/^\/dev\/(.+)$/, path); name = deviceNameFromPath(path);
name = match[0];
} }
/* FIXME: parent */ /* FIXME: parent */
@ -21,6 +21,13 @@ module.exports = function (_types) {
lsblk: { lsblk: {
[ID]: name, [ID]: name,
name: "name", name: "name",
type: (device) => {
return mapValue(device.type, {
partition: "PARTITION",
disk: "DISK",
loopDevice: "LOOP_DEVICE"
});
},
size: "size", size: "size",
mountpoint: "mountpoint", mountpoint: "mountpoint",
deviceNumber: "deviceNumber", deviceNumber: "deviceNumber",

@ -1,7 +1,11 @@
"use strict"; "use strict";
const Promise = require("bluebird");
const {createDataObject, LocalProperties, ID} = require("../../graphql/data-object"); const {createDataObject, LocalProperties, ID} = require("../../graphql/data-object");
const upperSnakeCase = require("../../upper-snake-case"); const upperSnakeCase = require("../../upper-snake-case");
const linearizeTree = require("../../linearize-tree");
const deviceNameFromPath = require("../../device-name-from-path");
module.exports = function (types) { module.exports = function (types) {
return function Drive({ path }) { return function Drive({ path }) {
@ -13,6 +17,25 @@ module.exports = function (types) {
}, },
/* FIXME: allBlockDevices, for representing every single block device that's hosted on this physical drive, linearly. Need to figure out how that works with representation of mdraid arrays, LVM volumes, etc. */ /* FIXME: allBlockDevices, for representing every single block device that's hosted on this physical drive, linearly. Need to figure out how that works with representation of mdraid arrays, LVM volumes, etc. */
}, },
lsblk: {
[ID]: deviceNameFromPath(path),
allBlockDevices: function (rootDevice, { type }, context) {
let devices = linearizeTree([rootDevice])
.map((device) => types.BlockDevice({ name: device.name }));
if (type != null) {
return Promise.filter(devices, (device) => {
return Promise.try(() => {
return device.type({}, context);
}).then((deviceType) => {
return (deviceType === type);
});
});
} else {
return devices;
}
}
},
smartctlScan: { smartctlScan: {
[ID]: path, [ID]: path,
interface: "interface" interface: "interface"

@ -0,0 +1,8 @@
"use strict";
const matchOrError = require("./match-or-error");
module.exports = function deviceNameFromPath(path) {
let [name] = matchOrError(/^\/dev\/(.+)$/, path);
return name;
};

@ -9,7 +9,9 @@ function withProperty(dataSource, id, property) {
} }
function withData(dataSource, id, callback) { function withData(dataSource, id, callback) {
return function (_, {data}) { return function (args, context) {
let {data} = context;
return Promise.try(() => { return Promise.try(() => {
if (data[dataSource] != null) { if (data[dataSource] != null) {
return data[dataSource].load(id); return data[dataSource].load(id);
@ -18,7 +20,7 @@ function withData(dataSource, id, callback) {
} }
}).then((value) => { }).then((value) => {
if (value != null) { if (value != null) {
return callback(value); return callback(value, args, context);
} else { } else {
throw new Error(`Got a null value from data source '${dataSource}' for ID '${id}'`); throw new Error(`Got a null value from data source '${dataSource}' for ID '${id}'`);
} }

@ -0,0 +1,19 @@
"use strict";
module.exports = function linearizeTree(rootList, childrenProperty = "children") {
let linearizedItems = [];
function add(list) {
for (let item of list) {
linearizedItems.push(item);
if (item[childrenProperty] != null) {
add(item[childrenProperty]);
}
}
}
add(rootList);
return linearizedItems;
};

@ -27,10 +27,36 @@ module.exports = function makeUnits(unitSpecs) {
} }
}, },
toString: function () { toString: function () {
/* TODO: Make this auto-convert */
return `${this.amount} ${this.unit}`; return `${this.amount} ${this.unit}`;
}, },
} toDisplay: function (decimals) {
let roundingFactor = Math.pow(10, decimals);
let unitMagnitude = i;
let amount = this.amount;
let unitsLeft = (i < unitSpecs.length - 1);
function createOfCurrentMagnitude() {
let roundedValue = Math.round(amount * roundingFactor) / roundingFactor;
return resultObject[unitSpecs[unitMagnitude].unit](roundedValue);
}
while (unitsLeft === true) {
let currentUnit = unitSpecs[unitMagnitude];
if (amount < currentUnit.toNext) {
return createOfCurrentMagnitude();
} else {
amount = amount / currentUnit.toNext;
unitMagnitude += 1;
unitsLeft = (unitMagnitude < unitSpecs.length - 1);
}
}
return createOfCurrentMagnitude;
}
};
unitSpecs.forEach((otherSpec, otherI) => { unitSpecs.forEach((otherSpec, otherI) => {
let factor = 1; let factor = 1;
@ -61,7 +87,7 @@ module.exports = function makeUnits(unitSpecs) {
amount: value amount: value
}); });
} }
} };
}); });
return resultObject; return resultObject;

@ -0,0 +1,9 @@
"use strict";
module.exports = function prefixTitle(prefix, title) {
if (title == null) {
return title;
} else {
return `${prefix} ${title}`;
}
};

@ -282,8 +282,15 @@ type SmartAttribute {
updatedWhen: SmartAttributeUpdateType! updatedWhen: SmartAttributeUpdateType!
} }
enum BlockDeviceType {
DISK
PARTITION
LOOP_DEVICE
}
type BlockDevice { type BlockDevice {
name: String! name: String!
type: BlockDeviceType!
path: String! path: String!
mountpoint: String mountpoint: String
deviceNumber: String! deviceNumber: String!
@ -298,6 +305,7 @@ type PhysicalDrive {
path: String! path: String!
interface: String! interface: String!
blockDevice: BlockDevice! blockDevice: BlockDevice!
allBlockDevices(type: BlockDeviceType): [BlockDevice!]!
smartAvailable: Boolean! smartAvailable: Boolean!
smartEnabled: Boolean smartEnabled: Boolean
smartHealth: SmartHealth smartHealth: SmartHealth

@ -1,5 +1,9 @@
$bodyBackgroundColor: rgb(228, 228, 228);
$menuBackgroundColor: rgb(0, 4, 36);
$submenuBackgroundColor: rgb(221, 221, 221);
body { body {
background-color: rgb(228, 228, 228); background-color: $bodyBackgroundColor;
margin: 0px; margin: 0px;
font-family: sans-serif; font-family: sans-serif;
} }
@ -17,9 +21,9 @@ label {
} }
.menu { .menu {
background-color: rgb(0, 4, 36); background-color: $menuBackgroundColor;
h1, .menu-item { h1, .menuItem {
display: inline-block; display: inline-block;
color: white; color: white;
} }
@ -28,7 +32,7 @@ label {
margin: 0px 16px; margin: 0px 16px;
} }
.menu-item { .menuItem {
a { a {
color: white; color: white;
text-decoration: none; text-decoration: none;
@ -37,7 +41,9 @@ label {
&.active { &.active {
a { a {
background-color: rgb(228, 228, 228); // background-color: rgb(228, 228, 228);
/* FIXME: Make this lighter when there is no submenu, to match the page background color */
background-color: $submenuBackgroundColor;
color: black; color: black;
} }
} }
@ -51,6 +57,39 @@ label {
} }
} }
.fakeSubmenu, .submenu {
background: linear-gradient(to bottom, $submenuBackgroundColor, $submenuBackgroundColor 60%, rgb(202, 204, 206));
}
.fakeSubmenu {
height: 16px;
}
.submenu {
padding: .3em .2em 0 .2em;
border-bottom: 1px solid $menuBackgroundColor;
.menuItem {
display: inline-block;
margin-bottom: -1px;
padding: .3em .7em;
font-size: .95em;
a {
text-decoration: none;
color: black;
}
// background-color: red;
&.active {
background-color: $bodyBackgroundColor;
border: 1px solid $menuBackgroundColor;
border-bottom: none;
}
}
}
table { table {
border-collapse: collapse; border-collapse: collapse;
@ -70,15 +109,15 @@ table {
table.drives { table.drives {
td.smart { td.smart {
&.healthy { &.HEALTHY {
background-color: rgb(0, 165, 0); background-color: rgb(0, 165, 0);
} }
&.deteriorating { &.DETERIORATING {
background-color: rgb(255, 145, 0); background-color: rgb(255, 145, 0);
} }
&.failing { &.FAILING {
background-color: rgb(230, 0, 0); background-color: rgb(230, 0, 0);
} }
} }

@ -0,0 +1,20 @@
"use strict";
const React = require("react");
const classnames = require("classnames");
const {LocalsContext} = require("../../express-async-react");
const isUnderPrefix = require("../../is-under-prefix");
module.exports = function MenuItem({ path, children }) {
let {currentPath} = React.useContext(LocalsContext);
let isActive = isUnderPrefix(path, currentPath);
return (
<div className={classnames("menuItem", {active: isActive})}>
<a href={path}>
{children}
</a>
</div>
);
};

@ -2,14 +2,18 @@
const React = require("react"); const React = require("react");
const Layout = require("./layout");
module.exports = { module.exports = {
template: function ErrorPage({ error }) { template: function ErrorPage({ error }) {
return ( return (
<div className="error"> <Layout title="An error occurred">
<h1>An error occurred.</h1> <div className="error">
<h2>{ error.message }</h2> <h1>An error occurred.</h1>
<pre>{ error.stack }</pre> <h2>{ error.message }</h2>
</div> <pre>{ error.stack }</pre>
</div>
</Layout>
); );
} }
}; };

@ -0,0 +1,24 @@
"use strict";
const React = require("react");
const MainLayout = require("../layout");
const MenuItem = require("../components/menu-item");
const prefixTitle = require("../../prefix-title");
function Submenu() {
return (<>
<MenuItem path="/hardware/system-information">System Information</MenuItem>
<MenuItem path="/hardware/storage-devices">Storage Devices</MenuItem>
<MenuItem path="/hardware/network-interfaces">Network Interfaces</MenuItem>
</>);
}
module.exports = function HardwareLayout({ children, title }) {
return (
<MainLayout submenu={<Submenu />} title={prefixTitle("Hardware >", title)}>
{children}
</MainLayout>
);
};

@ -1,109 +1,91 @@
"use strict"; "use strict";
const React = require("react"); const React = require("react");
const classnames = require("classnames");
const Layout = require("../../layout"); const Layout = require("../layout");
const gql = require("../../../graphql/tag"); const gql = require("../../../graphql/tag");
function PartitionEntry({partition, isLast}) {
return (
<tr className={classnames("partition", {last: isLast})}>
<td>{partition.name}</td>
<td>{partition.size.toString()}</td>
<td colSpan={5}>
{(partition.mountpoint != null)
? partition.mountpoint
: <span className="notMounted">(not mounted)</span>
}
</td>
</tr>
);
}
function DriveEntry({drive}) {
let hasPartitions = (drive.partitions.length > 0);
return (<>
<tr className={classnames({hasPartitions})}>
<td className={classnames("smart", drive.smartHealth)} rowSpan={1 + drive.partitions.length} />
<td>{drive.blockDevice.name}</td>
<td>{drive.size.toDisplay(2).toString()}</td>
<td>{drive.rpm} RPM</td>
<td>{drive.serialNumber}</td>
<td>{drive.model}</td>
<td>{drive.modelFamily}</td>
<td>{drive.firmwareVersion}</td>
</tr>
{drive.partitions.map((partition, i) => {
let isLast = (i === drive.partitions.length - 1);
return <PartitionEntry partition={partition} isLast={isLast} />;
})}
</>);
}
module.exports = { module.exports = {
query: gql` query: gql`
query { query {
hardware { hardware {
drives { drives {
path smartHealth
size
rpm
serialNumber
model model
modelFamily modelFamily
firmwareVersion
blockDevice {
name
}
partitions: allBlockDevices(type: PARTITION) {
name
mountpoint
size
}
} }
} }
} }
`, `,
template: function StorageDeviceList({data}) { template: function StorageDeviceList({data}) {
return ( return (
<Layout> <Layout title="Storage Devices">
<div className="fancyStuff"> <table className="drives">
<ul> <tr>
{data.hardware.drives.map((drive) => { <th>SMART</th>
return <li> <th>Device</th>
{drive.path}: {drive.model} ({drive.modelFamily}) <th>Total size</th>
</li>; <th>RPM</th>
})} <th>Serial number</th>
</ul> <th>Model</th>
</div> <th>Family</th>
<th>Firmware version</th>
</tr>
{data.hardware.drives.map((drive) => <DriveEntry drive={drive} />)}
</table>
</Layout> </Layout>
); );
} }
}; };
// 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

@ -0,0 +1,15 @@
"use strict";
const React = require("react");
const Layout = require("./layout");
module.exports = {
template: function Index() {
return (
<Layout>
Hello world!
</Layout>
);
}
};

@ -1,4 +0,0 @@
extends layout
block content
| Hello World!

@ -1,40 +1,36 @@
"use strict"; "use strict";
const React = require("react"); const React = require("react");
const classnames = require("classnames");
const {LocalsContext} = require("../express-async-react"); const MenuItem = require("./components/menu-item");
const isUnderPrefix = require("../is-under-prefix");
function MenuItem({ path, children }) {
let {currentPath} = React.useContext(LocalsContext);
let isActive = isUnderPrefix(path, currentPath);
return ( module.exports = function Layout({ title, submenu, children }) {
<div className={classnames("menu-item", {active: isActive})}>
<a href={path}>
{children}
</a>
</div>
);
}
module.exports = function Layout({ children }) {
return ( return (
<html> <html>
<head> <head>
<title>CVM</title> <title>
{(title != null)
? `CVM - ${title}`
: "CVM"
}
</title>
<link rel="stylesheet" href="/css/style.css"/> <link rel="stylesheet" href="/css/style.css"/>
</head> </head>
<body> <body>
<div className="menu"> <div className="menu">
<h1>CVM</h1> <h1>CVM</h1>
<MenuItem path="/hardware">Hardware</MenuItem> <MenuItem path="/hardware">Hardware</MenuItem>
<MenuItem path="/resource-pools">Resource Pools</MenuItem>
<MenuItem path="/disk-images">Disk Images</MenuItem> <MenuItem path="/disk-images">Disk Images</MenuItem>
<MenuItem path="/instances">Instances</MenuItem> <MenuItem path="/instances">Instances</MenuItem>
<MenuItem path="/users">Users</MenuItem> <MenuItem path="/users">Users</MenuItem>
</div> </div>
{(submenu != null)
? <div className="submenu">{submenu}</div>
: <div className="fakeSubmenu"></div>
}
<div className="content"> <div className="content">
{children} {children}
</div> </div>

@ -1,20 +0,0 @@
mixin menu-item(prefix)
.menu-item(class=isUnderPrefix(prefix, "active"))
block
doctype html
head
title CVM
link(rel="stylesheet", href="/css/style.css")
body
.menu
h1 CVM
+menu-item("/disk-images"): a(href="/disk-images") Disk Images
+menu-item("/instances"): a(href="/instances") Instances
+menu-item("/users"): a(href="/users") Users
.content
block content
script(src="/js/bundle.js")
script(src="/budo/livereload.js")
Loading…
Cancel
Save