Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/pull-request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ on:
- "CONTRIBUTING.md"
- "LICENSE.md"

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
build-and-test:
runs-on: ubuntu-latest
Expand Down
14 changes: 0 additions & 14 deletions packages/dashboard/src/backend-driver.ts

This file was deleted.

191 changes: 168 additions & 23 deletions packages/dashboard/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,92 @@
import { Backend, createBackendFromDriver } from "@sidequest/backend";
import { logger } from "@sidequest/core";
import express from "express";
import basicAuth from "express-basic-auth";
import expressLayouts from "express-ejs-layouts";
import morgan from "morgan";
import { Server } from "node:http";
import path from "node:path";
import { initBackend } from "./backend-driver";
import { DashboardConfig } from "./config";
import dashboardRouter from "./resources/dashboard";
import jobsRouter from "./resources/jobs";
import queuesRouter from "./resources/queues";
import { createDashboardRouter } from "./resources/dashboard";
import { createJobsRouter } from "./resources/jobs";
import { createQueuesRouter } from "./resources/queues";

/**
* A dashboard server for monitoring and managing Sidequest jobs and queues.
*
* The SidequestDashboard class provides a web-based interface for viewing job status,
* queue information, and other Sidequest-related data. It sets up an Express.js server
* with EJS templating, optional basic authentication, and connects to a configurable backend.
*
* @example
* ```typescript
* const dashboard = new SidequestDashboard();
* await dashboard.start({
* port: 3000,
* enabled: true,
* auth: {
* user: 'admin',
* password: 'secret'
* },
* backendConfig: {
* driver: '@sidequest/sqlite-backend'
* }
* });
* ```
*/
export class SidequestDashboard {
private app: express.Express;
/**
* The Express application instance used by the dashboard server.
* This property is optional and may be undefined if the server has not been initialized.
*/
private app?: express.Express;
/**
* Optional dashboard configuration object that defines the behavior and appearance
* of the dashboard component. If not provided, default configuration will be used.
*/
private config?: DashboardConfig;
/**
* The HTTP server instance used by the dashboard application.
* This server handles incoming requests and serves the dashboard interface.
* Will be undefined until the server is started.
*/
private server?: Server;
/**
* Backend instance used for server communication and data operations.
* When undefined, indicates that no backend connection has been established.
*/
private backend?: Backend;

constructor() {
this.app = express();
}

/**
* Starts the dashboard server with the provided configuration.
*
* @param config - Optional dashboard configuration object. If not provided, default values will be used.
* @returns A promise that resolves when the server setup is complete.
*
* @remarks
* - If the server is already running, a warning is logged and the method returns early
* - If the dashboard is disabled in config, a debug message is logged and the method returns early
* - The method sets up the Express app, backend driver, middlewares, authentication, EJS templating, routes, and starts listening
*
* @example
* ```typescript
* await dashboard.start({
* enabled: true,
* port: 3000,
* backendConfig: { driver: "@sidequest/sqlite-backend" }
* });
* ```
*/
async start(config?: DashboardConfig) {
if (this.server?.listening) {
logger("Dashboard").warn("Dashboard is already running. Please stop it before starting again.");
return;
}

this.config = {
enabled: true,
backendConfig: {
Expand All @@ -35,7 +101,8 @@ export class SidequestDashboard {
return;
}

await initBackend(this.config.backendConfig!);
this.app ??= express();
this.backend = await createBackendFromDriver(this.config.backendConfig!);

this.setupMiddlewares();
this.setupAuth();
Expand All @@ -45,20 +112,49 @@ export class SidequestDashboard {
this.listen();
}

/**
* Sets up middleware for the Express application.
*
* Configures HTTP request logging using Morgan middleware when debug logging is enabled.
* The middleware uses the "combined" format for comprehensive request logging.
*
* @remarks
* - Only adds Morgan logging middleware when debug mode is active
* - Uses Apache combined log format for detailed request information
* - Logs are handled through the Dashboard logger instance
*/
setupMiddlewares() {
logger("Dashboard").debug(`Setting up Middlewares`);
if (logger().isDebugEnabled()) {
this.app?.use(morgan("combined"));
}
}

setupAuth(config?: DashboardConfig) {
if (config?.auth) {
const auth = config.auth;
/**
* Sets up basic authentication for the dashboard application.
*
* If authentication configuration is provided, this method configures
* HTTP Basic Authentication middleware using the specified username and password.
* The middleware will challenge unauthorized requests with a 401 response.
*
* @remarks
* - Only sets up authentication if `this.config.auth` is defined
* - Uses a single user/password combination from the configuration
* - Enables challenge mode to prompt for credentials in browsers
*
* @example
* ```typescript
* // Assuming config.auth = { user: "admin", password: "secret" }
* dashboard.setupAuth(); // Sets up basic auth for user "admin"
* ```
*/
setupAuth() {
if (this.config!.auth) {
const auth = this.config!.auth;
logger("Dashboard").debug(`Basic auth setup with User: ${auth.user}`);
const users = {};
users[auth.user] = auth.password;
this.app?.use(
this.app!.use(
basicAuth({
users: users,
challenge: true,
Expand All @@ -67,26 +163,56 @@ export class SidequestDashboard {
}
}

/**
* Sets up EJS templating engine for the dashboard application.
* This method configures the Express application to use EJS as the view engine,
* sets the views directory, and specifies the layout file.
*
* @remarks
* - Uses `express-ejs-layouts` for layout support
* - Sets the views directory to the `views` folder within the package
* - Serves static files from the `public` directory
* - Ensures that the EJS engine is ready to render views with layouts
*/
setupEJS() {
logger("Dashboard").debug(`Setting up EJS`);
this.app?.use(expressLayouts);
this.app?.set("view engine", "ejs");
this.app?.set("views", path.join(import.meta.dirname, "views"));
this.app?.set("layout", path.join(import.meta.dirname, "views", "layout"));
this.app?.use("/public", express.static(path.join(import.meta.dirname, "public")));
this.app!.use(expressLayouts);
this.app!.set("view engine", "ejs");
this.app!.set("views", path.join(import.meta.dirname, "views"));
this.app!.set("layout", path.join(import.meta.dirname, "views", "layout"));
this.app!.use("/public", express.static(path.join(import.meta.dirname, "public")));
}

/**
* Sets up the main application routes for the dashboard.
*
* This method initializes and attaches the dashboard, jobs, and queues routers
* to the Express application instance. It also logs the setup process for debugging purposes.
*
* @remarks
* - Assumes that `this.app` and `this.backend` are initialized.
* - Uses the routers created by `createDashboardRouter`, `createJobsRouter`, and `createQueuesRouter`.
*/
setupRoutes() {
logger("Dashboard").debug(`Setting up routes`);
this.app?.use("/", dashboardRouter);
this.app?.use("/jobs", jobsRouter);
this.app?.use("/queues", queuesRouter);
this.app!.use(...createDashboardRouter(this.backend!));
this.app!.use(...createJobsRouter(this.backend!));
this.app!.use(...createQueuesRouter(this.backend!));
}

/**
* Starts the dashboard server on the configured port.
* Logs the startup process and handles any errors that occur during server initialization.
*
* @remarks
* If no port is specified in the configuration, the default port 8678 is used.
*
* @returns void
*/
listen() {
const port = this.config?.port ?? 8678;
const port = this.config!.port ?? 8678;
logger("Dashboard").debug(`Starting Dashboard with port ${port}`);
this.server = this.app?.listen(port, (error) => {
this.server = this.app!.listen(port, (error) => {
if (error) {
logger("Dashboard").error("Failed to start Sidequest Dashboard!", error);
} else {
Expand All @@ -95,12 +221,31 @@ export class SidequestDashboard {
});
}

close() {
this.server?.close(() => {
logger("Dashboard").info("Sidequest Dashboard stopped");
/**
* Closes the dashboard by shutting down the backend and server,
* and cleaning up associated resources.
*
* - Awaits the closure of the backend if it exists.
* - Closes the server and logs a message when stopped.
* - Resets backend, server, config, and app properties to `undefined`.
* - Logs a debug message indicating resources have been cleaned up.
*
* @returns {Promise<void>} Resolves when all resources have been closed and cleaned up.
*/
async close() {
await this.backend?.close();
await new Promise<void>((resolve) => {
this.server?.close(() => {
logger("Dashboard").info("Sidequest Dashboard stopped");
resolve();
});
});

this.backend = undefined;
this.server = undefined;
this.config = undefined;
this.app = undefined;
logger("Dashboard").debug("Dashboard resources cleaned up");
}
}

Expand Down
91 changes: 45 additions & 46 deletions packages/dashboard/src/resources/dashboard.ts
Original file line number Diff line number Diff line change
@@ -1,58 +1,57 @@
import { Backend } from "@sidequest/backend";
import { Router } from "express";
import { getBackend } from "../backend-driver";

const dashboardRouter = Router();

function rangeToMs(range: string | undefined) {
let rangeMs: number;
switch (range) {
case "12h":
rangeMs = 12 * 60 * 60 * 1000;
break;
case "12d":
rangeMs = 12 * 24 * 60 * 60 * 1000;
break;
case "12m":
default:
// Defaults to 12m
rangeMs = 12 * 60 * 1000;
break;
}

return rangeMs;
}
export function createDashboardRouter(backend: Backend) {
const dashboardRouter = Router();

function rangeToMs(range: string | undefined) {
let rangeMs: number;
switch (range) {
case "12h":
rangeMs = 12 * 60 * 60 * 1000;
break;
case "12d":
rangeMs = 12 * 24 * 60 * 60 * 1000;
break;
case "12m":
default:
// Defaults to 12m
rangeMs = 12 * 60 * 1000;
break;
}

return rangeMs;
}

dashboardRouter.get("/", async (req, res) => {
const { range = "12m" } = req.query;
const from = new Date(Date.now() - rangeToMs(range as string));
const backend = getBackend();
const jobs = await backend.countJobs({ from });
dashboardRouter.get("/", async (req, res) => {
const { range = "12m" } = req.query;
const from = new Date(Date.now() - rangeToMs(range as string));
const jobs = await backend.countJobs({ from });

res.render("pages/index", {
title: "Sidequest Dashboard",
stats: jobs,
res.render("pages/index", {
title: "Sidequest Dashboard",
stats: jobs,
});
});
});

dashboardRouter.get("/dashboard/stats", async (req, res) => {
const { range = "12m" } = req.query;
const from = new Date(Date.now() - rangeToMs(range as string));
const backend = getBackend();
const jobs = await backend.countJobs({ from });
dashboardRouter.get("/dashboard/stats", async (req, res) => {
const { range = "12m" } = req.query;
const from = new Date(Date.now() - rangeToMs(range as string));
const jobs = await backend.countJobs({ from });

res.render("partials/dashboard-stats", {
stats: jobs,
layout: false,
res.render("partials/dashboard-stats", {
stats: jobs,
layout: false,
});
});
});

dashboardRouter.get("/dashboard/graph-data", async (req, res) => {
const { range = "12m" } = req.query;
dashboardRouter.get("/dashboard/graph-data", async (req, res) => {
const { range = "12m" } = req.query;

const backend = getBackend();
const jobs = await backend.countJobsOverTime(range as string);
const jobs = await backend.countJobsOverTime(range as string);

res.json(jobs).end();
});
res.json(jobs).end();
});

export default dashboardRouter;
return ["/", dashboardRouter] as const;
}
Loading
Loading