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",
"main": "index.js",
"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": {
"type": "git",

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

@ -3,27 +3,10 @@
const Promise = require("bluebird");
const memoizee = require("memoizee");
const linearizeTree = require("../../linearize-tree");
const lsblk = require("../../wrappers/lsblk");
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 () {
let lsblkOnce = memoizee(() => {
return Promise.try(() => {
@ -31,7 +14,7 @@ module.exports = function () {
}).then((devices) => {
return {
tree: devices,
list: linearizeDevices(devices)
list: linearizeTree(devices)
};
});
});
@ -40,6 +23,7 @@ module.exports = function () {
return Promise.try(() => {
return lsblkOnce();
}).then(({tree, list}) => {
return names.map((name) => {
if (name === All) {
return tree;

@ -1,15 +1,15 @@
"use strict";
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) {
return function BlockDevice({ name, path }) {
if (name != null) {
path = `/dev/${name}`;
} else if (path != null) {
let match = matchOrError(/^\/dev\/(.+)$/, path);
name = match[0];
name = deviceNameFromPath(path);
}
/* FIXME: parent */
@ -21,6 +21,13 @@ module.exports = function (_types) {
lsblk: {
[ID]: name,
name: "name",
type: (device) => {
return mapValue(device.type, {
partition: "PARTITION",
disk: "DISK",
loopDevice: "LOOP_DEVICE"
});
},
size: "size",
mountpoint: "mountpoint",
deviceNumber: "deviceNumber",

@ -1,7 +1,11 @@
"use strict";
const Promise = require("bluebird");
const {createDataObject, LocalProperties, ID} = require("../../graphql/data-object");
const upperSnakeCase = require("../../upper-snake-case");
const linearizeTree = require("../../linearize-tree");
const deviceNameFromPath = require("../../device-name-from-path");
module.exports = function (types) {
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. */
},
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: {
[ID]: path,
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) {
return function (_, {data}) {
return function (args, context) {
let {data} = context;
return Promise.try(() => {
if (data[dataSource] != null) {
return data[dataSource].load(id);
@ -18,7 +20,7 @@ function withData(dataSource, id, callback) {
}
}).then((value) => {
if (value != null) {
return callback(value);
return callback(value, args, context);
} else {
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 () {
/* TODO: Make this auto-convert */
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) => {
let factor = 1;
@ -61,7 +87,7 @@ module.exports = function makeUnits(unitSpecs) {
amount: value
});
}
}
};
});
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!
}
enum BlockDeviceType {
DISK
PARTITION
LOOP_DEVICE
}
type BlockDevice {
name: String!
type: BlockDeviceType!
path: String!
mountpoint: String
deviceNumber: String!
@ -298,6 +305,7 @@ type PhysicalDrive {
path: String!
interface: String!
blockDevice: BlockDevice!
allBlockDevices(type: BlockDeviceType): [BlockDevice!]!
smartAvailable: Boolean!
smartEnabled: Boolean
smartHealth: SmartHealth

@ -1,5 +1,9 @@
$bodyBackgroundColor: rgb(228, 228, 228);
$menuBackgroundColor: rgb(0, 4, 36);
$submenuBackgroundColor: rgb(221, 221, 221);
body {
background-color: rgb(228, 228, 228);
background-color: $bodyBackgroundColor;
margin: 0px;
font-family: sans-serif;
}
@ -17,9 +21,9 @@ label {
}
.menu {
background-color: rgb(0, 4, 36);
background-color: $menuBackgroundColor;
h1, .menu-item {
h1, .menuItem {
display: inline-block;
color: white;
}
@ -28,7 +32,7 @@ label {
margin: 0px 16px;
}
.menu-item {
.menuItem {
a {
color: white;
text-decoration: none;
@ -37,7 +41,9 @@ label {
&.active {
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;
}
}
@ -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 {
border-collapse: collapse;
@ -70,15 +109,15 @@ table {
table.drives {
td.smart {
&.healthy {
&.HEALTHY {
background-color: rgb(0, 165, 0);
}
&.deteriorating {
&.DETERIORATING {
background-color: rgb(255, 145, 0);
}
&.failing {
&.FAILING {
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 Layout = require("./layout");
module.exports = {
template: function ErrorPage({ error }) {
return (
<div className="error">
<h1>An error occurred.</h1>
<h2>{ error.message }</h2>
<pre>{ error.stack }</pre>
</div>
<Layout title="An error occurred">
<div className="error">
<h1>An error occurred.</h1>
<h2>{ error.message }</h2>
<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";
const React = require("react");
const classnames = require("classnames");
const Layout = require("../../layout");
const Layout = require("../layout");
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 = {
query: gql`
query {
hardware {
drives {
path
smartHealth
size
rpm
serialNumber
model
modelFamily
firmwareVersion
blockDevice {
name
}
partitions: allBlockDevices(type: PARTITION) {
name
mountpoint
size
}
}
}
}
`,
template: function StorageDeviceList({data}) {
return (
<Layout>
<div className="fancyStuff">
<ul>
{data.hardware.drives.map((drive) => {
return <li>
{drive.path}: {drive.model} ({drive.modelFamily})
</li>;
})}
</ul>
</div>
<Layout title="Storage Devices">
<table className="drives">
<tr>
<th>SMART</th>
<th>Device</th>
<th>Total size</th>
<th>RPM</th>
<th>Serial number</th>
<th>Model</th>
<th>Family</th>
<th>Firmware version</th>
</tr>
{data.hardware.drives.map((drive) => <DriveEntry drive={drive} />)}
</table>
</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";
const React = require("react");
const classnames = require("classnames");
const {LocalsContext} = require("../express-async-react");
const isUnderPrefix = require("../is-under-prefix");
function MenuItem({ path, children }) {
let {currentPath} = React.useContext(LocalsContext);
let isActive = isUnderPrefix(path, currentPath);
const MenuItem = require("./components/menu-item");
return (
<div className={classnames("menu-item", {active: isActive})}>
<a href={path}>
{children}
</a>
</div>
);
}
module.exports = function Layout({ children }) {
module.exports = function Layout({ title, submenu, children }) {
return (
<html>
<head>
<title>CVM</title>
<title>
{(title != null)
? `CVM - ${title}`
: "CVM"
}
</title>
<link rel="stylesheet" href="/css/style.css"/>
</head>
<body>
<div className="menu">
<h1>CVM</h1>
<MenuItem path="/hardware">Hardware</MenuItem>
<MenuItem path="/resource-pools">Resource Pools</MenuItem>
<MenuItem path="/disk-images">Disk Images</MenuItem>
<MenuItem path="/instances">Instances</MenuItem>
<MenuItem path="/users">Users</MenuItem>
</div>
{(submenu != null)
? <div className="submenu">{submenu}</div>
: <div className="fakeSubmenu"></div>
}
<div className="content">
{children}
</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