diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore index 01473fa..84b782c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,8 @@ node_modules dist .env *.local +.worktrees/ +.p* +result +docs/ +.direnv/ diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..0ed680d --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1777077449, + "narHash": "sha256-AIiMJiqvGrN4HyLEbKAoCSRRYn0rnlW5VbKNIMIYqm4=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "a4bf06618f0b5ee50f14ed8f0da77d34ecc19160", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-25.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..14992b2 --- /dev/null +++ b/flake.nix @@ -0,0 +1,71 @@ +{ + description = "AZion Scheduler - React SPA for scheduling tasks"; + + inputs = { + nixpkgs.url = "github:nixos/nixpkgs/nixos-25.11"; + }; + + outputs = { + self, + nixpkgs, + }: let + systems = [ + "aarch64-linux" + "x86_64-linux" + ]; + + forAllSystems = nixpkgs.lib.genAttrs systems; + in { + nixosModules.default = import ./nixos; + + devShells = forAllSystems (system: let + pkgs = nixpkgs.legacyPackages.${system}; + in { + default = pkgs.mkShell { + name = "azion-scheduler-dev"; + + buildInputs = with pkgs; [ + nodejs_22 + nodePackages.typescript + ]; + + shellHook = '' + echo "AZion Scheduler Development Environment" + echo "" + echo "Available commands:" + echo " npm run dev - Start development server" + echo " npm run build - Build for production" + echo " npm run preview - Preview production build" + echo "" + echo "Node version: $(node --version)" + echo "NPM version: $(npm --version)" + echo "==========================================" + ''; + }; + }); + + packages = forAllSystems (system: let + pkgs = nixpkgs.legacyPackages.${system}; + in { + default = pkgs.buildNpmPackage { + pname = "azion-scheduler"; + version = "2.0.0"; + + src = self; + + npmDepsHash = "sha256-/4YWme2q0MSQkhdAfDJwNP/qxCBtNC/FNTi41Uejdy0="; + + installPhase = '' + runHook preInstall + cp -r dist $out + runHook postInstall + ''; + + meta = with pkgs.lib; { + description = "AZion Scheduler web UI"; + platforms = platforms.linux; + }; + }; + }); + }; +} diff --git a/nixos/default.nix b/nixos/default.nix new file mode 100644 index 0000000..8a2bda4 --- /dev/null +++ b/nixos/default.nix @@ -0,0 +1,5 @@ +{ + imports = [ + ./module.nix + ]; +} diff --git a/nixos/module.nix b/nixos/module.nix new file mode 100644 index 0000000..15d898d --- /dev/null +++ b/nixos/module.nix @@ -0,0 +1,522 @@ +{ + config, + lib, + pkgs, + ... +}: let + cfg = config.services.azion-scheduler; + + proxyScript = pkgs.writeText "azion-scheduler-proxy.js" '' + 'use strict'; + + const http = require('node:http'); + + class HttpError extends Error { + constructor(statusCode, message) { + super(message); + this.name = 'HttpError'; + this.statusCode = statusCode; + } + } + + function readRequiredEnv(name) { + const value = process.env[name]; + if (!value) { + throw new Error('Missing required environment variable: ' + name); + } + return value; + } + + function parseBaseUrl(rawValue) { + let parsedUrl; + try { + parsedUrl = new URL(rawValue); + } catch (error) { + if (error instanceof TypeError) { + throw new Error('BASEROW_URL must be a valid absolute URL.'); + } + throw error; + } + + if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { + throw new Error('BASEROW_URL must start with http:// or https://.'); + } + + return parsedUrl; + } + + function parsePositiveInteger(rawValue, variableName) { + const parsedValue = Number.parseInt(rawValue, 10); + if (!Number.isInteger(parsedValue) || parsedValue <= 0) { + throw new Error(variableName + ' must be a positive integer.'); + } + return parsedValue; + } + + function createRowsUrl(baseUrl, tableId, rowId, isListRequest) { + const rowPath = rowId === null + ? '/api/database/rows/table/' + tableId + '/' + : '/api/database/rows/table/' + tableId + '/' + rowId + '/'; + + const rowsUrl = new URL(rowPath, baseUrl); + rowsUrl.searchParams.set('user_field_names', 'true'); + + if (isListRequest) { + rowsUrl.searchParams.set('size', '200'); + } + + return rowsUrl.toString(); + } + + function sendJson(response, statusCode, payload) { + const body = JSON.stringify(payload); + response.writeHead(statusCode, { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': Buffer.byteLength(body), + }); + response.end(body); + } + + function sendNoContent(response) { + response.writeHead(204); + response.end(); + } + + function sendMethodNotAllowed(response, allowedMethods) { + response.writeHead(405, { + Allow: allowedMethods.join(', '), + 'Content-Type': 'application/json; charset=utf-8', + }); + response.end(JSON.stringify({ error: 'Method not allowed.' })); + } + + function extractErrorMessage(payload, fallbackMessage) { + if (typeof payload === 'string' && payload.length > 0) { + return payload; + } + + if (payload && typeof payload === 'object') { + if (typeof payload.error === 'string' && payload.error.length > 0) { + return payload.error; + } + + if (typeof payload.detail === 'string' && payload.detail.length > 0) { + return payload.detail; + } + + if (typeof payload.message === 'string' && payload.message.length > 0) { + return payload.message; + } + + return JSON.stringify(payload); + } + + return fallbackMessage; + } + + async function readJsonFromResponse(response) { + const bodyText = await response.text(); + if (bodyText.length === 0) { + return null; + } + + try { + return JSON.parse(bodyText); + } catch (error) { + if (error instanceof SyntaxError) { + throw new HttpError(502, 'Baserow returned invalid JSON.'); + } + throw error; + } + } + + async function readRequestBody(request) { + const MAX_BODY_BYTES = 1024 * 1024; + + return await new Promise((resolve, reject) => { + let body = ""; + let hasSizeError = false; + + request.setEncoding('utf8'); + + request.on('data', (chunk) => { + if (hasSizeError) { + return; + } + + body += chunk; + + if (Buffer.byteLength(body, 'utf8') > MAX_BODY_BYTES) { + hasSizeError = true; + reject(new HttpError(413, 'Request body exceeds 1 MB.')); + request.destroy(); + } + }); + + request.on('end', () => { + if (!hasSizeError) { + resolve(body); + } + }); + + request.on('error', (error) => { + reject(new HttpError(400, 'Failed to read request body: ' + error.message)); + }); + }); + } + + async function readJsonBody(request) { + const rawBody = await readRequestBody(request); + + if (rawBody.trim().length === 0) { + return {}; + } + + let parsedBody; + try { + parsedBody = JSON.parse(rawBody); + } catch (error) { + if (error instanceof SyntaxError) { + throw new HttpError(400, 'Request body must be valid JSON.'); + } + throw error; + } + + if (!parsedBody || typeof parsedBody !== 'object' || Array.isArray(parsedBody)) { + throw new HttpError(400, 'Request body must be a JSON object.'); + } + + return parsedBody; + } + + const BASEROW_FETCH_TIMEOUT_MS = 10000; + + async function fetchWithTimeout(url, options) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), BASEROW_FETCH_TIMEOUT_MS); + + try { + return await fetch(url, { + ...options, + signal: controller.signal, + }); + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new HttpError(504, 'Baserow request timed out after 10000 ms.'); + } + + throw error; + } finally { + clearTimeout(timeoutId); + } + } + + async function callBaserow(options) { + const requestUrl = createRowsUrl( + options.baseUrl, + options.tableId, + options.rowId === undefined ? null : options.rowId, + options.isListRequest === true + ); + + const headers = { + Accept: 'application/json', + Authorization: 'Token ' + options.token, + }; + + const requestOptions = { + method: options.method, + headers, + }; + + if (options.payload !== undefined && options.payload !== null) { + headers['Content-Type'] = 'application/json'; + requestOptions.body = JSON.stringify(options.payload); + } + + const response = await fetchWithTimeout(requestUrl, requestOptions); + + if (!response.ok) { + const errorPayload = await readJsonFromResponse(response); + const message = extractErrorMessage(errorPayload, response.statusText || 'Request failed'); + throw new HttpError(response.status, 'Baserow request failed: ' + message); + } + + if (response.status === 204) { + return null; + } + + return await readJsonFromResponse(response); + } + + function parseRowId(pathname, pattern, entityName) { + const match = pathname.match(pattern); + if (!match) { + return null; + } + + const rowId = Number.parseInt(match[1], 10); + if (!Number.isInteger(rowId) || rowId <= 0) { + throw new HttpError(400, entityName + ' ID must be a positive integer.'); + } + + return rowId; + } + + function ensureListResult(value, entityName) { + if (!value || typeof value !== 'object' || !Array.isArray(value.results)) { + throw new HttpError(502, 'Baserow list response for ' + entityName + ' is invalid.'); + } + + return value.results; + } + + const proxyPort = ${toString cfg.proxyPort}; + const baserowUrl = parseBaseUrl(readRequiredEnv('BASEROW_URL')); + const baserowToken = readRequiredEnv('BASEROW_TOKEN'); + const serversTableId = parsePositiveInteger(readRequiredEnv('SERVERS_TABLE_ID'), 'SERVERS_TABLE_ID'); + const processesTableId = parsePositiveInteger(readRequiredEnv('PROCESSES_TABLE_ID'), 'PROCESSES_TABLE_ID'); + + async function handleRequest(request, response) { + const method = request.method || 'GET'; + const requestUrl = new URL(request.url || '/', 'http://127.0.0.1'); + const pathname = requestUrl.pathname; + + if (pathname === '/health') { + if (method !== 'GET') { + sendMethodNotAllowed(response, ['GET']); + return; + } + + sendJson(response, 200, { status: 'ok' }); + return; + } + + if (pathname === '/servers') { + if (method === 'GET') { + const listResponse = await callBaserow({ + baseUrl: baserowUrl, + token: baserowToken, + tableId: serversTableId, + method: 'GET', + isListRequest: true, + }); + + sendJson(response, 200, ensureListResult(listResponse, 'servers')); + return; + } + + if (method === 'POST') { + const payload = await readJsonBody(request); + const createdRow = await callBaserow({ + baseUrl: baserowUrl, + token: baserowToken, + tableId: serversTableId, + method: 'POST', + payload, + }); + + sendJson(response, 201, createdRow); + return; + } + + sendMethodNotAllowed(response, ['GET', 'POST']); + return; + } + + const serverRowId = parseRowId(pathname, /^\/servers\/(\d+)$/, 'Server'); + if (serverRowId !== null) { + if (method !== 'DELETE') { + sendMethodNotAllowed(response, ['DELETE']); + return; + } + + await callBaserow({ + baseUrl: baserowUrl, + token: baserowToken, + tableId: serversTableId, + method: 'DELETE', + rowId: serverRowId, + }); + + sendNoContent(response); + return; + } + + if (pathname === '/processes') { + if (method === 'GET') { + const listResponse = await callBaserow({ + baseUrl: baserowUrl, + token: baserowToken, + tableId: processesTableId, + method: 'GET', + isListRequest: true, + }); + + sendJson(response, 200, ensureListResult(listResponse, 'processes')); + return; + } + + if (method === 'POST') { + const payload = await readJsonBody(request); + const createdRow = await callBaserow({ + baseUrl: baserowUrl, + token: baserowToken, + tableId: processesTableId, + method: 'POST', + payload, + }); + + sendJson(response, 201, createdRow); + return; + } + + sendMethodNotAllowed(response, ['GET', 'POST']); + return; + } + + const processRowId = parseRowId(pathname, /^\/processes\/(\d+)$/, 'Process'); + if (processRowId !== null) { + if (method === 'PATCH') { + const payload = await readJsonBody(request); + const updatedRow = await callBaserow({ + baseUrl: baserowUrl, + token: baserowToken, + tableId: processesTableId, + method: 'PATCH', + rowId: processRowId, + payload, + }); + + sendJson(response, 200, updatedRow); + return; + } + + if (method === 'DELETE') { + await callBaserow({ + baseUrl: baserowUrl, + token: baserowToken, + tableId: processesTableId, + method: 'DELETE', + rowId: processRowId, + }); + + sendNoContent(response); + return; + } + + sendMethodNotAllowed(response, ['PATCH', 'DELETE']); + return; + } + + sendJson(response, 404, { error: 'Not found.' }); + } + + const server = http.createServer((request, response) => { + handleRequest(request, response).catch((error) => { + if (error instanceof HttpError) { + sendJson(response, error.statusCode, { error: error.message }); + return; + } + + if (error instanceof Error) { + console.error('[azion-scheduler-proxy] Request failed:', error.message); + sendJson(response, 500, { error: 'Internal server error.' }); + return; + } + + console.error('[azion-scheduler-proxy] Request failed with unknown error type.'); + sendJson(response, 500, { error: 'Internal server error.' }); + }); + }); + + server.on('clientError', (error, socket) => { + console.error('[azion-scheduler-proxy] Client error:', error.message); + socket.end('HTTP/1.1 400 Bad Request\\r\\n\\r\\n'); + }); + + server.listen(proxyPort, '127.0.0.1', () => { + console.log('[azion-scheduler-proxy] Listening on 127.0.0.1:' + proxyPort); + }); + ''; +in { + options.services.azion-scheduler = { + enable = lib.mkEnableOption "azion-scheduler web UI"; + + package = lib.mkOption { + type = lib.types.package; + description = "The azion-scheduler package to serve."; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 3039; + description = "Port where nginx listens locally."; + }; + + proxyPort = lib.mkOption { + type = lib.types.port; + default = 3049; + description = "Port for the internal Baserow proxy service."; + }; + + environmentFile = lib.mkOption { + type = lib.types.path; + description = "File containing BASEROW_* runtime credentials and table IDs."; + }; + }; + + config = lib.mkIf cfg.enable { + systemd.services.azion-scheduler-proxy = { + description = "AZion Scheduler Baserow proxy"; + wantedBy = [ "multi-user.target" ]; + before = [ "nginx.service" ]; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + + serviceConfig = { + Type = "simple"; + EnvironmentFile = cfg.environmentFile; + ExecStart = "${pkgs.nodejs}/bin/node ${proxyScript}"; + Restart = "on-failure"; + RestartSec = 3; + DynamicUser = true; + NoNewPrivileges = true; + }; + }; + + systemd.services.nginx = { + after = [ "azion-scheduler-proxy.service" ]; + wants = [ "azion-scheduler-proxy.service" ]; + }; + + services.nginx = { + enable = true; + + virtualHosts."azion-scheduler" = { + listen = [ + { + addr = "127.0.0.1"; + port = cfg.port; + } + ]; + + locations."/" = { + root = "${cfg.package}"; + tryFiles = "$uri $uri/ /index.html"; + }; + + locations."/api/" = { + proxyPass = "http://127.0.0.1:${toString cfg.proxyPort}/"; + extraConfig = '' + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + client_max_body_size 1m; + ''; + }; + }; + }; + }; +}