diff --git a/package.json b/package.json index f37a394..7b2ae1b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/css/style.css b/public/css/style.css index 44384a0..bc56563 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -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) { diff --git a/src/api/data-sources/lsblk.js b/src/api/data-sources/lsblk.js index 54a3e10..c15f558 100644 --- a/src/api/data-sources/lsblk.js +++ b/src/api/data-sources/lsblk.js @@ -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; diff --git a/src/api/types/block-device.js b/src/api/types/block-device.js index 3d7c97d..d4269b3 100644 --- a/src/api/types/block-device.js +++ b/src/api/types/block-device.js @@ -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", diff --git a/src/api/types/drive.js b/src/api/types/drive.js index a19dbd5..f7610d2 100644 --- a/src/api/types/drive.js +++ b/src/api/types/drive.js @@ -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" diff --git a/src/device-name-from-path.js b/src/device-name-from-path.js new file mode 100644 index 0000000..2f41ed9 --- /dev/null +++ b/src/device-name-from-path.js @@ -0,0 +1,8 @@ +"use strict"; + +const matchOrError = require("./match-or-error"); + +module.exports = function deviceNameFromPath(path) { + let [name] = matchOrError(/^\/dev\/(.+)$/, path); + return name; +}; diff --git a/src/graphql/data-object.js b/src/graphql/data-object.js index d7483c7..a425f93 100644 --- a/src/graphql/data-object.js +++ b/src/graphql/data-object.js @@ -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}'`); } diff --git a/src/linearize-tree.js b/src/linearize-tree.js new file mode 100644 index 0000000..b68d6c7 --- /dev/null +++ b/src/linearize-tree.js @@ -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; +}; \ No newline at end of file diff --git a/src/make-units.js b/src/make-units.js index 57b5e2d..a528385 100644 --- a/src/make-units.js +++ b/src/make-units.js @@ -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; diff --git a/src/prefix-title.js b/src/prefix-title.js new file mode 100644 index 0000000..04e6634 --- /dev/null +++ b/src/prefix-title.js @@ -0,0 +1,9 @@ +"use strict"; + +module.exports = function prefixTitle(prefix, title) { + if (title == null) { + return title; + } else { + return `${prefix} ${title}`; + } +}; \ No newline at end of file diff --git a/src/schemas/main.gql b/src/schemas/main.gql index 948ca59..4876b75 100644 --- a/src/schemas/main.gql +++ b/src/schemas/main.gql @@ -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 diff --git a/src/scss/style.scss b/src/scss/style.scss index 7944e67..072c779 100644 --- a/src/scss/style.scss +++ b/src/scss/style.scss @@ -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); } } diff --git a/src/views/components/menu-item.jsx b/src/views/components/menu-item.jsx new file mode 100644 index 0000000..d7ce9b8 --- /dev/null +++ b/src/views/components/menu-item.jsx @@ -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 ( +
+ + {children} + +
+ ); +}; \ No newline at end of file diff --git a/src/views/error.jsx b/src/views/error.jsx index 95ca28c..277a939 100644 --- a/src/views/error.jsx +++ b/src/views/error.jsx @@ -2,14 +2,18 @@ const React = require("react"); +const Layout = require("./layout"); + module.exports = { template: function ErrorPage({ error }) { return ( -
-

An error occurred.

-

{ error.message }

-
{ error.stack }
-
+ +
+

An error occurred.

+

{ error.message }

+
{ error.stack }
+
+
); } }; diff --git a/src/views/hardware/layout.jsx b/src/views/hardware/layout.jsx new file mode 100644 index 0000000..d270e82 --- /dev/null +++ b/src/views/hardware/layout.jsx @@ -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 (<> + System Information + Storage Devices + Network Interfaces + ); +} + +module.exports = function HardwareLayout({ children, title }) { + return ( + } title={prefixTitle("Hardware >", title)}> + {children} + + ); +}; \ No newline at end of file diff --git a/src/views/hardware/storage-devices/list.jsx b/src/views/hardware/storage-devices/list.jsx index c34b0e9..ce23f69 100644 --- a/src/views/hardware/storage-devices/list.jsx +++ b/src/views/hardware/storage-devices/list.jsx @@ -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 ( + + {partition.name} + {partition.size.toString()} + + {(partition.mountpoint != null) + ? partition.mountpoint + : (not mounted) + } + + + ); +} + +function DriveEntry({drive}) { + let hasPartitions = (drive.partitions.length > 0); + + return (<> + + + {drive.blockDevice.name} + {drive.size.toDisplay(2).toString()} + {drive.rpm} RPM + {drive.serialNumber} + {drive.model} + {drive.modelFamily} + {drive.firmwareVersion} + + {drive.partitions.map((partition, i) => { + let isLast = (i === drive.partitions.length - 1); + + return ; + })} + ); +} + 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 ( - -
-
    - {data.hardware.drives.map((drive) => { - return
  • - {drive.path}: {drive.model} ({drive.modelFamily}) -
  • ; - })} -
-
+ + + + + + + + + + + + + {data.hardware.drives.map((drive) => )} +
SMARTDeviceTotal sizeRPMSerial numberModelFamilyFirmware version
); } -}; - - - - -// 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 +}; \ No newline at end of file diff --git a/src/views/index.jsx b/src/views/index.jsx new file mode 100644 index 0000000..be95fe8 --- /dev/null +++ b/src/views/index.jsx @@ -0,0 +1,15 @@ +"use strict"; + +const React = require("react"); + +const Layout = require("./layout"); + +module.exports = { + template: function Index() { + return ( + + Hello world! + + ); + } +}; \ No newline at end of file diff --git a/src/views/index.pug b/src/views/index.pug deleted file mode 100644 index 5d0d4c0..0000000 --- a/src/views/index.pug +++ /dev/null @@ -1,4 +0,0 @@ -extends layout - -block content - | Hello World! diff --git a/src/views/layout.jsx b/src/views/layout.jsx index 5dfa718..c04f53c 100644 --- a/src/views/layout.jsx +++ b/src/views/layout.jsx @@ -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 ( -
- - {children} - -
- ); -} - -module.exports = function Layout({ children }) { +module.exports = function Layout({ title, submenu, children }) { return ( - CVM + + {(title != null) + ? `CVM - ${title}` + : "CVM" + } +

CVM

Hardware + Resource Pools Disk Images Instances Users
+ {(submenu != null) + ?
{submenu}
+ :
+ } +
{children}
diff --git a/src/views/layout.pug b/src/views/layout.pug deleted file mode 100644 index bb4ba26..0000000 --- a/src/views/layout.pug +++ /dev/null @@ -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")