panel: Forgejo integration, ssh deploy key and per-app environment variables

Forgejo:
- panel.nix passes the local Forgejo's public, API and ssh URLs (derived
  from forgejo.nix) to panel-api.
- Settings dialog: connect a Forgejo access token (verified against
  /api/v1/user, stored 0600 in state/panel/forgejo-token).
- New-app dialog gets a Forgejo repository picker with search and a branch
  dropdown; private repos are cloned over https with the stored token, or
  over ssh with the deploy key when no token is connected. The app name and
  domain are filled in from the repository name.
- Commit and compare links in the Source tab point at Forgejo; cards show
  the provider ("Forgejo · main").

Git over ssh:
- ssh:// and git@host:owner/repo URLs are accepted; the panel generates an
  ed25519 deploy key in state/panel/ssh and uses it for clone/fetch
  (BatchMode, accept-new host keys). openssh added to the service path.
- Credential redaction only applies to http(s) URLs, so ssh usernames are
  kept; git errors now report the meaningful line instead of git's advice.

Environment variables:
- Stored per app in state/env/<app>.env (0600), outside the repo and stack.
- panelctl passes them to every compose command via env(1), so ${VAR}
  interpolation works; by default deploy/restart also generate a compose
  override listing the keys under every service's environment (values are
  read from compose's environment, never quoted into YAML).
- Environment tab (and a section in the new-app dialog) with .env paste
  import, hidden values, validation of names (reserved podman/compose vars
  rejected), hints for ${VAR}s the compose file uses but aren't set, and
  Save / Save & deploy. Removing an app deletes its variables.

The API still accepts the old source_type "github" / github_* fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UbWSNkXxZhYf7eqHTyx3Bf
This commit is contained in:
agent 2026-09-26 22:52:09 +00:00
parent 2d3b30d078
commit db46c5e793
5 changed files with 1258 additions and 100 deletions

View file

@ -450,6 +450,45 @@
}
.segmented button.active { background: var(--surface); color: var(--text); box-shadow: var(--shadow); }
/* ── Repo picker ── */
.picker-list {
margin-top: 6px; max-height: 232px; overflow: auto;
border: 1px solid var(--border); border-radius: 8px;
}
.picker-item {
display: flex; align-items: center; gap: 10px; width: 100%;
background: none; border: 0; border-top: 1px solid var(--border);
padding: 8px 10px; text-align: left; cursor: pointer; color: var(--text); font: inherit;
}
.picker-item:first-child { border-top: 0; }
.picker-item:hover, .picker-item:focus-visible { background: var(--surface-2); outline: none; }
.picker-item.selected { background: var(--accent-soft); }
.picker-item .icon { color: var(--muted); }
.picker-name { font-weight: 600; font-size: 13.5px; }
.picker-desc { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.picker-main { flex: 1; min-width: 0; }
/* ── Settings ── */
.settings-section + .settings-section { margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--border); }
.settings-section h3 { font-size: 14px; margin-bottom: 4px; }
.keybox {
margin-top: 10px; display: flex; gap: 8px; align-items: flex-start;
background: var(--surface-2); border-radius: 8px; padding: 10px 12px;
font: 12px/1.5 var(--mono); word-break: break-all;
}
.keybox code { flex: 1; background: none; padding: 0; }
/* ── Environment editor ── */
.env-row { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 3fr) 28px; gap: 6px; align-items: center; margin-bottom: 6px; }
.env-row .input { height: 32px; padding: 5px 9px; font: 13px var(--mono); }
.env-head { font-size: 11.5px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: .04em; margin-bottom: 4px; }
.env-details summary { cursor: pointer; list-style: none; }
.env-details summary::-webkit-details-marker { display: none; }
.env-details summary::before { content: "▸"; display: inline-block; width: 14px; color: var(--muted); transition: transform .15s; }
.env-details[open] summary::before { transform: rotate(90deg); }
.missing-vars { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.chip.sm { height: 24px; padding: 0 9px; font: 12px var(--mono); }
/* ── Responsive ── */
@media (max-width: 760px) {
.hide-sm { display: none !important; }
@ -467,6 +506,10 @@
.route-row .arrow { display: none; }
.route-row .route-path { grid-column: 1 / 3; }
.route-head { display: none; }
.env-row { grid-template-columns: minmax(0, 1fr) 28px; }
.env-row > :nth-child(2) { grid-column: 1; }
.env-row > :nth-child(3) { grid-column: 2; grid-row: 1; }
.env-head { display: none; }
.file-row { grid-template-columns: 20px minmax(0, 1fr) auto; }
.file-row > :nth-child(3), .file-row > :nth-child(4) { display: none; }
.logs { height: 320px; }
@ -485,6 +528,7 @@
</label>
<div class="top-actions">
<button id="syncBtn" class="sync" type="button"><span class="sync-dot"></span><span id="syncText">Connecting…</span></button>
<button id="settingsBtn" class="btn ghost icon-only" type="button" title="Settings — Forgejo and deploy key" aria-label="Settings"></button>
<button id="activityBtn" class="btn ghost" type="button" title="Activity — operations and their output">
<span id="activityIcon"></span><span class="hide-sm">Activity</span><span id="activityCount" class="count" hidden></span>
</button>
@ -538,7 +582,7 @@
<div class="segmented" id="naSource">
<button type="button" data-v="default">Starter</button>
<button type="button" data-v="raw">Compose file</button>
<button type="button" data-v="github">Git repository</button>
<button type="button" data-v="git">Git repository</button>
</div>
<p class="hint" id="naSourceHint"></p>
</div>
@ -549,24 +593,56 @@
</div>
<div id="naGit" hidden>
<div class="field">
<label for="naUrl">Repository URL</label>
<input id="naUrl" class="input" placeholder="https://git.example.com/user/repo" autocomplete="off" spellcheck="false" />
</div>
<div class="grid-2 tight">
<div class="field">
<label for="naBranch">Branch</label>
<input id="naBranch" class="input" placeholder="default branch" autocomplete="off" spellcheck="false" />
</div>
<div class="field">
<label for="naPat">Access token <span class="muted">(private repos)</span></label>
<input id="naPat" class="input" type="password" autocomplete="new-password" />
<div class="field" id="naProviderField">
<div class="segmented" id="naProvider">
<button type="button" data-v="forgejo" id="naProviderForgejo">Forgejo</button>
<button type="button" data-v="url">Any git URL</button>
</div>
</div>
<p class="hint" style="margin:-8px 0 16px">
Works with GitHub, Forgejo/Gitea and other https hosts. Needs a <code>compose.yaml</code> at the repository root.
A token is kept in the clone's git config, so use a read-only one.
</p>
<div id="naForgejo">
<div class="field">
<label for="naRepoSearch">Repository</label>
<div class="picker">
<input id="naRepoSearch" class="input" placeholder="Search repositories…" autocomplete="off" spellcheck="false" />
<div class="picker-list" id="naRepoList" role="listbox"></div>
</div>
<p class="hint" id="naForgejoHint"></p>
</div>
<div class="grid-2 tight">
<div class="field">
<label for="naForgejoBranch">Branch</label>
<select id="naForgejoBranch" class="select" style="width:100%" disabled><option value="">Pick a repository first</option></select>
</div>
<div class="field">
<span class="label">Clone with</span>
<p class="muted small" id="naCloneVia" style="padding-top:7px">—</p>
</div>
</div>
</div>
<div id="naUrlMode" hidden>
<div class="field">
<label for="naUrl">Repository URL</label>
<input id="naUrl" class="input" placeholder="https://github.com/user/repo or ssh://git@host:port/user/repo.git" autocomplete="off" spellcheck="false" />
</div>
<div class="grid-2 tight">
<div class="field">
<label for="naBranch">Branch</label>
<input id="naBranch" class="input" placeholder="default branch" autocomplete="off" spellcheck="false" />
</div>
<div class="field" id="naPatField">
<label for="naPat">Access token <span class="muted">(private https repos)</span></label>
<input id="naPat" class="input" type="password" autocomplete="new-password" />
</div>
</div>
<p class="hint" style="margin:-8px 0 12px">
https, <code>ssh://</code> and <code>git@host:owner/repo</code> URLs all work. A token is kept in the clone's git config, so use a read-only one.
</p>
</div>
<div class="notice" id="naDeployKey" hidden></div>
<p class="hint" style="margin:0 0 16px">The repository needs a <code>compose.yaml</code> (or <code>docker-compose.yml</code>) at its root.</p>
</div>
<div class="field">
@ -576,6 +652,11 @@
<p class="hint">Domain → where Caddy forwards requests (the port the container publishes on 127.0.0.1). Path is optional, e.g. <code>/api/*</code>. Wildcard domains need a DNS challenge.</p>
</div>
<details class="field env-details" id="naEnvDetails">
<summary><span class="label" style="display:inline">Environment variables</span> <span class="muted small" id="naEnvCount">optional</span></summary>
<div id="naEnv" style="margin-top:8px"></div>
</details>
<label class="check"><input type="checkbox" id="naAuth" checked /><span><strong>Require login</strong><br><span class="muted small">Put the app behind Authelia.</span></span></label>
<label class="check"><input type="checkbox" id="naDeploy" checked /><span><strong>Deploy right away</strong><br><span class="muted small">Start the containers as soon as the app is created.</span></span></label>
@ -588,6 +669,36 @@
</form>
</dialog>
<dialog id="settingsDlg">
<form id="settingsForm" novalidate>
<header class="dialog-head">
<h2>Settings</h2>
<button type="button" class="btn ghost icon-only" data-close aria-label="Close"></button>
</header>
<div class="dialog-body">
<section class="settings-section" id="setForgejo">
<h3>Forgejo</h3>
<p class="muted small" id="setForgejoStatus">Loading…</p>
<div class="field" id="setTokenField" style="margin-top:10px">
<label for="setToken">Access token</label>
<div style="display:flex;gap:8px">
<input id="setToken" class="input" type="password" autocomplete="new-password" placeholder="Paste a Forgejo access token" />
<button type="submit" class="btn primary" id="setTokenSave">Connect</button>
</div>
<p class="hint">Create one in Forgejo under <em>Settings → Applications</em> with <strong>read</strong> access to repositories (and your user).
It lets the panel list your repositories and clone private ones over https. Stored in the panel's state directory, readable only by the service.</p>
</div>
<button type="button" class="btn ghost danger sm" id="setTokenRemove" hidden>Disconnect</button>
</section>
<section class="settings-section">
<h3>SSH deploy key</h3>
<p class="muted small">Used for repositories cloned over SSH (e.g. <code id="setSshExample">ssh://git@host/owner/repo.git</code>). Add it as a <strong>read-only deploy key</strong> in the repository's settings, or to your Forgejo account to give the panel access to all your repositories.</p>
<div id="setSshKey" class="muted small">Loading…</div>
</section>
</div>
</form>
</dialog>
<dialog id="confirmDlg" class="sm">
<form method="dialog">
<h2 id="cfTitle"></h2>
@ -658,6 +769,10 @@ const ICONS = {
save: '<path d="M5 3h11l3 3v13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/><path d="M8 3v5h7"/><path d="M8 21v-7h8v7"/>',
logs: '<path d="M4 6h16M4 12h16M4 18h10"/>',
layers: '<path d="M12 3l9 5-9 5-9-5z"/><path d="M3 13l9 5 9-5"/>',
settings: '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/>',
key: '<circle cx="7.5" cy="15.5" r="4.5"/><path d="M10.7 12.3L21 2"/><path d="M16 7l3 3"/><path d="M19 4l2 2"/>',
external: '<path d="M14 4h6v6"/><path d="M20 4l-9 9"/><path d="M19 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h5"/>',
braces: '<path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5a2 2 0 0 0 2 2h1"/><path d="M16 21h1a2 2 0 0 0 2-2v-5a2 2 0 0 1 2-2 2 2 0 0 1-2-2V5a2 2 0 0 0-2-2h-1"/>',
};
function icon(name, cls = "") {
@ -780,8 +895,44 @@ const api = {
deleteFile: (n, path, vol) => api.request(api.fileUrl(n, path, vol), { method: "DELETE" }),
upload: (n, path, vol, file) => api.request(api.fileUrl(n, path, vol), { method: "PUT", body: file }),
init: (payload) => api.post("/apps/init", payload),
getEnv: (n) => api.request(`/apps/${enc(n)}/env`),
saveEnv: (n, vars, inject) => api.post(`/apps/${enc(n)}/env`, { vars, inject }),
integrations: () => api.request("/integrations"),
saveForgejoToken: (token) => api.post("/integrations/forgejo", { token }),
forgejoRepos: (q) => api.request(`/forgejo/repos?q=${enc(q)}`),
forgejoBranches: (repo) => api.request(`/forgejo/branches?repo=${enc(repo)}`),
};
// Forgejo connection + deploy key, loaded on demand and cached.
const integrations = {
data: null,
pending: null,
load(force = false) {
if (this.data && !force) return Promise.resolve(this.data);
if (!this.pending) {
this.pending = api.integrations()
.then((d) => (this.data = d))
.finally(() => { this.pending = null; });
}
return this.pending;
},
};
const PROVIDER_NAMES = { forgejo: "Forgejo", github: "GitHub", git: "Git" };
function copyText(text, what = "Copied") {
navigator.clipboard?.writeText(text)
.then(() => toast("success", what), () => toast("error", "Couldn't copy to the clipboard"));
}
function keyBox(publicKey) {
return h("div", { class: "keybox" }, h("code", null, publicKey),
h("button", { type: "button", class: "btn ghost sm icon-only", title: "Copy public key", "aria-label": "Copy public key", onclick: () => copyText(publicKey, "Public key copied") }, icon("copy")));
}
const isGitUrl = (u) => /^(https?:\/\/\S+|ssh:\/\/\S+|[\w.-]+@[\w.-]+:\S+)$/.test(u);
const isSshUrl = (u) => !!u && !/^https?:\/\//.test(u);
const session = {
expired: false,
expire() {
@ -811,7 +962,7 @@ const cards = new Map(); // name -> AppCard
const BUSY_LABELS = {
init: "Creating…", deploy: "Deploying…", restart: "Restarting…", stop: "Stopping…",
"repo-pull": "Syncing…", backup: "Backing up…", restore: "Restoring…", remove: "Removing…",
compose: "Saving…", routes: "Updating routes…", "volume-clear": "Clearing…", "render-route": "Rendering…",
compose: "Saving…", routes: "Updating routes…", env: "Saving variables…", "volume-clear": "Clearing…", "render-route": "Rendering…",
};
const busyLabel = (b) => BUSY_LABELS[b] || "Working…";
const busyOf = (app) => state.localBusy.get(app.name) || app.busy || null;
@ -1049,7 +1200,7 @@ window.addEventListener("hashchange", applyHash);
// ─── App card ──────────────────────────────────────────────────────────────
const TABS = [
["overview", "Overview"], ["compose", "Compose"], ["logs", "Logs"], ["routes", "Routes"],
["overview", "Overview"], ["compose", "Compose"], ["env", "Environment"], ["logs", "Logs"], ["routes", "Routes"],
["files", "Files"], ["backups", "Backups"], ["source", "Source"],
];
@ -1094,13 +1245,15 @@ class AppCard {
this.pill.className = `pill pill-${st.key}`;
fill(this.pill, st.key === "busy" ? h("span", { class: "spinner" }) : h("span", { class: "dot" }), st.label);
}
const subSig = JSON.stringify([app.routes, app.auth, app.repo_url, app.repo_branch]);
const subSig = JSON.stringify([app.routes, app.auth, app.repo_url, app.repo_branch, app.env_count]);
if (subSig !== this.subSig) {
this.subSig = subSig;
fill(this.sub,
const provider = app.repo_provider === "forgejo" || app.repo_provider === "github" ? `${PROVIDER_NAMES[app.repo_provider]} · ` : "";
fill(this.sub,
...domainLinks(app.routes, 2),
app.auth ? h("span", { class: "badge", title: "Visitors must log in through Authelia" }, icon("lock"), "Protected") : null,
app.repo_url ? h("span", { class: "badge", title: stripUrl(app.repo_url) }, icon("git"), app.repo_branch || "git") : null);
app.repo_url ? h("span", { class: "badge", title: stripUrl(app.repo_web_url || app.repo_url) }, icon("git"), `${provider}${app.repo_branch || "default"}`) : null,
app.env_count ? h("span", { class: "badge hide-sm", title: "Environment variables" }, icon("braces"), `${app.env_count} var${app.env_count === 1 ? "" : "s"}`) : null);
}
this.deployBtn.disabled = st.key === "busy";
if (this.panels) {
@ -1113,6 +1266,7 @@ class AppCard {
this.panels = {
overview: new OverviewPanel(this, "overview"),
compose: new ComposePanel(this, "compose"),
env: new EnvPanel(this, "env"),
logs: new LogsPanel(this, "logs"),
routes: new RoutesPanel(this, "routes"),
files: new FilesPanel(this, "files"),
@ -1178,8 +1332,7 @@ class AppCard {
destroy() {
this.collapse();
this.root.remove();
state.dirty.delete(`${this.name}:compose`);
state.dirty.delete(`${this.name}:routes`);
for (const key of ["compose", "routes", "env"]) state.dirty.delete(`${this.name}:${key}`);
}
}
@ -1200,7 +1353,7 @@ class OverviewPanel extends Panel {
update(app) {
const s = app.status || {};
const containers = s.containers || [];
const sig = JSON.stringify([containers, app.routes, app.auth, app.repo_url, app.repo_branch, app.compose_file, s.state]);
const sig = JSON.stringify([containers, app.routes, app.auth, app.repo_url, app.repo_branch, app.compose_file, s.state, app.env_count, app.env_inject]);
if (sig === this.sig) return;
this.sig = sig;
@ -1224,7 +1377,15 @@ class OverviewPanel extends Panel {
r.path ? h("span", { class: "mono muted" }, r.path) : null))),
h("dl", { class: "facts" },
h("dt", null, "Access"), h("dd", null, app.auth ? "Login required (Authelia)" : "Public"),
h("dt", null, "Source"), h("dd", null, app.repo_url ? `${stripUrl(app.repo_url)} @ ${app.repo_branch || "default"}` : "Compose file"),
h("dt", null, "Source"), h("dd", null, app.repo_url
? [`${PROVIDER_NAMES[app.repo_provider] || "Git"}: `,
app.repo_web_url ? h("a", { href: app.repo_web_url, target: "_blank", rel: "noopener" }, stripUrl(app.repo_web_url)) : stripUrl(app.repo_url),
` @ ${app.repo_branch || "default"}`]
: "Compose file"),
h("dt", null, "Environment"), h("dd", null,
h("button", { type: "button", class: "link", onclick: () => this.card.showTab("env") },
app.env_count ? `${app.env_count} variable${app.env_count === 1 ? "" : "s"}` : "None set"),
app.env_count && !app.env_inject ? h("span", { class: "muted" }, " (compose file only)") : null),
h("dt", null, "Compose"), h("dd", { class: "mono small" }, app.compose_file || "—")));
fill(this.el, h("div", { class: "grid-2" }, containerBox, infoBox));
@ -1504,6 +1665,239 @@ class RoutesPanel extends Panel {
}
}
// ── Environment variables ──
const RESERVED_ENV = new Set(["PATH", "HOME", "USER", "LOGNAME", "SHELL", "TMPDIR", "PWD", "OLDPWD", "IFS", "TERM"]);
const RESERVED_ENV_PREFIXES = ["XDG_", "DBUS_", "DOCKER_", "CONTAINER_", "CONTAINERS_", "COMPOSE_", "PODMAN_", "BUILDAH_", "LD_", "BASH_"];
function envKeyError(key) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return `"${key || "(empty)"}" isn't a valid variable name (letters, digits and _, not starting with a digit).`;
if (RESERVED_ENV.has(key) || RESERVED_ENV_PREFIXES.some((p) => key.startsWith(p))) return `${key} is reserved — it would change how podman/compose run.`;
return null;
}
// Parse KEY=value lines as found in .env files (comments, `export`, quotes).
function parseDotenv(text) {
const out = [];
for (let line of String(text || "").split(/\r?\n/)) {
line = line.trim();
if (!line || line.startsWith("#")) continue;
line = line.replace(/^export\s+/, "");
const i = line.indexOf("=");
if (i < 1) continue;
const key = line.slice(0, i).trim();
let value = line.slice(i + 1).trim();
const q = value[0];
if ((q === '"' || q === "'") && value.length >= 2 && value.endsWith(q)) {
value = value.slice(1, -1);
if (q === '"') value = value.replace(/\\(["\\$])/g, "$1");
} else {
const comment = value.search(/\s#/);
if (comment >= 0) value = value.slice(0, comment).trim();
}
out.push({ key, value });
}
return out;
}
// Variables a compose file references: name -> true when every use has a default (${X:-y}).
function composeVarRefs(text) {
const refs = new Map();
const src = String(text || "")
.split("\n").map((l) => l.replace(/(^|\s)#.*$/, "")).join("\n")
.replace(/\$\$/g, "");
for (const m of src.matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)(:?[-?+])?[^}]*\}|\$([A-Za-z_][A-Za-z0-9_]*)/g)) {
const name = m[1] || m[3];
const hasDefault = !!m[2] && m[2].endsWith("-");
refs.set(name, (refs.get(name) ?? true) && hasDefault);
}
return refs;
}
function envEditor({ onChange = () => {} } = {}) {
let reveal = false;
const rows = h("div");
const revealToggle = h("input", {
type: "checkbox",
onchange: () => {
reveal = revealToggle.checked;
for (const r of rows.children) r._inputs.v.type = reveal ? "text" : "password";
},
});
const pasteArea = h("textarea", { class: "code", rows: "6", spellcheck: "false", style: "min-height:120px", placeholder: "KEY=value\n# comments, export and quotes are fine\nDATABASE_URL=postgres://…" });
const pasteBox = h("div", { hidden: true, style: "margin:8px 0" }, pasteArea,
h("div", { class: "row-actions" },
btn("Import", "check", () => { importText(pasteArea.value); pasteArea.value = ""; pasteBox.hidden = true; }, "primary"),
btn("Cancel", null, () => { pasteBox.hidden = true; }, "ghost")));
const empty = h("p", { class: "muted small", style: "margin:4px 0 8px" }, "No variables yet.");
const el = h("div", null,
h("div", { class: "env-row env-head" }, h("span", null, "Name"), h("span", null, "Value"), h("span")),
empty,
rows,
h("div", { class: "row-actions" },
btn("Add variable", "plus", () => { addRow({}, true); changed(); }, "ghost"),
btn("Paste .env", "file", () => { pasteBox.hidden = false; pasteArea.focus(); }, "ghost"),
h("label", { class: "toggle" }, revealToggle, "Show values")),
pasteBox);
function changed() {
for (const r of rows.children) { r._inputs.k.classList.remove("invalid"); r._inputs.v.classList.remove("invalid"); }
empty.hidden = rows.children.length > 0;
onChange();
}
function addRow(v = {}, focus = false) {
const k = h("input", {
class: "input", placeholder: "NAME", value: v.key || "", spellcheck: "false", autocomplete: "off", "aria-label": "Variable name",
oninput: (e) => {
const cleaned = e.target.value.replace(/[^A-Za-z0-9_]/g, "_");
if (cleaned !== e.target.value) e.target.value = cleaned;
changed();
},
});
const val = h("input", {
class: "input", type: reveal ? "text" : "password", placeholder: "value", value: v.value || "",
spellcheck: "false", autocomplete: "new-password", "aria-label": "Value", oninput: changed,
onpaste: (e) => {
// Pasting several KEY=value lines into a value field imports them all.
const text = e.clipboardData?.getData("text") || "";
if (text.includes("\n") && parseDotenv(text).length > 1) { e.preventDefault(); row.remove(); importText(text); }
},
});
const row = h("div", { class: "env-row" }, k, val,
h("button", { type: "button", class: "btn ghost sm icon-only", title: "Remove", "aria-label": "Remove variable", onclick: () => { row.remove(); changed(); } }, icon("x")));
row._inputs = { k, v: val };
rows.append(row);
empty.hidden = true;
if (focus) k.focus();
return row;
}
function importText(text) {
const parsed = parseDotenv(text);
for (const { key, value } of parsed) {
const existing = [...rows.children].find((r) => r._inputs.k.value === key);
if (existing) existing._inputs.v.value = value; else addRow({ key, value });
}
for (const r of [...rows.children]) if (!r._inputs.k.value && !r._inputs.v.value) r.remove();
changed();
toast(parsed.length ? "success" : "info", parsed.length ? `Imported ${parsed.length} variable${parsed.length === 1 ? "" : "s"}` : "No KEY=value lines found");
}
return {
el,
set(vars) { fill(rows); vars.forEach((v) => addRow(v)); empty.hidden = vars.length > 0; },
get() {
return [...rows.children]
.map((r) => ({ key: r._inputs.k.value.trim(), value: r._inputs.v.value }))
.filter((v) => v.key || v.value);
},
keys() { return new Set(this.get().map((v) => v.key)); },
add(key) { const r = addRow({ key }); r._inputs.v.focus(); changed(); },
validate() {
const seen = new Set();
for (const r of rows.children) {
const { k, v } = r._inputs;
const key = k.value.trim();
if (!key && !v.value) continue;
const err = envKeyError(key) || (seen.has(key) ? `${key} is set more than once.` : null);
if (err) { k.classList.add("invalid"); k.focus(); return err; }
seen.add(key);
}
return null;
},
};
}
class EnvPanel extends Panel {
constructor(card, key) {
super(card, key);
this.loaded = false;
this.original = "";
this.dirty = false;
this.refs = null;
this.editor = envEditor({ onChange: () => this.onChange() });
this.inject = h("input", { type: "checkbox", checked: true, onchange: () => this.onChange() });
this.missing = h("div", { class: "notice warn", hidden: true });
this.err = h("p", { class: "form-error", hidden: true });
this.dirtyLabel = h("span", { class: "dirty-label", hidden: true }, "Unsaved changes");
this.el.append(
h("p", { class: "muted small", style: "margin-bottom:10px" },
"Available as ", h("code", null, "${NAME}"), " in the compose file and applied on the next deploy. ",
"Stored on the server outside the repository, readable only by the panel."),
this.missing,
this.editor.el,
h("label", { class: "check" }, this.inject,
h("span", null, h("strong", null, "Pass to every container"), h("br"),
h("span", { class: "muted small" }, "Otherwise they are only used to fill in ${…} references in the compose file."))),
this.err,
h("div", { class: "row-actions" },
btn("Save & deploy", "play", () => this.save(true), "primary"),
btn("Save", "save", () => this.save(false)),
btn("Revert", null, () => this.revert(), "ghost"),
h("span", { class: "spacer" }),
this.dirtyLabel));
}
show() {
if (!this.loaded) this.load();
this.checkMissing();
}
sig() { return JSON.stringify([this.editor.get(), this.inject.checked]); }
async load() {
try {
const d = await api.getEnv(this.name);
this.editor.set(d.vars || []);
this.inject.checked = d.inject !== false;
this.loaded = true;
this.original = this.sig();
this.onChange();
} catch (e) {
this.err.textContent = `Couldn't load variables: ${e.message}`;
this.err.hidden = false;
}
}
onChange() {
this.dirty = this.loaded && this.sig() !== this.original;
this.dirtyLabel.hidden = !this.dirty;
this.err.hidden = true;
this.card.setDirty("env", this.dirty);
this.renderMissing();
}
async checkMissing() {
try {
this.refs = composeVarRefs((await api.getCompose(this.name)).content);
this.renderMissing();
} catch {
// Hints only.
}
}
renderMissing() {
if (!this.refs || !this.loaded) return;
const keys = this.editor.keys();
const missing = [...this.refs].filter(([k, hasDefault]) => !hasDefault && !keys.has(k)).map(([k]) => k);
this.missing.hidden = !missing.length;
if (missing.length) {
fill(this.missing, icon("alert"), h("div", { class: "missing-vars" },
h("span", null, "The compose file uses variables that aren't set:"),
missing.map((k) => h("button", { type: "button", class: "chip sm", title: `Add ${k}`, onclick: () => this.editor.add(k) }, `+ ${k}`))));
}
}
async revert() {
this.loaded = false;
await this.load();
}
async save(deploy) {
const error = this.editor.validate();
if (error) { this.err.textContent = error; this.err.hidden = false; return; }
const vars = this.editor.get();
const res = await runOp(this.name, "env", `Save variables · ${this.name}`,
() => api.saveEnv(this.name, vars, this.inject.checked),
{ success: deploy ? null : "Variables saved", successDetail: () => "They apply on the next deploy." });
if (!res) return;
this.original = this.sig();
this.onChange();
if (deploy) actions.deploy(this.name);
}
}
// ── Files ──
const hasFiles = (e) => [...(e.dataTransfer?.types || [])].includes("Files");
@ -1696,9 +2090,11 @@ class BackupsPanel extends Panel {
// ── Git source ──
function commitView(c) {
function commitView(c, webUrl = "") {
const sha = h("code", null, c.short);
return h("span", { class: "commit" },
h("code", null, c.short), h("span", null, c.subject),
webUrl ? h("a", { href: `${webUrl}/commit/${c.sha}`, target: "_blank", rel: "noopener", title: "Open commit" }, sha) : sha,
h("span", null, c.subject),
h("span", { class: "muted small" }, ` — ${c.author}${c.time ? `, ${ago(c.time * 1000)}` : ""}`));
}
@ -1729,14 +2125,22 @@ class SourcePanel extends Panel {
}
}
render(d, fetched) {
const url = /^https?:\/\//.test(d.url) ? d.url : null;
fill(this.info,
const web = d.web_url || "";
const provider = PROVIDER_NAMES[d.provider] || "Git";
const authFailed = /permission denied|publickey|authentication failed|could not read username|403|401/i.test(d.fetch_error || "");
fill(this.info,
h("dl", { class: "facts" },
h("dt", null, "Repository"), h("dd", null, url ? h("a", { href: url, target: "_blank", rel: "noopener" }, stripUrl(url)) : d.url),
h("dt", null, "Repository"), h("dd", null,
web ? h("a", { href: web, target: "_blank", rel: "noopener" }, stripUrl(web)) : d.url,
h("span", { class: "muted" }, ` · ${provider}${d.ssh ? " over SSH" : ""}`)),
h("dt", null, "Branch"), h("dd", { class: "mono" }, d.branch || "default"),
h("dt", null, "Deployed commit"), h("dd", null, d.commit ? commitView(d.commit) : h("span", { class: "muted" }, "Not cloned yet — sync to clone it."))),
h("dt", null, "Deployed commit"), h("dd", null, d.commit ? commitView(d.commit, web) : h("span", { class: "muted" }, "Not cloned yet — sync to clone it."))),
d.dirty ? h("div", { class: "notice warn", style: "margin-top:12px" }, icon("alert"),
h("span", null, "The checkout has local changes (for example compose edits made here). They will be discarded on the next sync.")) : null);
h("span", null, "The checkout has local changes (for example compose edits made here). They will be discarded on the next sync.")) : null,
d.ssh && d.public_key ? h("details", { class: "env-details", style: "margin-top:12px", open: authFailed },
h("summary", null, h("span", { class: "label", style: "display:inline" }, "Deploy key"),
h("span", { class: "muted small" }, " — add it to the repository if syncing fails with “permission denied”")),
keyBox(d.public_key)) : null);
if (!fetched) return;
this.check.hidden = false;
if (d.fetch_error) {
@ -1745,9 +2149,10 @@ class SourcePanel extends Panel {
fill(this.check, icon("check"), "Up to date with ", h("code", null, `origin/${d.branch}`), ".");
} else {
const n = d.behind;
fill(this.check,
fill(this.check,
h("strong", null, n == null ? "New commits available" : `${n} new commit${n === 1 ? "" : "s"}`),
d.remote ? h("span", null, " — latest: ", commitView(d.remote)) : null);
d.remote ? h("span", null, " — latest: ", commitView(d.remote, web)) : null,
web && d.commit && d.remote ? h("a", { href: `${web}/compare/${d.commit.sha}...${d.remote.sha}`, target: "_blank", rel: "noopener" }, icon("external"), " Compare") : null);
}
}
}
@ -2041,23 +2446,35 @@ const composeTemplate = (port) => `services:
const SOURCE_HINTS = {
default: "Starts a tiny traefik/whoami container so you can check the route works. Edit the compose file afterwards.",
raw: "Paste a compose file. Publish the app's port on 127.0.0.1 so Caddy can reach it.",
github: "Clones a repository and deploys its compose file. Use “Sync” later to pull new commits and redeploy.",
git: "Clones a repository and deploys its compose file. Use “Sync” later to pull new commits and redeploy.",
};
const slugify = (s) => String(s || "").toLowerCase().replace(/[\s_.]+/g, "-").replace(/[^a-z0-9-]/g, "").replace(/^-+|-+$/g, "");
const newApp = {
source: "default",
provider: "forgejo",
domainTouched: false,
selectedRepo: null,
searchTimer: null,
searchSeq: 0,
init() {
this.dlg = $("#newAppDlg");
this.routes = routeEditor({ onChange: () => { $("#naError").hidden = true; } });
$("#naRoutes").append(this.routes.el);
this.env = envEditor({ onChange: () => this.updateEnvCount() });
$("#naEnv").append(this.env.el);
$("#naAddRoute").append(icon("plus"), "Add route");
$("#naAddRoute").onclick = () => this.routes.add();
$("#naSource").onclick = (e) => {
const b = e.target.closest("button[data-v]");
if (b) this.setSource(b.dataset.v);
};
$("#naProvider").onclick = (e) => {
const b = e.target.closest("button[data-v]");
if (b) this.setProvider(b.dataset.v);
};
$("#naName").addEventListener("input", (e) => {
const v = e.target.value.toLowerCase().replace(/[\s_.]+/g, "-").replace(/[^a-z0-9-]/g, "");
if (v !== e.target.value) e.target.value = v;
@ -2067,6 +2484,20 @@ const newApp = {
this.routes.el.addEventListener("input", (e) => {
if (e.target === this.routes.first()?.d) this.domainTouched = true;
});
$("#naRepoSearch").addEventListener("input", () => {
clearTimeout(this.searchTimer);
this.searchTimer = setTimeout(() => this.searchRepos($("#naRepoSearch").value.trim()), 250);
});
$("#naRepoSearch").addEventListener("keydown", (e) => {
if (e.key === "ArrowDown") { e.preventDefault(); $("#naRepoList .picker-item")?.focus(); }
});
$("#naRepoList").addEventListener("keydown", (e) => {
const items = [...$("#naRepoList").querySelectorAll(".picker-item")];
const i = items.indexOf(document.activeElement);
if (e.key === "ArrowDown" && i < items.length - 1) { e.preventDefault(); items[i + 1].focus(); }
if (e.key === "ArrowUp") { e.preventDefault(); (i > 0 ? items[i - 1] : $("#naRepoSearch")).focus(); }
});
$("#naUrl").addEventListener("input", () => this.updateUrlMode());
this.dlg.querySelectorAll("[data-close]").forEach((b) => {
if (!b.textContent.trim()) b.append(icon("x"));
b.onclick = () => this.dlg.close();
@ -2078,7 +2509,12 @@ const newApp = {
$("#newAppForm").reset();
$("#naError").hidden = true;
$("#naCompose").value = "";
$("#naEnvDetails").open = false;
this.env.set([]);
this.updateEnvCount();
this.domainTouched = false;
this.selectedRepo = null;
this.reposLoaded = false;
this.port = suggestPort();
this.base = suggestBaseDomain();
this.routes.set([{ domain: "", upstream: `127.0.0.1:${this.port}` }]);
@ -2088,6 +2524,11 @@ const newApp = {
$("#naName").focus();
},
updateEnvCount() {
const n = this.env.get().length;
$("#naEnvCount").textContent = n ? `${n} set` : "optional";
},
autofill() {
const first = this.routes.first();
if (!first || this.domainTouched) return;
@ -2099,12 +2540,129 @@ const newApp = {
this.source = v;
for (const b of $("#naSource").querySelectorAll("button")) b.classList.toggle("active", b.dataset.v === v);
$("#naRaw").hidden = v !== "raw";
$("#naGit").hidden = v !== "github";
$("#naGit").hidden = v !== "git";
$("#naSourceHint").textContent = SOURCE_HINTS[v];
if (v === "raw" && !$("#naCompose").value.trim()) {
const port = (this.routes.first()?.u.value || "").split(":").pop() || this.port;
$("#naCompose").value = composeTemplate(port);
}
if (v === "git") this.initGit();
},
async initGit() {
let d = null;
try { d = await integrations.load(); } catch { /* fall back to URL mode */ }
const configured = !!d?.forgejo?.configured;
$("#naProviderField").hidden = !configured;
if (configured) $("#naProviderForgejo").textContent = `Forgejo · ${stripUrl(d.forgejo.url)}`;
this.setProvider(configured ? this.provider : "url");
},
setProvider(v) {
this.provider = v;
for (const b of $("#naProvider").querySelectorAll("button")) b.classList.toggle("active", b.dataset.v === v);
$("#naForgejo").hidden = v !== "forgejo";
$("#naUrlMode").hidden = v !== "url";
if (v === "forgejo" && !this.reposLoaded) this.searchRepos("");
if (v === "forgejo") this.updateCloneVia(); else this.updateUrlMode();
},
async searchRepos(q) {
const seq = ++this.searchSeq;
const list = $("#naRepoList");
fill(list, h("div", { class: "empty-sm" }, h("span", { class: "spinner" }), " Loading repositories…"));
try {
const d = await api.forgejoRepos(q);
if (seq !== this.searchSeq) return;
this.reposLoaded = true;
const f = integrations.data?.forgejo;
fill($("#naForgejoHint"), d.authenticated ? null : [
"Showing public repositories only. ",
h("button", { type: "button", class: "link", onclick: () => settings.open() }, "Connect a Forgejo token"),
" to see private ones — or use “Any git URL” with an SSH URL and the deploy key."]);
const repos = d.repos.filter((r) => !r.archived);
if (!repos.length) {
fill(list, h("div", { class: "empty-sm" }, q ? `No repositories match “${q}”.` : "No repositories found."));
return;
}
fill(list, repos.map((r) => h("button", {
type: "button", role: "option",
class: `picker-item${this.selectedRepo?.full_name === r.full_name ? " selected" : ""}`,
onclick: () => this.selectRepo(r),
},
icon(r.private ? "lock" : "git"),
h("span", { class: "picker-main" },
h("div", { class: "picker-name" }, r.full_name, r.empty ? h("span", { class: "muted small" }, " (empty)") : null),
r.description ? h("div", { class: "picker-desc" }, r.description) : null),
h("span", { class: "muted small hide-sm" }, r.updated_at ? ago(Date.parse(r.updated_at)) : ""))));
if (f && !f.has_token && d.repos.length === 0 && !q) {
fill(list, h("div", { class: "empty-sm" }, "No public repositories. Connect a token in Settings to list private ones."));
}
} catch (e) {
if (seq !== this.searchSeq) return;
fill(list, h("div", { class: "empty-sm error" }, `Couldn't load repositories: ${e.message}`));
}
},
async selectRepo(r) {
this.selectedRepo = r;
$("#naError").hidden = true;
for (const item of $("#naRepoList").querySelectorAll(".picker-item")) {
item.classList.toggle("selected", item.querySelector(".picker-name")?.firstChild?.textContent === r.full_name);
}
if (!$("#naName").value) {
$("#naName").value = slugify(r.full_name.split("/")[1]);
this.autofill();
}
this.updateCloneVia();
const sel = $("#naForgejoBranch");
sel.disabled = true;
fill(sel, h("option", { value: "" }, "Loading branches…"));
try {
const d = await api.forgejoBranches(r.full_name);
if (this.selectedRepo !== r) return;
const branches = d.branches.length ? d.branches : [r.default_branch || "main"];
fill(sel, branches.map((b) => h("option", { value: b }, b === r.default_branch ? `${b} (default)` : b)));
sel.value = branches.includes(r.default_branch) ? r.default_branch : branches[0];
sel.disabled = false;
} catch (e) {
fill(sel, h("option", { value: r.default_branch || "" }, r.default_branch || "default branch"));
sel.disabled = false;
}
},
// How a Forgejo repository will be cloned, given the panel's credentials.
cloneVia(r) {
const f = integrations.data?.forgejo;
if (f?.has_token) return { url: r.clone_url, useToken: true, label: "HTTPS with your Forgejo token" };
if (r.private) return { url: r.ssh_url, useToken: false, ssh: true, label: "SSH with the panel's deploy key" };
return { url: r.clone_url, useToken: false, label: "HTTPS (public repository)" };
},
updateCloneVia() {
const r = this.selectedRepo;
const via = r ? this.cloneVia(r) : null;
$("#naCloneVia").textContent = via ? via.label : "—";
this.showDeployKey(!!via?.ssh);
},
updateUrlMode() {
const url = $("#naUrl").value.trim();
const ssh = !!url && isSshUrl(url);
$("#naPatField").hidden = ssh;
this.showDeployKey(ssh);
},
async showDeployKey(show) {
const box = $("#naDeployKey");
if (!show || this.source !== "git") { box.hidden = true; return; }
let key = null;
try { key = (await integrations.load()).ssh?.public_key; } catch { /* shown below */ }
box.hidden = false;
fill(box, icon("key"), h("div", { style: "flex:1;min-width:0" },
h("div", null, "Cloning over SSH uses the panel's deploy key. Add it to the repository as a ",
h("strong", null, "read-only deploy key"), " (Repository settings → Deploy keys) before creating the app."),
key ? keyBox(key) : h("div", { class: "error" }, "No deploy key available (ssh-keygen missing).")));
},
async submit() {
@ -2120,30 +2678,48 @@ const newApp = {
if (state.apps.has(name)) return fail(`An app called “${name}” already exists.`, $("#naName"));
const routeError = this.routes.validate();
if (routeError) return fail(routeError);
const envError = this.env.validate();
if (envError) { $("#naEnvDetails").open = true; return fail(envError); }
const payload = { name, routes: normRoutes(this.routes.get()), auth: $("#naAuth").checked, source_type: this.source };
const payload = {
name,
routes: normRoutes(this.routes.get()),
auth: $("#naAuth").checked,
source_type: this.source,
env: this.env.get(),
};
if (this.source === "raw") {
payload.compose_content = $("#naCompose").value;
if (!payload.compose_content.trim()) return fail("Paste a compose file.", $("#naCompose"));
}
if (this.source === "github") {
payload.github_url = $("#naUrl").value.trim();
payload.github_branch = $("#naBranch").value.trim();
payload.github_pat = $("#naPat").value.trim();
if (!/^https?:\/\/\S+$/.test(payload.github_url)) return fail("Enter the repository's https:// URL.", $("#naUrl"));
if (this.source === "git") {
if (this.provider === "forgejo") {
const r = this.selectedRepo;
if (!r) return fail("Pick a repository.", $("#naRepoSearch"));
const via = this.cloneVia(r);
payload.repo_url = via.url;
payload.repo_branch = $("#naForgejoBranch").value;
payload.use_forgejo_token = via.useToken;
} else {
payload.repo_url = $("#naUrl").value.trim();
payload.repo_branch = $("#naBranch").value.trim();
payload.repo_token = isSshUrl(payload.repo_url) ? "" : $("#naPat").value.trim();
if (!isGitUrl(payload.repo_url)) return fail("Enter an https://, ssh:// or git@host:owner/repo URL.", $("#naUrl"));
}
}
const deploy = $("#naDeploy").checked;
const submitBtn = $("#naSubmit");
submitBtn.disabled = true;
fill(submitBtn, h("span", { class: "spinner" }), this.source === "github" ? "Cloning…" : "Creating…");
fill(submitBtn, h("span", { class: "spinner" }), this.source === "git" ? "Cloning…" : "Creating…");
const entry = activity.start(`Create · ${name}`);
try {
const res = await api.init(payload);
entry.finish(true, outputOf(res));
} catch (e) {
entry.finish(false, e.detail || e.message);
return fail(e.message);
const hint = /permission denied|publickey/i.test(e.detail || e.message) ? " — has the deploy key been added to the repository?" : "";
return fail(e.message + hint);
} finally {
submitBtn.disabled = false;
submitBtn.textContent = "Create app";
@ -2161,6 +2737,85 @@ const newApp = {
},
};
// ─── Settings dialog ───────────────────────────────────────────────────────
const settings = {
init() {
this.dlg = $("#settingsDlg");
this.dlg.querySelectorAll("[data-close]").forEach((b) => {
b.append(icon("x"));
b.onclick = () => this.dlg.close();
});
$("#settingsForm").onsubmit = (e) => { e.preventDefault(); this.saveToken(); };
$("#setTokenRemove").onclick = () => this.removeToken();
},
async open() {
if (!this.dlg.open) this.dlg.showModal();
await this.render(true);
},
async render(force) {
let d;
try {
d = await integrations.load(force);
} catch (e) {
$("#setForgejoStatus").textContent = `Couldn't load settings: ${e.message}`;
return;
}
const f = d.forgejo;
$("#setForgejo").hidden = !f.configured;
if (f.configured) {
const link = h("a", { href: f.url, target: "_blank", rel: "noopener" }, stripUrl(f.url));
fill($("#setForgejoStatus"), f.has_token
? (f.user
? [icon("check"), " Connected to ", link, " as ", h("strong", null, f.user), "."]
: ["A token is saved, but it doesn't work: ", f.error || "unknown error", ". Paste a new one below."])
: ["Instance: ", link, ". Not connected — the panel can only list public repositories."]);
$("#setTokenField").hidden = !!(f.has_token && f.user);
$("#setTokenRemove").hidden = !f.has_token;
$("#setSshExample").textContent = f.ssh_url ? `${f.ssh_url}/owner/repo.git` : "ssh://git@host/owner/repo.git";
}
fill($("#setSshKey"), d.ssh?.public_key
? keyBox(d.ssh.public_key)
: h("span", { class: "error" }, "ssh-keygen isn't available, so cloning over SSH is disabled."));
},
async saveToken() {
const token = $("#setToken").value.trim();
if (!token) { $("#setToken").focus(); return; }
const b = $("#setTokenSave");
b.disabled = true;
try {
const r = await api.saveForgejoToken(token);
$("#setToken").value = "";
toast("success", `Connected to Forgejo as ${r.user}`);
await this.render(true);
newApp.reposLoaded = false;
if (newApp.dlg.open && newApp.source === "git" && newApp.provider === "forgejo") {
newApp.searchRepos($("#naRepoSearch").value.trim());
newApp.updateCloneVia();
}
} catch (e) {
toast("error", "Couldn't connect to Forgejo", { detail: e.message });
} finally {
b.disabled = false;
}
},
async removeToken() {
const ok = await confirmDialog({
title: "Disconnect Forgejo?",
body: [h("p", null, "The stored token is deleted. Apps already cloned with it keep syncing — the token stays in their git config.")],
confirmLabel: "Disconnect", danger: true,
});
if (!ok) return;
try {
await api.saveForgejoToken("");
toast("success", "Forgejo disconnected");
} catch (e) {
toast("error", "Couldn't disconnect", { detail: e.message });
}
this.render(true);
},
};
// ─── Wiring ────────────────────────────────────────────────────────────────
$("#brandMark").append(icon("layers"));
@ -2170,6 +2825,8 @@ $("#newIcon").append(icon("plus"));
$("#activityClose").append(icon("x"));
$("#newAppBtn").onclick = () => newApp.open();
$("#settingsBtn").append(icon("settings"));
$("#settingsBtn").onclick = () => settings.open();
$("#syncBtn").onclick = () => (session.expired ? location.reload() : poller.now());
$("#reloginBtn").onclick = () => location.reload();
$("#activityBtn").onclick = () => activity.toggle();
@ -2211,6 +2868,7 @@ setInterval(() => {
}, 1000);
newApp.init();
settings.init();
renderFilters();
renderSync();
poller.schedule(0);