--- /dev/null
+.env
+node_modules
--- /dev/null
+# Monitorcito
+
+A tiny monitor that gives you the status of a handful of systemd services.
+
+## Configuration
+
+This project uses environment variables to change configuration.
+
+* `MONITORCITO_SERVICES`: A comma separated list of services. Required.
+* `MONITORCITO_PORT`: A comma separated list of services. Defaults to 1991.
+
+
+Set `NODE_DEBUG=monitorcito` if you want to log any output. Otherwise it will
+be silent.
+
+## How to run
+
+Set the appropriate environment variables and run `bin/monitorcito`.
+
+## The Frontend
+
+Monitorcito includes a public directory, this is a frontend that queries
+the service and refreshes every 5 seconds. The service assumes the API
+is running in `/api`.
+
+Here's an example of how to set it up with nginx.
+
+```
+upstream monitorcito {
+ server localhost:1991;
+ keepalive 64;
+}
+
+server {
+ listen 80;
+
+ root /home/deploy/src/monitorcito/public;
+ index index.html;
+
+ server_name monitor.unlimited.pizza;
+
+ location /api {
+ proxy_redirect off;
+
+ proxy_pass http://monitorcito;
+ }
+}
+```
--- /dev/null
+#!/usr/bin/env node
+
+const Monitorcito = require('..');
+const Http = require('http');
+const { debuglog } = require('util');
+
+const internals = {
+
+ kUnsetServicesError: 'Please specify comma separated services in MONITORCITO_SERVICES env variable',
+
+ arguments: null,
+ log: debuglog('monitorcito'),
+
+ prepareArguments() {
+
+ internals.log('Validating arguments');
+ if (!process.env.MONITORCITO_SERVICES) {
+ throw new Error(internals.kUnsetServicesError);
+ }
+
+ internals.arguments = process.env.MONITORCITO_SERVICES.split(',')
+ internals.log(`Arguments are ${internals.arguments}`);
+ },
+
+ startServer() {
+
+ internals.log('Setting up the server');
+ const server = Http.createServer(async (request, response) => {
+
+ internals.log('Incoming request');
+ const responseBody = JSON.stringify(await Monitorcito(internals.arguments));
+ internals.log(`Responding with ${responseBody}`);
+
+ response.writeHead(200, { 'Content-Type': 'application/json' });
+ response.write(responseBody);
+ response.end();
+ });
+ const port = Number(process.env.MONITORCITO_PORT) || 1991;
+ server.listen(port);
+ internals.log(`Listening on port ${port}`);
+ },
+
+ async run() {
+
+ internals.prepareArguments();
+ internals.startServer();
+ }
+};
+
+internals.run()
+ .catch((error) => {
+
+ console.error(error);
+ process.exit(1);
+ });
--- /dev/null
+MONITORCITO_SERVICES=comma,separated,list
--- /dev/null
+const { promisify } = require('util');
+const { exec } = require('child_process');
+
+const internals = {
+ kActiveIndicator: 'active',
+ kFieldSeparator: '\n',
+ kKeyValueSeparator: '=',
+ kBlockSeparator: '\n\n',
+ statusCommand: 'systemctl show --no-page -p Id -p ActiveState',
+
+ formatStatusOutput(systemctlText) {
+
+ const blocks = systemctlText.trim().split(internals.kBlockSeparator);
+ return blocks
+ .map((block) => {
+
+ const fields = block.split(internals.kFieldSeparator);
+ return fields.reduce((fieldObject, field) => {
+
+ const [key, value] = field.split(internals.kKeyValueSeparator);
+ return {
+ ...fieldObject,
+ [key]: value
+ }
+ }, {})
+
+ })
+ .reduce((statusObject, service) => {
+
+ return {
+ ...statusObject,
+ [service.Id]: service.ActiveState === internals.kActiveIndicator
+ }
+ }, {});
+ },
+
+ exec: promisify(exec)
+};
+
+module.exports = async (services) => {
+
+ const { stdout } = await internals.exec(`${internals.statusCommand} ${services.join(' ')}`);
+ return internals.formatStatusOutput(stdout);
+};
--- /dev/null
+{
+ "name": "monitorcito",
+ "version": "1.0.0",
+ "lockfileVersion": 1
+}
--- /dev/null
+{
+ "name": "monitorcito",
+ "version": "1.0.0",
+ "description": "A tiny web monitor for systems",
+ "main": "lib/index.js",
+ "directories": {
+ "lib": "lib"
+ },
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1"
+ },
+ "keywords": [
+ "systemd",
+ "monitor"
+ ],
+ "author": "Rubén Beltrán del Río <apps@unlimited.pizza>",
+ "license": "Apache-2.0",
+ "dependencies": {}
+}
--- /dev/null
+h1 canvas {
+ width: 64px;
+ height: 64px;
+ display: inline-block;
+ background-color: gainsboro;
+}
+
+table {
+ border-collapse: collapse;
+}
+
+th, td {
+ padding: 8px;
+ border-style: inset;
+}
+
+.true {
+ color: green;
+}
+.false {
+ color: red;
+}
+.critical {
+ font-weight: bold;
+ color: red;
+}
+
+@media (prefers-color-scheme: dark) {
+ body {
+ color: white;
+ background-color: black;
+ }
+
+ a {
+ color: #5dc1fd;
+ }
+
+ a:visited {
+ color: #ed6eff;
+ }
+}
--- /dev/null
+<!DOCTYPE HTML>
+
+<html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <meta name="author" content="Rubén Beltrán del Río">
+ <meta name="description" content="monitor @ unlimited.pizza - check the status of running services">
+
+ <title>monitor @ unlimited 🍕</title>
+
+ <link rel="stylesheet" type="text/css" href="/css/application.css">
+ <script type="module" src="https://unlimited.pizza/js/animation.js"></script>
+ <script type="module" src="/js/monitorcito.js"></script>
+
+ <!--
+ /\
+ / O\ U N L I M I T E D
+ /O o \ P I Z Z A
+ |______|
+ -->
+ </head>
+ <body>
+ <header>
+ <h1>
+ <canvas width=100 height=100></canvas>
+ <a href="/">Monitorcito</a>
+ </h1>
+ </header>
+ <main>
+ <p>Status of services running in unlimited dot pizza</p>
+ <table id="monitorcito">
+ </table>
+ </main>
+ </body>
+</html>
--- /dev/null
+(function () {
+
+ const table = document.querySelector('#monitorcito');
+
+ function loadData () {
+ fetch('/api')
+ .then((response) => response.json())
+ .then((serviceStatus) => {
+
+ while (table.firstChild) {
+ table.removeChild(table.firstChild)
+ }
+
+ for (const [service, isActive] of Object.entries(serviceStatus)) {
+ const row = document.createElement('tr');
+ const serviceCell = document.createElement('td');
+ serviceCell.appendChild(document.createTextNode(service));
+ const isActiveCell = document.createElement('td');
+ isActiveCell.appendChild(document.createTextNode(isActive ? 'OK' : 'FAIL'));
+ isActiveCell.classList.add(isActive);
+ row.appendChild(serviceCell);
+ row.appendChild(isActiveCell);
+ table.appendChild(row);
+ }
+
+ setTimeout(loadData, 5000);
+ })
+ .catch(() => {
+ const container = document.querySelector('main');
+ container.removeChild(table);
+ const paragraph = document.createElement('p');
+ paragraph.appendChild(document.createTextNode('Everything is broken. If your internet works, please check the server\'s not on fire. When in doubt, just refresh.'));
+ paragraph.classList.add('critical');
+ container.appendChild(paragraph);
+ })
+ }
+
+ loadData();
+}
+)();