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
46 changes: 46 additions & 0 deletions examples/06-dashboard-base-path/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { logger, Sidequest } from "sidequest";
import { TestJob } from "./test-job.js";

async function main() {
logger().info("Starting Sidequest with dashboard base path...");

await Sidequest.start({
backend: {
driver: "@sidequest/sqlite-backend",
config: "./sidequest.sqlite",
},
dashboard: {
enabled: true,
port: 8678,
basePath: "/admin/sidequest",
},
queues: [{ name: "default", concurrency: 1 }],
});

logger().info("\n✅ Sidequest started!");
logger().info("🌐 Dashboard should be accessible at: http://localhost:8678/admin/sidequest");
logger().info("\n📝 Testing URLs:");
logger().info(" - Dashboard: http://localhost:8678/admin/sidequest/");
logger().info(" - Jobs: http://localhost:8678/admin/sidequest/jobs");
logger().info(" - Queues: http://localhost:8678/admin/sidequest/queues");
logger().info(" - Logo: http://localhost:8678/admin/sidequest/public/img/logo.png");
logger().info(" - Styles: http://localhost:8678/admin/sidequest/public/css/styles.css");

// Enqueue some test jobs
logger().info("\n📦 Enqueueing test jobs...");
for (let i = 1; i <= 5; i++) {
await Sidequest.build(TestJob).enqueue();
logger().info(` ✓ Job ${i} enqueued`);
}

logger().info("\n⚠️ Note: The dashboard should NOT be accessible at http://localhost:8678/ (without base path)");
logger().info("💡 Try accessing the dashboard and verify:");
logger().info(" 1. All assets load correctly (logo, styles, scripts)");
logger().info(" 2. Navigation links work (Dashboard, Jobs, Queues)");
logger().info(" 3. Job actions work (run, cancel, rerun)");
logger().info(" 4. HTMX polling/updates work correctly");
logger().info("\n🛑 Press Ctrl+C to stop\n");
}

// eslint-disable-next-line no-console
main().catch(console.error);
9 changes: 9 additions & 0 deletions examples/06-dashboard-base-path/test-job.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Job, logger } from "sidequest";

export class TestJob extends Job {
async run() {
logger().info(`Processing test job: ${this.id}`);
await new Promise((resolve) => setTimeout(resolve, 1000));
return { message: "Job completed successfully" };
}
}
26 changes: 26 additions & 0 deletions packages/dashboard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,32 @@ await dashboard.start({
// Dashboard available at http://localhost:8678
```

### Reverse Proxy Setup

When deploying behind a reverse proxy, use the `basePath` option:

```typescript
await Sidequest.start({
dashboard: {
port: 8678,
basePath: "/admin/sidequest", // Serve at /admin/sidequest
auth: {
user: "admin",
password: "secure-password",
},
},
});
```

Then configure your reverse proxy to forward requests:

```nginx
# Nginx example
location /admin/sidequest/ {
proxy_pass http://localhost:8678/admin/sidequest/;
}
```

## License

LGPL-3.0-or-later
58 changes: 58 additions & 0 deletions packages/dashboard/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,67 @@
import { BackendConfig } from "@sidequest/backend";

/**
* Configuration interface for the Sidequest dashboard.
*
* Defines the available options for configuring the dashboard including
* backend connectivity, server settings, authentication, and routing.
*
* @interface DashboardConfig
* @example
* ```typescript
* const config: DashboardConfig = {
* enabled: true,
* port: 3000,
* basePath: "/admin/sidequest",
* auth: {
* user: "admin",
* password: "secure-password"
* }
* };
* ```
*/
export interface DashboardConfig {
/**
* Configuration for connecting to the Sidequest backend.
* This includes the driver and any necessary connection options.
*/
backendConfig?: BackendConfig;
/**
* Indicates whether the dashboard is enabled.
* If set to false, the dashboard server will not start.
*
* @default false
*/
enabled?: boolean;
/**
* Port number on which the dashboard server will listen for incoming requests.
*
* @default 8678
*/
port?: number;
/**
* Base path for the dashboard when served behind a reverse proxy.
* For example, if you want to serve the dashboard at `/admin/sidequest`,
* set this to `/admin/sidequest`.
*
* @example "/admin/sidequest"
* @default ""
*/
basePath?: string;
/**
* Optional basic authentication configuration.
* If provided, the dashboard will require users to authenticate
* using the specified username and password.
*
* @example
* ```typescript
* auth: {
* user: 'admin',
* password: 'secure-password'
* }
* ```
* @default undefined
*/
auth?: {
user: string;
password: string;
Expand Down
30 changes: 26 additions & 4 deletions packages/dashboard/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,16 @@ export class SidequestDashboard {
...config,
};

// Normalize basePath: remove trailing slash, ensure leading slash
if (this.config.basePath) {
this.config.basePath = this.config.basePath.replace(/\/$/, "");
if (!this.config.basePath.startsWith("/")) {
this.config.basePath = "/" + this.config.basePath;
}
} else {
this.config.basePath = "";
}

if (!this.config.enabled) {
logger("Dashboard").debug(`Dashboard is disabled`);
return;
Expand Down Expand Up @@ -128,6 +138,12 @@ export class SidequestDashboard {
if (logger().isDebugEnabled()) {
this.app?.use(morgan("combined"));
}

// Make basePath available to all templates
this.app?.use((req, res, next) => {
res.locals.basePath = this.config!.basePath ?? "";
next();
});
}

/**
Expand Down Expand Up @@ -180,7 +196,8 @@ export class SidequestDashboard {
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")));
const publicPath = this.config!.basePath ? `${this.config!.basePath}/public` : "/public";
this.app!.use(publicPath, express.static(path.join(import.meta.dirname, "public")));
}

/**
Expand All @@ -195,9 +212,14 @@ export class SidequestDashboard {
*/
setupRoutes() {
logger("Dashboard").debug(`Setting up routes`);
this.app!.use(...createDashboardRouter(this.backend!));
this.app!.use(...createJobsRouter(this.backend!));
this.app!.use(...createQueuesRouter(this.backend!));
const basePath = this.config!.basePath ?? "";
const [dashboardPath, dashboardRouter] = createDashboardRouter(this.backend!);
const [jobsPath, jobsRouter] = createJobsRouter(this.backend!);
const [queuesPath, queuesRouter] = createQueuesRouter(this.backend!);

this.app!.use(basePath + dashboardPath, dashboardRouter);
this.app!.use(basePath + jobsPath, jobsRouter);
this.app!.use(basePath + queuesPath, queuesRouter);
}

/**
Expand Down
3 changes: 2 additions & 1 deletion packages/dashboard/src/public/js/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ const jobsTimeline = new Chart(ctx, {
});

async function refreshGraph() {
const res = await fetch(`/dashboard/graph-data?range=${currentRange}`);
const basePath = window.SIDEQUEST_BASE_PATH || "";
const res = await fetch(`${basePath}/dashboard/graph-data?range=${currentRange}`);
const graph = await res.json();
const timestamps = graph.map((entry) => entry.timestamp);

Expand Down
38 changes: 24 additions & 14 deletions packages/dashboard/src/views/layout.ejs
Original file line number Diff line number Diff line change
Expand Up @@ -4,30 +4,40 @@
<meta charset="UTF-8" />
<title><%= title || 'Sidequest Dashboard' %></title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="/public/css/styles.css" rel="stylesheet" />
<script src="/public/js/htmx.js"></script>
<script src="/public/js/feather-icons.js"></script>
<script src="/public/js/highlight.js"></script>
<script src="/public/js/chart.js"></script>
<script src="/public/js/scroll.js"></script>
<script src="/public/js/selection.js"></script>
<link href="<%= basePath %>/public/css/styles.css" rel="stylesheet" />
<script src="<%= basePath %>/public/js/htmx.js"></script>
<script src="<%= basePath %>/public/js/feather-icons.js"></script>
<script src="<%= basePath %>/public/js/highlight.js"></script>
<script src="<%= basePath %>/public/js/chart.js"></script>
<script src="<%= basePath %>/public/js/scroll.js"></script>
<script src="<%= basePath %>/public/js/selection.js"></script>
</head>
<body class="h-full bg-base-100 text-base-content">
<div class="flex h-full">
<!-- Sidebar -->
<aside class="w-64 bg-neutral border-r border-base-100 flex flex-col">
<div class="p-4 flex items-center gap-2 border-b border-base-100">
<img src="/public/img/logo.png" alt="Sidequest Logo" class="h-14 w-auto" />
<img src="<%= basePath %>/public/img/logo.png" alt="Sidequest Logo" class="h-14 w-auto" />
<span class="text-xl font-semibold text-white">Sidequest</span>
</div>
<nav class="flex-1 p-4 space-y-2 text-sm">
<a href="/" class="btn btn-ghost btn-sm justify-start w-full text-base-content hover:bg-secondary hover:text-secondary-content">Dashboard</a>
<a href="/jobs" class="btn btn-ghost btn-sm justify-start w-full text-base-content hover:bg-secondary hover:text-secondary-content">Jobs</a>
<a href="/queues" class="btn btn-ghost btn-sm justify-start w-full text-base-content hover:bg-secondary hover:text-secondary-content">Queues</a>
<a
href="<%= basePath %>/"
class="btn btn-ghost btn-sm justify-start w-full text-base-content hover:bg-secondary hover:text-secondary-content"
>Dashboard</a
>
<a
href="<%= basePath %>/jobs"
class="btn btn-ghost btn-sm justify-start w-full text-base-content hover:bg-secondary hover:text-secondary-content"
>Jobs</a
>
<a
href="<%= basePath %>/queues"
class="btn btn-ghost btn-sm justify-start w-full text-base-content hover:bg-secondary hover:text-secondary-content"
>Queues</a
>
</nav>
<div class="p-4 border-t border-base-100 text-xs text-neutral-content">
v0.1.0 — OSS Edition
</div>
<div class="p-4 border-t border-base-100 text-xs text-neutral-content">v0.1.0 — OSS Edition</div>
</aside>

<!-- Main content -->
Expand Down
5 changes: 4 additions & 1 deletion packages/dashboard/src/views/pages/index.ejs
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,7 @@
</div>
</div>

<script src="/public/js/dashboard.js"></script>
<script>
window.SIDEQUEST_BASE_PATH = "<%= basePath %>";
</script>
<script src="<%= basePath %>/public/js/dashboard.js"></script>
2 changes: 1 addition & 1 deletion packages/dashboard/src/views/pages/job.ejs
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
<%- include('../partials/job-view', { job }) %>

<script src="/public/js/job.js"></script>
<script src="<%= basePath %>/public/js/job.js"></script>
4 changes: 2 additions & 2 deletions packages/dashboard/src/views/pages/jobs.ejs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<section class="space-y-6">
<form method="GET" id="filter-form" hx-get="/jobs" hx-target="#jobs-table"
<form method="GET" id="filter-form" hx-get="<%= basePath %>/jobs" hx-target="#jobs-table"
hx-trigger="change from:* delay:300ms, every 3s, jobChanged" hx-push-url="true" hx-sync="this:replace">
<div class="flex flex-wrap items-end gap-4 text-base mb-4">
<div class="form-control w-48">
Expand Down Expand Up @@ -75,7 +75,7 @@
</form>
</section>

<script src="/public/js/jobs.js"></script>
<script src="<%= basePath %>/public/js/jobs.js"></script>

<script>
const startUtc = "<%= filters.start %>";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<div
id="dashboard-stats"
class="grid grid-cols-4 gap-4"
hx-get="/dashboard/stats"
hx-get="<%= basePath %>/dashboard/stats"
hx-swap="outerHTML"
hx-trigger="every 1s, refresh from:body"
hx-include="#graph-range"
Expand Down
8 changes: 4 additions & 4 deletions packages/dashboard/src/views/partials/job-view.ejs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<section class="space-y-6"
id="job-details"
hx-get="/jobs/<%= job.id %>"
hx-get="<%= basePath %>/jobs/<%= job.id %>"
hx-swap="outerHTML"
hx-trigger="every 3s, jobChanged"
>
Expand All @@ -20,13 +20,13 @@
</h1>
<div class="flex justify-end space-x-2">
<% if (!['canceled', 'failed', 'completed'].includes(job.state)){ %>
<button class="btn btn-sm btn-outline" hx-patch="/jobs/<%= job.id %>/cancel"><i data-feather="x" class="w-4 h-4"></i>Cancel</button>
<button class="btn btn-sm btn-outline" hx-patch="<%= basePath %>/jobs/<%= job.id %>/cancel"><i data-feather="x" class="w-4 h-4"></i>Cancel</button>
<% } %>
<% if (job.available_at > new Date()) { %>
<button type="button" class="btn btn-sm btn-outline" hx-patch="/jobs/<%= job.id %>/run"><i data-feather="play" class="w-4 h-4"></i>Run</button>
<button type="button" class="btn btn-sm btn-outline" hx-patch="<%= basePath %>/jobs/<%= job.id %>/run"><i data-feather="play" class="w-4 h-4"></i>Run</button>
<% } %>
<% if (['canceled', 'failed', 'completed'].includes(job.state)){ %>
<button class="btn btn-sm btn-outline" hx-patch="/jobs/<%= job.id %>/rerun"><i data-feather="refresh-ccw" class="w-4 h-4"></i>Re-Run</button>
<button class="btn btn-sm btn-outline" hx-patch="<%= basePath %>/jobs/<%= job.id %>/rerun"><i data-feather="refresh-ccw" class="w-4 h-4"></i>Re-Run</button>
<% } %>
</div>
</div>
Expand Down
12 changes: 6 additions & 6 deletions packages/dashboard/src/views/partials/jobs-table.ejs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
<tbody>
<% jobs.forEach(job => { %>
<tr class="hover:bg-base-300">
<td class="px-2 py-3 text-base-content link"><a href="/jobs/<%= job.id %>"><%= job.id %></a></td>
<td class="px-2 py-3 text-base-content link"><a href="<%= basePath %>/jobs/<%= job.id %>"><%= job.id %></a></td>
<td class="px-2 py-3 text-base-content"><%= job.class %></td>
<td class="px-2 py-3"><%= job.queue %></td>
<td class="px-2 py-3">
Expand All @@ -36,13 +36,13 @@
<td class="px-2 py-3 text-base-content"><%= job.claimed_by || '-' %></td>
<td class="px-2 py-3 flex flex-nowrap gap-2">
<% if (job.available_at > new Date()) { %>
<button type="button" class="btn btn-sm" hx-patch="/jobs/<%= job.id %>/run" hx-push-url="false" hx-target="this"><i data-feather="play" class="w-4 h-4"></i>Run</button>
<button type="button" class="btn btn-sm" hx-patch="<%= basePath %>/jobs/<%= job.id %>/run" hx-push-url="false" hx-target="this"><i data-feather="play" class="w-4 h-4"></i>Run</button>
<% } %>
<% if (!['canceled', 'failed', 'completed'].includes(job.state)){ %>
<button type="button" class="btn btn-sm" hx-patch="/jobs/<%= job.id %>/cancel" hx-push-url="false" hx-target="this"><i data-feather="x" class="w-4 h-4"></i>Cancel</button>
<button type="button" class="btn btn-sm" hx-patch="<%= basePath %>/jobs/<%= job.id %>/cancel" hx-push-url="false" hx-target="this"><i data-feather="x" class="w-4 h-4"></i>Cancel</button>
<% } %>
<% if (['canceled', 'failed', 'completed'].includes(job.state)){ %>
<button type="button" class="btn btn-sm" hx-patch="/jobs/<%= job.id %>/rerun" hx-push-url="false" hx-target="this"><i data-feather="refresh-ccw" class="w-4 h-4"></i>Rerun</button>
<button type="button" class="btn btn-sm" hx-patch="<%= basePath %>/jobs/<%= job.id %>/rerun" hx-push-url="false" hx-target="this"><i data-feather="refresh-ccw" class="w-4 h-4"></i>Rerun</button>
<% } %>
</td>
</tr>
Expand All @@ -52,7 +52,7 @@

<div class="join mt-4">
<% if (pagination.page > 1) { %>
<button type="button" class="join-item btn btn-sm" hx-get="/jobs" hx-target="#jobs-table" hx-include="#filter-form"
<button type="button" class="join-item btn btn-sm" hx-get="<%= basePath %>/jobs" hx-target="#jobs-table" hx-include="#filter-form"
hx-vals='{"page": <%= pagination.page - 1 %>}'>«</button>
<% } else { %>
<button class="join-item btn btn-sm btn-disabled">«</button>
Expand All @@ -63,7 +63,7 @@
</button>

<% if (pagination.hasNextPage) { %>
<button type="button" class="join-item btn btn-sm" hx-get="/jobs" hx-target="#jobs-table" hx-include="#filter-form"
<button type="button" class="join-item btn btn-sm" hx-get="<%= basePath %>/jobs" hx-target="#jobs-table" hx-include="#filter-form"
hx-vals='{"page": <%= pagination.page + 1 %>}'>»</button>
<% } else { %>
<button class="join-item btn btn-sm btn-disabled">»</button>
Expand Down
Loading
Loading