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
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@
- `:webserver`
- `:source`
- `:just-auth`
- `:agiladmin :webserver` now separates internal bind settings from public URL settings:
- `:host` and `:port` are Jetty bind values.
- `:base-host` and `:base-path` are browser-facing URL parts.
- `:upload-max-size` configures upload byte limits (default `500000`).
- Project configs are separate YAML files stored under the configured budgets path and loaded by `load-project`.
- Tests use fixture config under `test/assets/agiladmin.yaml`.

Expand Down Expand Up @@ -125,6 +129,7 @@
- HTMX is loaded locally from `resources/public/static/js/htmx.min.js` and is intended for progressive enhancement only; keep full-page fallback behavior working.
- Current HTMX seams follow the same pattern: the normal route remains authoritative and returns a full page, while `web/htmx-request?` switches selected actions to fragment responses. Existing examples are `POST /reload`, `POST /timesheets/upload`, and `POST /project`.
- `resources/public/static/js/app.js` replaces the old Bootstrap JS for navbar toggles and tab switching.
- Browser-facing app URLs should be generated via `agiladmin.webpage/path` / `asset-path` helpers rather than hard-coded `"/..."` strings; route definitions remain root paths and reverse proxies are expected to strip any configured public `base-path`.
- DHTMLX Gantt remains a JS island. Do not rewrite it into HTMX; only change the surrounding shell unless the task explicitly calls for deeper work.

## Useful Files
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,9 @@ agiladmin:
webserver:
host: localhost
port: 8000
base-host: ""
base-path: /
upload-max-size: 500000
anti-forgery: false
ssl-redirect: false

Expand All @@ -231,6 +234,9 @@ Notes:
- `budgets.ssh-key` is the private key path used for Git access; if it does not exist, Agiladmin generates a new keypair and exposes the public key in the `/config` page
- project names are discovered from `*.yaml` files in `budgets.path`, using the part of the filename before the first `.`
- `pocketbase` is optional only if you are using dev auth locally
- `webserver.upload-max-size` is in bytes and defaults to `500000`
- `webserver.base-path` is the browser-visible mount prefix; if you publish under a subpath such as `/agiladmin`, your reverse proxy must strip that prefix before forwarding to Jetty routes
- when TLS terminates at Caddy or another reverse proxy, keep `webserver.ssl-redirect: false` and let the proxy handle HTTP to HTTPS redirects

## Project Configuration

Expand Down
3 changes: 3 additions & 0 deletions doc/agiladmin.pocketbase.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ agiladmin:
ssl-redirect: false
port: 8000
host: localhost
base-host: ""
base-path: /
upload-max-size: 500000

budgets:
git: ssh://git@example.org/admin-budgets
Expand Down
3 changes: 3 additions & 0 deletions doc/agiladmin.pocketbase.yaml.in
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ agiladmin:
ssl-redirect: false
port: 8000
host: localhost
base-host: ""
base-path: /
upload-max-size: 500000

budgets:
git: ssh://git@example.org/admin-budgets
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"sync:htmx": "node ./scripts/sync-frontend-assets.mjs",
"build:frontend": "npm run sync:htmx && npm run build:css",
"test:e2e": "node ./scripts/e2e/run-playwright.mjs",
"test:e2e:base-path": "E2E_BASE_PATH=/agiladmin node ./scripts/e2e/run-playwright.mjs test/e2e/base-path.spec.js",
"test:e2e:headed": "node ./scripts/e2e/run-playwright.mjs --headed",
"test:e2e:debug": "node ./scripts/e2e/run-playwright.mjs --debug"
},
Expand Down
16 changes: 16 additions & 0 deletions packaging/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,22 @@ Agiladmin reads the budgets directory from the instance config file, not from `%

The top-level `make install` now renders the default instance config from a template, so the installed `agiladmin.yaml` uses the active `APP_HOME` and instance name instead of copying a static sample verbatim.

## Reverse Proxy Notes (Caddy)

Use `agiladmin.webserver.base-host` for the public origin and `agiladmin.webserver.base-path` for the public path prefix. Keep internal Jetty routes unchanged.

If Agiladmin is published at a subpath (for example `/agiladmin`), the proxy must strip that prefix before forwarding to Jetty. A minimal Caddy pattern is:

```caddyfile
example.org {
handle_path /agiladmin/* {
reverse_proxy 127.0.0.1:8000
}
}
```

When TLS terminates at Caddy, keep `agiladmin.webserver.ssl-redirect: false` in Agiladmin config and let Caddy handle HTTP to HTTPS redirects.

The PocketBase unit uses `User=@APP_NAME@` and `Group=@APP_NAME@`. Create that service account before enabling the unit, for example:

```sh
Expand Down
2 changes: 1 addition & 1 deletion playwright.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export default defineConfig({
testDir: "./test/e2e",
retries: process.env.CI ? 1 : 0,
use: {
baseURL: "http://127.0.0.1:18080",
baseURL: process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:18080",
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "retain-on-failure",
Expand Down
62 changes: 62 additions & 0 deletions resources/public/static/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -252,12 +252,74 @@
});
}

function initUploadProgress(root) {
root.querySelectorAll("form").forEach(function (form) {
if (form.dataset.uploadProgressBound === "true") {
return;
}

var progress = form.querySelector("[data-upload-progress]");
var label = form.querySelector("[data-upload-progress-label]");
if (!progress || !label) {
return;
}

function setProgress(percent) {
var value = Math.max(0, Math.min(100, Math.round(percent)));
progress.value = value;
label.textContent = value + "%";
}

function resetProgress() {
setProgress(0);
}

form.dataset.uploadProgressBound = "true";
resetProgress();

form.addEventListener("htmx:beforeRequest", function (event) {
if (event.target !== form) {
return;
}
resetProgress();
});

form.addEventListener("htmx:xhr:progress", function (event) {
if (event.target !== form) {
return;
}

var detail = event.detail || {};
var total = Number(detail.total || 0);
var loaded = Number(detail.loaded || 0);
if (total > 0) {
setProgress((loaded / total) * 100);
}
});

form.addEventListener("htmx:afterRequest", function (event) {
if (event.target !== form) {
return;
}

var successful = event.detail && event.detail.successful;
if (successful) {
setProgress(100);
window.setTimeout(resetProgress, 300);
} else {
resetProgress();
}
});
});
}

function boot(root) {
initTabGroups(root);
initNavToggles(root);
initTextFilters(root);
initThemeToggle(root);
initPageLoading(root);
initUploadProgress(root);
}

document.addEventListener("DOMContentLoaded", function () {
Expand Down
86 changes: 84 additions & 2 deletions scripts/e2e/run-playwright.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,26 @@
import { spawn } from "node:child_process";
import http from "node:http";
import { setTimeout as sleep } from "node:timers/promises";

const args = process.argv.slice(2);
const BACKEND_ORIGIN = "http://127.0.0.1:18080";
const PROXY_ORIGIN = "http://127.0.0.1:18081";
const E2E_BASE_PATH = normalizeBasePath(process.env.E2E_BASE_PATH ?? "/");
let server;
let proxyServer;

function normalizeBasePath(basePath) {
const raw = String(basePath ?? "").trim();
if (!raw || raw === "/") return "/";
const cleaned = raw.replace(/^\/+/, "").replace(/\/+$/, "");
return cleaned ? `/${cleaned}` : "/";
}

function withBasePath(pathname, basePath) {
if (basePath === "/") return pathname;
const route = pathname.startsWith("/") ? pathname : `/${pathname}`;
return `${basePath}${route}`;
}

async function waitForLogin(url, timeoutMs = 90000) {
const started = Date.now();
Expand All @@ -18,6 +36,52 @@ async function waitForLogin(url, timeoutMs = 90000) {
throw new Error(`Timed out waiting for ${url}`);
}

function startPrefixProxy(basePath) {
const prefix = basePath;
const server = http.createServer((req, res) => {
try {
const requestUrl = new URL(req.url ?? "/", PROXY_ORIGIN);
const pathOnly = requestUrl.pathname;
const hasPrefix = pathOnly === prefix || pathOnly.startsWith(`${prefix}/`);
const strippedPath = hasPrefix
? pathOnly === prefix
? "/"
: pathOnly.slice(prefix.length)
: pathOnly;
const targetPath = `${strippedPath}${requestUrl.search}`;
const proxyReq = http.request(
{
protocol: "http:",
hostname: "127.0.0.1",
port: 18080,
method: req.method,
path: targetPath,
headers: {
...req.headers,
host: "127.0.0.1:18080",
},
},
(proxyRes) => {
res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
proxyRes.pipe(res);
},
);
proxyReq.on("error", (err) => {
res.statusCode = 502;
res.end(`proxy error: ${err.message}`);
});
req.pipe(proxyReq);
} catch (err) {
res.statusCode = 500;
res.end(`proxy setup error: ${err.message}`);
}
});
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(18081, "127.0.0.1", () => resolve(server));
});
}

async function main() {
server = spawn("node", ["./scripts/e2e/start-agiladmin.mjs"], {
stdio: "inherit",
Expand All @@ -30,11 +94,20 @@ async function main() {
}
});

await waitForLogin("http://127.0.0.1:18080/login");
if (E2E_BASE_PATH !== "/") {
proxyServer = await startPrefixProxy(E2E_BASE_PATH);
}

const loginUrl = E2E_BASE_PATH === "/" ? `${BACKEND_ORIGIN}/login` : `${PROXY_ORIGIN}/login`;
await waitForLogin(loginUrl);
const baseURL = E2E_BASE_PATH === "/" ? BACKEND_ORIGIN : PROXY_ORIGIN;

const runner = spawn("npx", ["playwright", "test", ...args], {
stdio: "inherit",
env: process.env,
env: {
...process.env,
PLAYWRIGHT_BASE_URL: baseURL,
},
});

const testCode = await new Promise((resolve) => {
Expand All @@ -44,6 +117,9 @@ async function main() {
if (server && !server.killed) {
server.kill("SIGTERM");
}
if (proxyServer) {
await new Promise((resolve) => proxyServer.close(resolve));
}
process.exit(testCode);
}

Expand All @@ -52,6 +128,9 @@ for (const signal of ["SIGINT", "SIGTERM"]) {
if (server && !server.killed) {
server.kill("SIGTERM");
}
if (proxyServer) {
proxyServer.close();
}
process.exit(130);
});
}
Expand All @@ -61,5 +140,8 @@ main().catch((err) => {
if (server && !server.killed) {
server.kill("SIGTERM");
}
if (proxyServer) {
proxyServer.close();
}
process.exit(1);
});
13 changes: 13 additions & 0 deletions scripts/e2e/start-agiladmin.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ const STATE_PATH = path.join(STATE_DIR, "agiladmin-e2e-state.json");
const OUTPUT_DIR = path.join(REPO_ROOT, "output", "playwright");
const LOG_PATH = path.join(OUTPUT_DIR, "agiladmin-server.log");
const DEBUG_E2E = process.env.DEBUG_E2E === "1";
const E2E_BASE_PATH = normalizeBasePath(process.env.E2E_BASE_PATH ?? "/");

function normalizeBasePath(basePath) {
const raw = String(basePath ?? "").trim();
if (!raw || raw === "/") return "/";
const cleaned = raw.replace(/^\/+/, "").replace(/\/+$/, "");
return cleaned ? `/${cleaned}` : "/";
}

function yamlConfig(budgetsPath, sshKeyPath) {
return [
Expand All @@ -28,7 +36,11 @@ function yamlConfig(budgetsPath, sshKeyPath) {
" webserver:",
" host: 127.0.0.1",
" port: 18080",
" base-host: \"\"",
` base-path: ${E2E_BASE_PATH}`,
" upload-max-size: 500000",
" anti-forgery: false",
" ssl-redirect: false",
].join("\n");
}

Expand Down Expand Up @@ -105,6 +117,7 @@ async function prepareEnv() {
manager: managerFixturePath,
guest: guestFixturePath,
},
basePath: E2E_BASE_PATH,
logPath: LOG_PATH,
};
await fs.writeFile(STATE_PATH, JSON.stringify(state, null, 2), "utf8");
Expand Down
14 changes: 13 additions & 1 deletion src/agiladmin/config.clj
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@
(s/optional-key :projects) [s/Str]
(s/optional-key :webserver) {(s/optional-key :port) s/Num
(s/optional-key :host) s/Str
(s/optional-key :base-host) s/Str
(s/optional-key :base-path) s/Str
(s/optional-key :upload-max-size) s/Num
(s/optional-key :anti-forgery) s/Bool
(s/optional-key :ssl-redirect) s/Bool}
(s/optional-key :source) {:git s/Str
Expand Down Expand Up @@ -82,7 +85,10 @@
:ssh-key "id_rsa"
:path "budgets/"}
:webserver
{:anti-forgery false
{:base-host ""
:base-path "/"
:upload-max-size 500000
:anti-forgery false
:ssl-redirect false}})

(def project-defaults {})
Expand Down Expand Up @@ -273,6 +279,12 @@
(defn load-config [name default]
(log/info (str "Loading configuration: " name))
(let [conf (config-read name default)
conf (if (f/failed? conf)
conf
(let [app-key (keyword (:appname conf))]
(update-in conf
[app-key :webserver]
#(merge (:webserver default-settings) %))))
loaded-paths (->> (:paths conf)
(filter #(.exists (io/as-file %)))
vec)
Expand Down
Loading