panel: redesign web UI and make syncing responsive #1
6 changed files with 1269 additions and 101 deletions
12
panel.nix
12
panel.nix
|
|
@ -1,5 +1,8 @@
|
||||||
{ config, pkgs, ... }:
|
{ config, lib, pkgs, ... }:
|
||||||
|
|
||||||
|
let
|
||||||
|
forgejoServer = config.services.forgejo.settings.server;
|
||||||
|
in
|
||||||
{
|
{
|
||||||
environment.systemPackages = [
|
environment.systemPackages = [
|
||||||
(pkgs.writeShellScriptBin "panelctl" (builtins.readFile ./panel/panelctl.sh))
|
(pkgs.writeShellScriptBin "panelctl" (builtins.readFile ./panel/panelctl.sh))
|
||||||
|
|
@ -34,6 +37,7 @@
|
||||||
pkgs.unzip
|
pkgs.unzip
|
||||||
pkgs.git
|
pkgs.git
|
||||||
pkgs.util-linux # flock, used by panelctl to serialise routes file writes
|
pkgs.util-linux # flock, used by panelctl to serialise routes file writes
|
||||||
|
pkgs.openssh # cloning repositories over ssh with the panel's deploy key
|
||||||
];
|
];
|
||||||
|
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
|
|
@ -52,6 +56,12 @@
|
||||||
PANEL_BASE_DIR = "/var/lib/containers";
|
PANEL_BASE_DIR = "/var/lib/containers";
|
||||||
PANELCTL_PATH = "/run/current-system/sw/bin/panelctl";
|
PANELCTL_PATH = "/run/current-system/sw/bin/panelctl";
|
||||||
PANEL_FRONTEND_DIR = "${./panel/frontend}";
|
PANEL_FRONTEND_DIR = "${./panel/frontend}";
|
||||||
|
|
||||||
|
# Forgejo integration (repo picker, private clones, commit links).
|
||||||
|
# The API is reached on localhost; clones use the public URLs.
|
||||||
|
PANEL_FORGEJO_URL = lib.removeSuffix "/" forgejoServer.ROOT_URL;
|
||||||
|
PANEL_FORGEJO_API_URL = "http://${forgejoServer.HTTP_ADDR}:${toString forgejoServer.HTTP_PORT}";
|
||||||
|
PANEL_FORGEJO_SSH_URL = "ssh://${forgejoServer.BUILTIN_SSH_SERVER_USER}@${forgejoServer.DOMAIN}:${toString forgejoServer.SSH_PORT}";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
30
panel/API.md
30
panel/API.md
|
|
@ -13,6 +13,10 @@ Default bind: `127.0.0.1:9911`
|
||||||
| GET | `/` | Web UI (served from `frontend/index.html`) |
|
| GET | `/` | Web UI (served from `frontend/index.html`) |
|
||||||
| GET | `/health` | Health check |
|
| GET | `/health` | Health check |
|
||||||
| GET | `/status` | All apps with routes, container status and running operation (what the UI polls) |
|
| GET | `/status` | All apps with routes, container status and running operation (what the UI polls) |
|
||||||
|
| GET | `/integrations` | Forgejo connection (`configured`, `url`, `has_token`, `user`) and the SSH deploy public key |
|
||||||
|
| POST | `/integrations/forgejo` | `{"token": "..."}` — verify against Forgejo and store; `""` disconnects |
|
||||||
|
| GET | `/forgejo/repos?q=` | Search repositories visible to the stored token (public ones without) |
|
||||||
|
| GET | `/forgejo/branches?repo=owner/name` | Branch names of a Forgejo repository |
|
||||||
|
|
||||||
### Apps — Read
|
### Apps — Read
|
||||||
|
|
||||||
|
|
@ -26,7 +30,8 @@ Default bind: `127.0.0.1:9911`
|
||||||
| GET | `/apps/<name>/logs?tail=N` | Fetch last N log lines (default 100) |
|
| GET | `/apps/<name>/logs?tail=N` | Fetch last N log lines (default 100) |
|
||||||
| GET | `/apps/<name>/backups` | List available backups |
|
| GET | `/apps/<name>/backups` | List available backups |
|
||||||
| GET | `/apps/<name>/backups/<file>` | Download backup zip |
|
| GET | `/apps/<name>/backups/<file>` | Download backup zip |
|
||||||
| GET | `/apps/<name>/repo` | Git source info (URL, branch, deployed commit, local changes) |
|
| GET | `/apps/<name>/env` | Environment variables: `{"vars": [{"key", "value"}], "inject": true}` |
|
||||||
|
| GET | `/apps/<name>/repo` | Git source info (URL, web URL, provider, branch, deployed commit, local changes, deploy key for ssh) |
|
||||||
| GET | `/apps/<name>/repo?fetch=1` | Same, plus fetches the remote and reports `behind` / `remote` |
|
| GET | `/apps/<name>/repo?fetch=1` | Same, plus fetches the remote and reports `behind` / `remote` |
|
||||||
| GET | `/apps/<name>/volumes` | Volumes the file browser can open |
|
| GET | `/apps/<name>/volumes` | Volumes the file browser can open |
|
||||||
| GET | `/apps/<name>/volume/files?vol=&path=` | List a folder in a volume |
|
| GET | `/apps/<name>/volume/files?vol=&path=` | List a folder in a volume |
|
||||||
|
|
@ -50,28 +55,37 @@ Default bind: `127.0.0.1:9911`
|
||||||
| POST | `/apps/<name>/restore` | Restore from backup |
|
| POST | `/apps/<name>/restore` | Restore from backup |
|
||||||
| POST | `/apps/<name>/remove` | Remove app |
|
| POST | `/apps/<name>/remove` | Remove app |
|
||||||
| POST | `/apps/<name>/repo-pull` | Git apps: fetch branch, hard-reset checkout to it, redeploy |
|
| POST | `/apps/<name>/repo-pull` | Git apps: fetch branch, hard-reset checkout to it, redeploy |
|
||||||
|
| POST | `/apps/<name>/env` | Replace environment variables: `{"vars": [...], "inject": true, "deploy": false}` |
|
||||||
| POST | `/apps/<name>/volume-clear` | Stop the app and empty its default data folder |
|
| POST | `/apps/<name>/volume-clear` | Stop the app and empty its default data folder |
|
||||||
|
|
||||||
Write operations are serialised per app. While one runs, another write to the
|
Write operations are serialised per app. While one runs, another write to the
|
||||||
same app returns `409` with `{"ok": false, "error": "...", "busy": "deploy"}`.
|
same app returns `409` with `{"ok": false, "error": "...", "busy": "deploy"}`.
|
||||||
`deploy` returns the compose output in `stdout` (or `stderr` on failure).
|
`deploy` returns the compose output in `stdout` (or `stderr` on failure).
|
||||||
|
|
||||||
### Create app (git repository)
|
### Create app (git repository, with environment variables)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"name": "blog",
|
"name": "blog",
|
||||||
"routes": [{"domain": "blog.srazka.com", "upstream": "127.0.0.1:18090"}],
|
"routes": [{"domain": "blog.srazka.com", "upstream": "127.0.0.1:18090"}],
|
||||||
"auth": true,
|
"auth": true,
|
||||||
"source_type": "github",
|
"source_type": "git",
|
||||||
"github_url": "https://git.srazka.com/reudy-net/blog.git",
|
"repo_url": "https://git.srazka.com/reudy-net/blog.git",
|
||||||
"github_branch": "",
|
"repo_branch": "",
|
||||||
"github_pat": ""
|
"use_forgejo_token": true,
|
||||||
|
"env": [{"key": "DATABASE_URL", "value": "postgres://..."}],
|
||||||
|
"env_inject": true
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Any http(s) git host works. An empty branch uses the repository's default branch.
|
- `repo_url` may be `https://…`, `ssh://git@host:port/owner/repo.git` or
|
||||||
The compose file must be at the repository root.
|
`git@host:owner/repo.git`. ssh URLs use the panel's deploy key.
|
||||||
|
- `repo_token` sets an https token explicitly; `use_forgejo_token` uses the
|
||||||
|
token stored in Settings (only for URLs on the configured Forgejo host).
|
||||||
|
- An empty branch uses the repository's default branch. The compose file must
|
||||||
|
be at the repository root.
|
||||||
|
- The older `source_type: "github"` with `github_url` / `github_branch` /
|
||||||
|
`github_pat` is still accepted.
|
||||||
|
|
||||||
### Sync response (`repo-pull`)
|
### Sync response (`repo-pull`)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -114,13 +114,49 @@ panelctl remove whoami
|
||||||
|
|
||||||
### Git-backed apps
|
### Git-backed apps
|
||||||
|
|
||||||
Apps created from a repository (GitHub, Forgejo/Gitea or any https git host)
|
Apps created from a repository (Forgejo, GitHub or any git host, over https or
|
||||||
are cloned to `stacks/<app>/repo`. **Sync** fetches the configured branch and
|
ssh) are cloned to `stacks/<app>/repo`.
|
||||||
|
|
||||||
|
**Forgejo.** `panel.nix` points the panel at the local Forgejo
|
||||||
|
(`PANEL_FORGEJO_URL`, `PANEL_FORGEJO_API_URL`, `PANEL_FORGEJO_SSH_URL`, taken
|
||||||
|
from `forgejo.nix`). In the panel's **Settings** you can connect a Forgejo
|
||||||
|
access token (read access to repositories and user). With it, the new-app
|
||||||
|
dialog lists your repositories and branches, and private ones are cloned over
|
||||||
|
https with the token. Without it, public repositories are listed and private
|
||||||
|
ones are cloned over ssh with the deploy key. Commit and compare links point at
|
||||||
|
Forgejo. The token is stored in `state/panel/forgejo-token` (mode 0600).
|
||||||
|
|
||||||
|
**SSH / deploy key.** The panel generates an ed25519 key pair in
|
||||||
|
`state/panel/ssh/` the first time it is needed. Its public half is shown in
|
||||||
|
Settings (and next to ssh URLs); add it as a read-only deploy key to a
|
||||||
|
repository — or to your Forgejo account for access to all repositories — to
|
||||||
|
clone `ssh://git@git.srazka.com:14922/owner/repo.git` style URLs. **Sync** fetches the configured branch and
|
||||||
hard-resets the checkout to it before redeploying, so the repository is the
|
hard-resets the checkout to it before redeploying, so the repository is the
|
||||||
source of truth: compose edits made in the panel are discarded on the next sync
|
source of truth: compose edits made in the panel are discarded on the next sync
|
||||||
(the UI warns about this). An access token for a private repository is stored in
|
(the UI warns about this). An access token for a private repository is stored in
|
||||||
the clone's `.git/config`; use a read-only token.
|
the clone's `.git/config`; use a read-only token.
|
||||||
|
|
||||||
|
### Environment variables
|
||||||
|
|
||||||
|
Each app can have environment variables (the **Environment** tab, or when
|
||||||
|
creating the app; `.env` text can be pasted in). They are stored in
|
||||||
|
`state/env/<app>.env` as `KEY=VALUE` lines (mode 0600) — outside the repository
|
||||||
|
and stack directory, so git syncs never touch them — and `panelctl` passes them
|
||||||
|
to every compose command:
|
||||||
|
|
||||||
|
- They are always available for `${VAR}` interpolation in the compose file.
|
||||||
|
The UI points out variables the compose file uses without a default that
|
||||||
|
aren't set.
|
||||||
|
- With **Pass to every container** (the default), `deploy`/`restart` also
|
||||||
|
generate `stacks/<app>/.panel-env.yaml`, a compose override that lists the
|
||||||
|
keys under every service's `environment:`. Compose reads the values from its
|
||||||
|
own environment, so they are never quoted into YAML, and they take
|
||||||
|
precedence over values set in the compose file.
|
||||||
|
|
||||||
|
Values must be single-line. Names that would change how podman/compose run
|
||||||
|
(`PATH`, `HOME`, `XDG_*`, `DOCKER_*`, `COMPOSE_*`, `PODMAN_*`, …) are rejected.
|
||||||
|
Changes apply on the next deploy. Backups do not include variables.
|
||||||
|
|
||||||
### Concurrency
|
### Concurrency
|
||||||
|
|
||||||
`panel-api` handles requests concurrently, so a long deploy never blocks status
|
`panel-api` handles requests concurrently, so a long deploy never blocks status
|
||||||
|
|
|
||||||
|
|
@ -450,6 +450,45 @@
|
||||||
}
|
}
|
||||||
.segmented button.active { background: var(--surface); color: var(--text); box-shadow: var(--shadow); }
|
.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 ── */
|
/* ── Responsive ── */
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.hide-sm { display: none !important; }
|
.hide-sm { display: none !important; }
|
||||||
|
|
@ -467,6 +506,10 @@
|
||||||
.route-row .arrow { display: none; }
|
.route-row .arrow { display: none; }
|
||||||
.route-row .route-path { grid-column: 1 / 3; }
|
.route-row .route-path { grid-column: 1 / 3; }
|
||||||
.route-head { display: none; }
|
.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 { grid-template-columns: 20px minmax(0, 1fr) auto; }
|
||||||
.file-row > :nth-child(3), .file-row > :nth-child(4) { display: none; }
|
.file-row > :nth-child(3), .file-row > :nth-child(4) { display: none; }
|
||||||
.logs { height: 320px; }
|
.logs { height: 320px; }
|
||||||
|
|
@ -485,6 +528,7 @@
|
||||||
</label>
|
</label>
|
||||||
<div class="top-actions">
|
<div class="top-actions">
|
||||||
<button id="syncBtn" class="sync" type="button"><span class="sync-dot"></span><span id="syncText">Connecting…</span></button>
|
<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">
|
<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>
|
<span id="activityIcon"></span><span class="hide-sm">Activity</span><span id="activityCount" class="count" hidden></span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -538,7 +582,7 @@
|
||||||
<div class="segmented" id="naSource">
|
<div class="segmented" id="naSource">
|
||||||
<button type="button" data-v="default">Starter</button>
|
<button type="button" data-v="default">Starter</button>
|
||||||
<button type="button" data-v="raw">Compose file</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>
|
</div>
|
||||||
<p class="hint" id="naSourceHint"></p>
|
<p class="hint" id="naSourceHint"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -549,24 +593,56 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="naGit" hidden>
|
<div id="naGit" hidden>
|
||||||
<div class="field">
|
<div class="field" id="naProviderField">
|
||||||
<label for="naUrl">Repository URL</label>
|
<div class="segmented" id="naProvider">
|
||||||
<input id="naUrl" class="input" placeholder="https://git.example.com/user/repo" autocomplete="off" spellcheck="false" />
|
<button type="button" data-v="forgejo" id="naProviderForgejo">Forgejo</button>
|
||||||
</div>
|
<button type="button" data-v="url">Any git URL</button>
|
||||||
<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>
|
</div>
|
||||||
</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.
|
<div id="naForgejo">
|
||||||
A token is kept in the clone's git config, so use a read-only one.
|
<div class="field">
|
||||||
</p>
|
<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>
|
||||||
|
|
||||||
<div class="field">
|
<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>
|
<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>
|
</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="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>
|
<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>
|
</form>
|
||||||
</dialog>
|
</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">
|
<dialog id="confirmDlg" class="sm">
|
||||||
<form method="dialog">
|
<form method="dialog">
|
||||||
<h2 id="cfTitle"></h2>
|
<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"/>',
|
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"/>',
|
logs: '<path d="M4 6h16M4 12h16M4 18h10"/>',
|
||||||
layers: '<path d="M12 3l9 5-9 5-9-5z"/><path d="M3 13l9 5 9-5"/>',
|
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 = "") {
|
function icon(name, cls = "") {
|
||||||
|
|
@ -780,8 +895,44 @@ const api = {
|
||||||
deleteFile: (n, path, vol) => api.request(api.fileUrl(n, path, vol), { method: "DELETE" }),
|
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 }),
|
upload: (n, path, vol, file) => api.request(api.fileUrl(n, path, vol), { method: "PUT", body: file }),
|
||||||
init: (payload) => api.post("/apps/init", payload),
|
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 = {
|
const session = {
|
||||||
expired: false,
|
expired: false,
|
||||||
expire() {
|
expire() {
|
||||||
|
|
@ -811,7 +962,7 @@ const cards = new Map(); // name -> AppCard
|
||||||
const BUSY_LABELS = {
|
const BUSY_LABELS = {
|
||||||
init: "Creating…", deploy: "Deploying…", restart: "Restarting…", stop: "Stopping…",
|
init: "Creating…", deploy: "Deploying…", restart: "Restarting…", stop: "Stopping…",
|
||||||
"repo-pull": "Syncing…", backup: "Backing up…", restore: "Restoring…", remove: "Removing…",
|
"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 busyLabel = (b) => BUSY_LABELS[b] || "Working…";
|
||||||
const busyOf = (app) => state.localBusy.get(app.name) || app.busy || null;
|
const busyOf = (app) => state.localBusy.get(app.name) || app.busy || null;
|
||||||
|
|
@ -1049,7 +1200,7 @@ window.addEventListener("hashchange", applyHash);
|
||||||
// ─── App card ──────────────────────────────────────────────────────────────
|
// ─── App card ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const TABS = [
|
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"],
|
["files", "Files"], ["backups", "Backups"], ["source", "Source"],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -1094,13 +1245,15 @@ class AppCard {
|
||||||
this.pill.className = `pill pill-${st.key}`;
|
this.pill.className = `pill pill-${st.key}`;
|
||||||
fill(this.pill, st.key === "busy" ? h("span", { class: "spinner" }) : h("span", { class: "dot" }), st.label);
|
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) {
|
if (subSig !== this.subSig) {
|
||||||
this.subSig = subSig;
|
this.subSig = subSig;
|
||||||
|
const provider = app.repo_provider === "forgejo" || app.repo_provider === "github" ? `${PROVIDER_NAMES[app.repo_provider]} · ` : "";
|
||||||
fill(this.sub,
|
fill(this.sub,
|
||||||
...domainLinks(app.routes, 2),
|
...domainLinks(app.routes, 2),
|
||||||
app.auth ? h("span", { class: "badge", title: "Visitors must log in through Authelia" }, icon("lock"), "Protected") : null,
|
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";
|
this.deployBtn.disabled = st.key === "busy";
|
||||||
if (this.panels) {
|
if (this.panels) {
|
||||||
|
|
@ -1113,6 +1266,7 @@ class AppCard {
|
||||||
this.panels = {
|
this.panels = {
|
||||||
overview: new OverviewPanel(this, "overview"),
|
overview: new OverviewPanel(this, "overview"),
|
||||||
compose: new ComposePanel(this, "compose"),
|
compose: new ComposePanel(this, "compose"),
|
||||||
|
env: new EnvPanel(this, "env"),
|
||||||
logs: new LogsPanel(this, "logs"),
|
logs: new LogsPanel(this, "logs"),
|
||||||
routes: new RoutesPanel(this, "routes"),
|
routes: new RoutesPanel(this, "routes"),
|
||||||
files: new FilesPanel(this, "files"),
|
files: new FilesPanel(this, "files"),
|
||||||
|
|
@ -1178,8 +1332,7 @@ class AppCard {
|
||||||
destroy() {
|
destroy() {
|
||||||
this.collapse();
|
this.collapse();
|
||||||
this.root.remove();
|
this.root.remove();
|
||||||
state.dirty.delete(`${this.name}:compose`);
|
for (const key of ["compose", "routes", "env"]) state.dirty.delete(`${this.name}:${key}`);
|
||||||
state.dirty.delete(`${this.name}:routes`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1200,7 +1353,7 @@ class OverviewPanel extends Panel {
|
||||||
update(app) {
|
update(app) {
|
||||||
const s = app.status || {};
|
const s = app.status || {};
|
||||||
const containers = s.containers || [];
|
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;
|
if (sig === this.sig) return;
|
||||||
this.sig = sig;
|
this.sig = sig;
|
||||||
|
|
||||||
|
|
@ -1224,7 +1377,15 @@ class OverviewPanel extends Panel {
|
||||||
r.path ? h("span", { class: "mono muted" }, r.path) : null))),
|
r.path ? h("span", { class: "mono muted" }, r.path) : null))),
|
||||||
h("dl", { class: "facts" },
|
h("dl", { class: "facts" },
|
||||||
h("dt", null, "Access"), h("dd", null, app.auth ? "Login required (Authelia)" : "Public"),
|
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 || "—")));
|
h("dt", null, "Compose"), h("dd", { class: "mono small" }, app.compose_file || "—")));
|
||||||
|
|
||||||
fill(this.el, h("div", { class: "grid-2" }, containerBox, infoBox));
|
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 ──
|
// ── Files ──
|
||||||
|
|
||||||
const hasFiles = (e) => [...(e.dataTransfer?.types || [])].includes("Files");
|
const hasFiles = (e) => [...(e.dataTransfer?.types || [])].includes("Files");
|
||||||
|
|
@ -1696,9 +2090,11 @@ class BackupsPanel extends Panel {
|
||||||
|
|
||||||
// ── Git source ──
|
// ── Git source ──
|
||||||
|
|
||||||
function commitView(c) {
|
function commitView(c, webUrl = "") {
|
||||||
|
const sha = h("code", null, c.short);
|
||||||
return h("span", { class: "commit" },
|
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)}` : ""}`));
|
h("span", { class: "muted small" }, ` — ${c.author}${c.time ? `, ${ago(c.time * 1000)}` : ""}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1729,14 +2125,22 @@ class SourcePanel extends Panel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
render(d, fetched) {
|
render(d, fetched) {
|
||||||
const url = /^https?:\/\//.test(d.url) ? d.url : null;
|
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,
|
fill(this.info,
|
||||||
h("dl", { class: "facts" },
|
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, "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"),
|
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;
|
if (!fetched) return;
|
||||||
this.check.hidden = false;
|
this.check.hidden = false;
|
||||||
if (d.fetch_error) {
|
if (d.fetch_error) {
|
||||||
|
|
@ -1747,7 +2151,8 @@ class SourcePanel extends Panel {
|
||||||
const n = d.behind;
|
const n = d.behind;
|
||||||
fill(this.check,
|
fill(this.check,
|
||||||
h("strong", null, n == null ? "New commits available" : `${n} new commit${n === 1 ? "" : "s"}`),
|
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 = {
|
const SOURCE_HINTS = {
|
||||||
default: "Starts a tiny traefik/whoami container so you can check the route works. Edit the compose file afterwards.",
|
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.",
|
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 = {
|
const newApp = {
|
||||||
source: "default",
|
source: "default",
|
||||||
|
provider: "forgejo",
|
||||||
domainTouched: false,
|
domainTouched: false,
|
||||||
|
selectedRepo: null,
|
||||||
|
searchTimer: null,
|
||||||
|
searchSeq: 0,
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this.dlg = $("#newAppDlg");
|
this.dlg = $("#newAppDlg");
|
||||||
this.routes = routeEditor({ onChange: () => { $("#naError").hidden = true; } });
|
this.routes = routeEditor({ onChange: () => { $("#naError").hidden = true; } });
|
||||||
$("#naRoutes").append(this.routes.el);
|
$("#naRoutes").append(this.routes.el);
|
||||||
|
this.env = envEditor({ onChange: () => this.updateEnvCount() });
|
||||||
|
$("#naEnv").append(this.env.el);
|
||||||
$("#naAddRoute").append(icon("plus"), "Add route");
|
$("#naAddRoute").append(icon("plus"), "Add route");
|
||||||
$("#naAddRoute").onclick = () => this.routes.add();
|
$("#naAddRoute").onclick = () => this.routes.add();
|
||||||
$("#naSource").onclick = (e) => {
|
$("#naSource").onclick = (e) => {
|
||||||
const b = e.target.closest("button[data-v]");
|
const b = e.target.closest("button[data-v]");
|
||||||
if (b) this.setSource(b.dataset.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) => {
|
$("#naName").addEventListener("input", (e) => {
|
||||||
const v = e.target.value.toLowerCase().replace(/[\s_.]+/g, "-").replace(/[^a-z0-9-]/g, "");
|
const v = e.target.value.toLowerCase().replace(/[\s_.]+/g, "-").replace(/[^a-z0-9-]/g, "");
|
||||||
if (v !== e.target.value) e.target.value = v;
|
if (v !== e.target.value) e.target.value = v;
|
||||||
|
|
@ -2067,6 +2484,20 @@ const newApp = {
|
||||||
this.routes.el.addEventListener("input", (e) => {
|
this.routes.el.addEventListener("input", (e) => {
|
||||||
if (e.target === this.routes.first()?.d) this.domainTouched = true;
|
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) => {
|
this.dlg.querySelectorAll("[data-close]").forEach((b) => {
|
||||||
if (!b.textContent.trim()) b.append(icon("x"));
|
if (!b.textContent.trim()) b.append(icon("x"));
|
||||||
b.onclick = () => this.dlg.close();
|
b.onclick = () => this.dlg.close();
|
||||||
|
|
@ -2078,7 +2509,12 @@ const newApp = {
|
||||||
$("#newAppForm").reset();
|
$("#newAppForm").reset();
|
||||||
$("#naError").hidden = true;
|
$("#naError").hidden = true;
|
||||||
$("#naCompose").value = "";
|
$("#naCompose").value = "";
|
||||||
|
$("#naEnvDetails").open = false;
|
||||||
|
this.env.set([]);
|
||||||
|
this.updateEnvCount();
|
||||||
this.domainTouched = false;
|
this.domainTouched = false;
|
||||||
|
this.selectedRepo = null;
|
||||||
|
this.reposLoaded = false;
|
||||||
this.port = suggestPort();
|
this.port = suggestPort();
|
||||||
this.base = suggestBaseDomain();
|
this.base = suggestBaseDomain();
|
||||||
this.routes.set([{ domain: "", upstream: `127.0.0.1:${this.port}` }]);
|
this.routes.set([{ domain: "", upstream: `127.0.0.1:${this.port}` }]);
|
||||||
|
|
@ -2088,6 +2524,11 @@ const newApp = {
|
||||||
$("#naName").focus();
|
$("#naName").focus();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateEnvCount() {
|
||||||
|
const n = this.env.get().length;
|
||||||
|
$("#naEnvCount").textContent = n ? `${n} set` : "optional";
|
||||||
|
},
|
||||||
|
|
||||||
autofill() {
|
autofill() {
|
||||||
const first = this.routes.first();
|
const first = this.routes.first();
|
||||||
if (!first || this.domainTouched) return;
|
if (!first || this.domainTouched) return;
|
||||||
|
|
@ -2099,12 +2540,129 @@ const newApp = {
|
||||||
this.source = v;
|
this.source = v;
|
||||||
for (const b of $("#naSource").querySelectorAll("button")) b.classList.toggle("active", b.dataset.v === v);
|
for (const b of $("#naSource").querySelectorAll("button")) b.classList.toggle("active", b.dataset.v === v);
|
||||||
$("#naRaw").hidden = v !== "raw";
|
$("#naRaw").hidden = v !== "raw";
|
||||||
$("#naGit").hidden = v !== "github";
|
$("#naGit").hidden = v !== "git";
|
||||||
$("#naSourceHint").textContent = SOURCE_HINTS[v];
|
$("#naSourceHint").textContent = SOURCE_HINTS[v];
|
||||||
if (v === "raw" && !$("#naCompose").value.trim()) {
|
if (v === "raw" && !$("#naCompose").value.trim()) {
|
||||||
const port = (this.routes.first()?.u.value || "").split(":").pop() || this.port;
|
const port = (this.routes.first()?.u.value || "").split(":").pop() || this.port;
|
||||||
$("#naCompose").value = composeTemplate(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() {
|
async submit() {
|
||||||
|
|
@ -2120,30 +2678,48 @@ const newApp = {
|
||||||
if (state.apps.has(name)) return fail(`An app called “${name}” already exists.`, $("#naName"));
|
if (state.apps.has(name)) return fail(`An app called “${name}” already exists.`, $("#naName"));
|
||||||
const routeError = this.routes.validate();
|
const routeError = this.routes.validate();
|
||||||
if (routeError) return fail(routeError);
|
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") {
|
if (this.source === "raw") {
|
||||||
payload.compose_content = $("#naCompose").value;
|
payload.compose_content = $("#naCompose").value;
|
||||||
if (!payload.compose_content.trim()) return fail("Paste a compose file.", $("#naCompose"));
|
if (!payload.compose_content.trim()) return fail("Paste a compose file.", $("#naCompose"));
|
||||||
}
|
}
|
||||||
if (this.source === "github") {
|
if (this.source === "git") {
|
||||||
payload.github_url = $("#naUrl").value.trim();
|
if (this.provider === "forgejo") {
|
||||||
payload.github_branch = $("#naBranch").value.trim();
|
const r = this.selectedRepo;
|
||||||
payload.github_pat = $("#naPat").value.trim();
|
if (!r) return fail("Pick a repository.", $("#naRepoSearch"));
|
||||||
if (!/^https?:\/\/\S+$/.test(payload.github_url)) return fail("Enter the repository's https:// URL.", $("#naUrl"));
|
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 deploy = $("#naDeploy").checked;
|
||||||
|
|
||||||
const submitBtn = $("#naSubmit");
|
const submitBtn = $("#naSubmit");
|
||||||
submitBtn.disabled = true;
|
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}`);
|
const entry = activity.start(`Create · ${name}`);
|
||||||
try {
|
try {
|
||||||
const res = await api.init(payload);
|
const res = await api.init(payload);
|
||||||
entry.finish(true, outputOf(res));
|
entry.finish(true, outputOf(res));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
entry.finish(false, e.detail || e.message);
|
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 {
|
} finally {
|
||||||
submitBtn.disabled = false;
|
submitBtn.disabled = false;
|
||||||
submitBtn.textContent = "Create app";
|
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 ────────────────────────────────────────────────────────────────
|
// ─── Wiring ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
$("#brandMark").append(icon("layers"));
|
$("#brandMark").append(icon("layers"));
|
||||||
|
|
@ -2170,6 +2825,8 @@ $("#newIcon").append(icon("plus"));
|
||||||
$("#activityClose").append(icon("x"));
|
$("#activityClose").append(icon("x"));
|
||||||
|
|
||||||
$("#newAppBtn").onclick = () => newApp.open();
|
$("#newAppBtn").onclick = () => newApp.open();
|
||||||
|
$("#settingsBtn").append(icon("settings"));
|
||||||
|
$("#settingsBtn").onclick = () => settings.open();
|
||||||
$("#syncBtn").onclick = () => (session.expired ? location.reload() : poller.now());
|
$("#syncBtn").onclick = () => (session.expired ? location.reload() : poller.now());
|
||||||
$("#reloginBtn").onclick = () => location.reload();
|
$("#reloginBtn").onclick = () => location.reload();
|
||||||
$("#activityBtn").onclick = () => activity.toggle();
|
$("#activityBtn").onclick = () => activity.toggle();
|
||||||
|
|
@ -2211,6 +2868,7 @@ setInterval(() => {
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
newApp.init();
|
newApp.init();
|
||||||
|
settings.init();
|
||||||
renderFilters();
|
renderFilters();
|
||||||
renderSync();
|
renderSync();
|
||||||
poller.schedule(0);
|
poller.schedule(0);
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,14 @@
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
@ -22,12 +26,38 @@ FRONTEND_DIR = os.environ.get(
|
||||||
os.path.join(os.path.dirname(os.path.abspath(__file__)), "frontend"),
|
os.path.join(os.path.dirname(os.path.abspath(__file__)), "frontend"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Optional Forgejo instance (set from panel.nix). The API URL may be an internal
|
||||||
|
# address; the public URL is what repositories are cloned from and linked to.
|
||||||
|
FORGEJO_URL = os.environ.get("PANEL_FORGEJO_URL", "").rstrip("/")
|
||||||
|
FORGEJO_API_URL = (os.environ.get("PANEL_FORGEJO_API_URL", "") or FORGEJO_URL).rstrip("/")
|
||||||
|
FORGEJO_SSH_URL = os.environ.get("PANEL_FORGEJO_SSH_URL", "").rstrip("/")
|
||||||
|
FORGEJO_HOST = urlparse(FORGEJO_URL).hostname or ""
|
||||||
|
|
||||||
|
PANEL_STATE_DIR = os.path.join(BASE_DIR, "state", "panel")
|
||||||
|
FORGEJO_TOKEN_FILE = os.path.join(PANEL_STATE_DIR, "forgejo-token")
|
||||||
|
SSH_DIR = os.path.join(PANEL_STATE_DIR, "ssh")
|
||||||
|
SSH_KEY = os.path.join(SSH_DIR, "id_ed25519")
|
||||||
|
ENV_DIR = os.path.join(BASE_DIR, "state", "env")
|
||||||
|
|
||||||
COMPOSE_FILENAMES = ["compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml"]
|
COMPOSE_FILENAMES = ["compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml"]
|
||||||
GIT_TIMEOUT = 300
|
GIT_TIMEOUT = 300
|
||||||
# Manifest values are written into a file that panelctl sources with bash, so
|
# Manifest values are written into a file that panelctl sources with bash, so
|
||||||
# they must not contain anything that is special inside double quotes.
|
# they must not contain anything that is special inside double quotes.
|
||||||
REPO_URL_RE = re.compile(r"^https?://[^\s\"'`$\\]+$")
|
_URL_CHARS = r"[^\s\"'`$\\]"
|
||||||
|
REPO_URL_RE = re.compile(
|
||||||
|
rf"^(?:https?://{_URL_CHARS}+" # https://host/owner/repo.git
|
||||||
|
rf"|ssh://{_URL_CHARS}+" # ssh://git@host:port/owner/repo.git
|
||||||
|
rf"|[A-Za-z0-9._-]+@[A-Za-z0-9.-]+:{_URL_CHARS}+)$" # git@host:owner/repo.git
|
||||||
|
)
|
||||||
BRANCH_RE = re.compile(r"^[A-Za-z0-9._/][A-Za-z0-9._/-]*$")
|
BRANCH_RE = re.compile(r"^[A-Za-z0-9._/][A-Za-z0-9._/-]*$")
|
||||||
|
FORGEJO_REPO_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$")
|
||||||
|
|
||||||
|
ENV_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||||
|
# Variables are set on the compose process itself, so anything that changes how
|
||||||
|
# podman/compose run (or where they look for state) is off limits.
|
||||||
|
RESERVED_ENV = {"PATH", "HOME", "USER", "LOGNAME", "SHELL", "TMPDIR", "PWD", "OLDPWD", "IFS", "TERM"}
|
||||||
|
RESERVED_ENV_PREFIXES = ("XDG_", "DBUS_", "DOCKER_", "CONTAINER_", "CONTAINERS_", "COMPOSE_",
|
||||||
|
"PODMAN_", "BUILDAH_", "LD_", "BASH_")
|
||||||
|
|
||||||
|
|
||||||
def is_safe_name(name):
|
def is_safe_name(name):
|
||||||
|
|
@ -67,8 +97,9 @@ def busy_snapshot():
|
||||||
|
|
||||||
|
|
||||||
def redact_credentials(text, replacement="***@"):
|
def redact_credentials(text, replacement="***@"):
|
||||||
"""Hide user:token@ credentials embedded in URLs."""
|
"""Hide user:token@ credentials embedded in http(s) URLs.
|
||||||
return re.sub(r"([a-zA-Z][a-zA-Z0-9+.-]*://)[^/@\s]+@", r"\1" + replacement, text or "")
|
(ssh://git@host is a username, not a secret, and must be kept.)"""
|
||||||
|
return re.sub(r"(https?://)[^/@\s]+@", r"\1" + replacement, text or "")
|
||||||
|
|
||||||
|
|
||||||
def last_line(text):
|
def last_line(text):
|
||||||
|
|
@ -76,17 +107,74 @@ def last_line(text):
|
||||||
return lines[-1] if lines else ""
|
return lines[-1] if lines else ""
|
||||||
|
|
||||||
|
|
||||||
|
def git_error(stderr):
|
||||||
|
"""The informative line of a git failure (git ends with generic advice)."""
|
||||||
|
lines = [line.strip() for line in (stderr or "").splitlines() if line.strip()]
|
||||||
|
for line in lines:
|
||||||
|
if re.match(r"^(ssh|fatal|error|remote):", line, re.I) and "could not read from remote" not in line.lower():
|
||||||
|
return re.sub(r"^fatal:\s*", "", line)
|
||||||
|
return last_line(stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def write_private_file(path, content):
|
||||||
|
"""Atomically write a file only the service user can read."""
|
||||||
|
os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True)
|
||||||
|
tmp = f"{path}.tmp"
|
||||||
|
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(content)
|
||||||
|
os.replace(tmp, path)
|
||||||
|
|
||||||
|
|
||||||
|
# ── SSH deploy key ──
|
||||||
|
# One key pair for the panel; add its public half as a (read-only) deploy key
|
||||||
|
# to repositories cloned over SSH.
|
||||||
|
|
||||||
|
def ssh_public_key(create=True):
|
||||||
|
pub = SSH_KEY + ".pub"
|
||||||
|
if not os.path.isfile(pub) and create:
|
||||||
|
keygen = shutil.which("ssh-keygen")
|
||||||
|
if not keygen:
|
||||||
|
return None
|
||||||
|
os.makedirs(SSH_DIR, mode=0o700, exist_ok=True)
|
||||||
|
subprocess.run(
|
||||||
|
[keygen, "-t", "ed25519", "-N", "", "-q", "-C", f"panel@{socket.gethostname()}", "-f", SSH_KEY],
|
||||||
|
capture_output=True, check=False, timeout=30,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with open(pub, "r", encoding="utf-8") as fh:
|
||||||
|
return fh.read().strip()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_ssh_url(url):
|
||||||
|
return not re.match(r"^https?://", url or "")
|
||||||
|
|
||||||
|
|
||||||
# ── Git helpers ──
|
# ── Git helpers ──
|
||||||
|
|
||||||
|
def git_env():
|
||||||
|
# Never block on an interactive credential prompt.
|
||||||
|
env = dict(os.environ, GIT_TERMINAL_PROMPT="0")
|
||||||
|
if os.path.isfile(SSH_KEY):
|
||||||
|
env["GIT_SSH_COMMAND"] = " ".join([
|
||||||
|
"ssh", "-i", shlex.quote(SSH_KEY),
|
||||||
|
"-o", "IdentitiesOnly=yes",
|
||||||
|
"-o", "BatchMode=yes",
|
||||||
|
"-o", "StrictHostKeyChecking=accept-new",
|
||||||
|
"-o", "UserKnownHostsFile=" + shlex.quote(os.path.join(SSH_DIR, "known_hosts")),
|
||||||
|
])
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
def run_git(args, cwd=None, timeout=GIT_TIMEOUT):
|
def run_git(args, cwd=None, timeout=GIT_TIMEOUT):
|
||||||
git_bin = shutil.which("git")
|
git_bin = shutil.which("git")
|
||||||
if not git_bin:
|
if not git_bin:
|
||||||
return {"ok": False, "stdout": "", "stderr": "git is not installed or not in PATH"}
|
return {"ok": False, "stdout": "", "stderr": "git is not installed or not in PATH"}
|
||||||
cmd = [git_bin] + (["-C", cwd] if cwd else []) + args
|
cmd = [git_bin] + (["-C", cwd] if cwd else []) + args
|
||||||
# Never block on an interactive credential prompt.
|
|
||||||
env = dict(os.environ, GIT_TERMINAL_PROMPT="0")
|
|
||||||
try:
|
try:
|
||||||
proc = subprocess.run(cmd, capture_output=True, text=True, env=env, timeout=timeout)
|
proc = subprocess.run(cmd, capture_output=True, text=True, env=git_env(), timeout=timeout)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
return {"ok": False, "stdout": "", "stderr": f"git {args[0]} timed out after {timeout}s"}
|
return {"ok": False, "stdout": "", "stderr": f"git {args[0]} timed out after {timeout}s"}
|
||||||
return {
|
return {
|
||||||
|
|
@ -98,14 +186,145 @@ def run_git(args, cwd=None, timeout=GIT_TIMEOUT):
|
||||||
|
|
||||||
def clone_repo(url, branch, target_dir, token=""):
|
def clone_repo(url, branch, target_dir, token=""):
|
||||||
auth_url = url
|
auth_url = url
|
||||||
if token:
|
if token and not is_ssh_url(url):
|
||||||
auth_url = url.replace("://", f"://{quote(token, safe='')}@", 1)
|
auth_url = url.replace("://", f"://{quote(token, safe='')}@", 1)
|
||||||
|
elif is_ssh_url(url):
|
||||||
|
ssh_public_key() # make sure the deploy key exists before the first clone
|
||||||
args = ["clone"]
|
args = ["clone"]
|
||||||
if branch:
|
if branch:
|
||||||
args += ["--branch", branch]
|
args += ["--branch", branch]
|
||||||
return run_git(args + ["--", auth_url, target_dir])
|
return run_git(args + ["--", auth_url, target_dir])
|
||||||
|
|
||||||
|
|
||||||
|
def repo_host_and_path(url):
|
||||||
|
url = redact_credentials(url or "", "")
|
||||||
|
m = (re.match(r"^https?://([^/:]+)(?::\d+)?/(.+?)(?:\.git)?/?$", url)
|
||||||
|
or re.match(r"^ssh://(?:[^@/]+@)?([^/:]+)(?::\d+)?/(.+?)(?:\.git)?/?$", url)
|
||||||
|
or re.match(r"^(?:[^@/]+@)?([^/:]+):(?!/)(.+?)(?:\.git)?/?$", url))
|
||||||
|
return (m.group(1), m.group(2)) if m else (None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def repo_provider(url):
|
||||||
|
host, _ = repo_host_and_path(url)
|
||||||
|
if host and FORGEJO_HOST and host == FORGEJO_HOST:
|
||||||
|
return "forgejo"
|
||||||
|
if host in ("github.com", "www.github.com"):
|
||||||
|
return "github"
|
||||||
|
return "git"
|
||||||
|
|
||||||
|
|
||||||
|
def repo_web_url(url):
|
||||||
|
"""Browser URL of a repository, for commit / compare links."""
|
||||||
|
host, path = repo_host_and_path(url)
|
||||||
|
if not host:
|
||||||
|
return ""
|
||||||
|
if FORGEJO_HOST and host == FORGEJO_HOST:
|
||||||
|
return f"{FORGEJO_URL}/{path}"
|
||||||
|
m = re.match(r"^(https?)://", url or "")
|
||||||
|
return f"{m.group(1) if m else 'https'}://{host}/{path}"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Forgejo ──
|
||||||
|
|
||||||
|
class ForgejoError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def forgejo_token():
|
||||||
|
try:
|
||||||
|
with open(FORGEJO_TOKEN_FILE, "r", encoding="utf-8") as fh:
|
||||||
|
return fh.read().strip()
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def forgejo_api(path, token=None, timeout=10):
|
||||||
|
if not FORGEJO_API_URL:
|
||||||
|
raise ForgejoError("no Forgejo instance is configured")
|
||||||
|
token = forgejo_token() if token is None else token
|
||||||
|
req = urllib.request.Request(FORGEJO_API_URL + path, headers={"Accept": "application/json"})
|
||||||
|
if token:
|
||||||
|
req.add_header("Authorization", f"token {token}")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as res:
|
||||||
|
return json.loads(res.read().decode("utf-8") or "null")
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
if exc.code in (401, 403):
|
||||||
|
raise ForgejoError("Forgejo rejected the token") from exc
|
||||||
|
if exc.code == 404:
|
||||||
|
raise ForgejoError("not found on Forgejo (or no access)") from exc
|
||||||
|
raise ForgejoError(f"Forgejo answered HTTP {exc.code}") from exc
|
||||||
|
except (urllib.error.URLError, TimeoutError, ValueError) as exc:
|
||||||
|
raise ForgejoError(f"can't reach Forgejo: {getattr(exc, 'reason', exc)}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def is_forgejo_https_url(url):
|
||||||
|
return bool(FORGEJO_URL) and not is_ssh_url(url) and repo_provider(url) == "forgejo"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Environment variables ──
|
||||||
|
# Stored per app in state/env/<app>.env as KEY=VALUE lines (0600). panelctl
|
||||||
|
# passes them to every compose command, so they work for ${VAR} interpolation
|
||||||
|
# and — unless disabled — are injected into every service.
|
||||||
|
|
||||||
|
def env_file_path(name):
|
||||||
|
return os.path.join(ENV_DIR, f"{name}.env")
|
||||||
|
|
||||||
|
|
||||||
|
def read_app_env(name):
|
||||||
|
items = []
|
||||||
|
try:
|
||||||
|
with open(env_file_path(name), "r", encoding="utf-8") as fh:
|
||||||
|
lines = fh.read().splitlines()
|
||||||
|
except OSError:
|
||||||
|
return items
|
||||||
|
for line in lines:
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
items.append({"key": key, "value": value})
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def validate_env(items):
|
||||||
|
if items is None:
|
||||||
|
return []
|
||||||
|
if not isinstance(items, list):
|
||||||
|
raise ValueError("env must be a list of {key, value}")
|
||||||
|
seen = set()
|
||||||
|
out = []
|
||||||
|
for item in items:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
raise ValueError("env must be a list of {key, value}")
|
||||||
|
key = str(item.get("key", "")).strip()
|
||||||
|
value = item.get("value", "")
|
||||||
|
value = "" if value is None else str(value)
|
||||||
|
if not key and not value:
|
||||||
|
continue
|
||||||
|
if not ENV_KEY_RE.match(key):
|
||||||
|
raise ValueError(f"'{key}' is not a valid variable name (letters, digits and _, not starting with a digit)")
|
||||||
|
if key in RESERVED_ENV or key.startswith(RESERVED_ENV_PREFIXES):
|
||||||
|
raise ValueError(f"'{key}' is reserved because it would change how podman/compose run")
|
||||||
|
if key in seen:
|
||||||
|
raise ValueError(f"'{key}' is set more than once")
|
||||||
|
if any(c in value for c in "\n\r\0"):
|
||||||
|
raise ValueError(f"the value of '{key}' must be a single line")
|
||||||
|
seen.add(key)
|
||||||
|
out.append({"key": key, "value": value})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def write_app_env(name, items):
|
||||||
|
path = env_file_path(name)
|
||||||
|
if not items:
|
||||||
|
try:
|
||||||
|
os.remove(path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
write_private_file(path, "".join(f"{i['key']}={i['value']}\n" for i in items))
|
||||||
|
|
||||||
|
|
||||||
def repo_commit(repo_dir, ref="HEAD"):
|
def repo_commit(repo_dir, ref="HEAD"):
|
||||||
result = run_git(["log", "-1", "--format=%H%x1f%s%x1f%an%x1f%ct", ref], cwd=repo_dir, timeout=15)
|
result = run_git(["log", "-1", "--format=%H%x1f%s%x1f%an%x1f%ct", ref], cwd=repo_dir, timeout=15)
|
||||||
if not result["ok"] or not result["stdout"]:
|
if not result["ok"] or not result["stdout"]:
|
||||||
|
|
@ -200,13 +419,18 @@ def load_app_summaries():
|
||||||
env = parse_env_blob(fh.read())
|
env = parse_env_blob(fh.read())
|
||||||
except OSError:
|
except OSError:
|
||||||
continue
|
continue
|
||||||
|
repo_url = redact_credentials(env.get("APP_REPO_URL", ""), "")
|
||||||
apps.append({
|
apps.append({
|
||||||
"name": name,
|
"name": name,
|
||||||
"routes": manifest_routes(env),
|
"routes": manifest_routes(env),
|
||||||
"auth": env.get("APP_AUTH_PROTECTED", "true") == "true",
|
"auth": env.get("APP_AUTH_PROTECTED", "true") == "true",
|
||||||
"compose_file": env.get("APP_COMPOSE_FILE", ""),
|
"compose_file": env.get("APP_COMPOSE_FILE", ""),
|
||||||
"repo_url": redact_credentials(env.get("APP_REPO_URL", ""), ""),
|
"repo_url": repo_url,
|
||||||
"repo_branch": env.get("APP_REPO_BRANCH", ""),
|
"repo_branch": env.get("APP_REPO_BRANCH", ""),
|
||||||
|
"repo_provider": repo_provider(repo_url) if repo_url else "",
|
||||||
|
"repo_web_url": repo_web_url(repo_url) if repo_url else "",
|
||||||
|
"env_count": len(read_app_env(name)),
|
||||||
|
"env_inject": env.get("APP_ENV_INJECT", "true") != "false",
|
||||||
})
|
})
|
||||||
return apps
|
return apps
|
||||||
|
|
||||||
|
|
@ -456,6 +680,76 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
self._json(200, {"ok": True, "service": "panel-api"})
|
self._json(200, {"ok": True, "service": "panel-api"})
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# /integrations — Forgejo connection and the panel's SSH deploy key
|
||||||
|
if path == "/integrations":
|
||||||
|
token = forgejo_token()
|
||||||
|
forgejo = {
|
||||||
|
"configured": bool(FORGEJO_URL),
|
||||||
|
"url": FORGEJO_URL,
|
||||||
|
"ssh_url": FORGEJO_SSH_URL,
|
||||||
|
"has_token": bool(token),
|
||||||
|
"user": None,
|
||||||
|
}
|
||||||
|
if FORGEJO_URL and token:
|
||||||
|
try:
|
||||||
|
forgejo["user"] = (forgejo_api("/api/v1/user", timeout=5) or {}).get("login")
|
||||||
|
except ForgejoError as exc:
|
||||||
|
forgejo["error"] = str(exc)
|
||||||
|
self._json(200, {"ok": True, "forgejo": forgejo, "ssh": {"public_key": ssh_public_key()}})
|
||||||
|
return
|
||||||
|
|
||||||
|
# /forgejo/repos?q= — repositories visible to the stored token (public ones without)
|
||||||
|
if path == "/forgejo/repos":
|
||||||
|
q = query.get("q", [""])[0].strip()
|
||||||
|
try:
|
||||||
|
data = forgejo_api(f"/api/v1/repos/search?q={quote(q)}&limit=50&sort=updated&order=desc")
|
||||||
|
except ForgejoError as exc:
|
||||||
|
self._json(502, {"ok": False, "error": str(exc)})
|
||||||
|
return
|
||||||
|
repos = [{
|
||||||
|
"full_name": r.get("full_name", ""),
|
||||||
|
"description": r.get("description", ""),
|
||||||
|
"private": bool(r.get("private")),
|
||||||
|
"empty": bool(r.get("empty")),
|
||||||
|
"archived": bool(r.get("archived")),
|
||||||
|
"default_branch": r.get("default_branch", ""),
|
||||||
|
"clone_url": r.get("clone_url", ""),
|
||||||
|
"ssh_url": r.get("ssh_url", ""),
|
||||||
|
"html_url": r.get("html_url", ""),
|
||||||
|
"updated_at": r.get("updated_at", ""),
|
||||||
|
} for r in (data or {}).get("data", [])]
|
||||||
|
self._json(200, {"ok": True, "repos": repos, "authenticated": bool(forgejo_token())})
|
||||||
|
return
|
||||||
|
|
||||||
|
# /forgejo/branches?repo=owner/name
|
||||||
|
if path == "/forgejo/branches":
|
||||||
|
repo = query.get("repo", [""])[0].strip()
|
||||||
|
if not FORGEJO_REPO_RE.match(repo):
|
||||||
|
self._json(400, {"ok": False, "error": "repo must look like owner/name"})
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
data = forgejo_api(f"/api/v1/repos/{repo}/branches?limit=100")
|
||||||
|
except ForgejoError as exc:
|
||||||
|
self._json(502, {"ok": False, "error": str(exc)})
|
||||||
|
return
|
||||||
|
self._json(200, {"ok": True, "branches": [b.get("name", "") for b in (data or [])]})
|
||||||
|
return
|
||||||
|
|
||||||
|
# /apps/<name>/env — environment variables used when deploying
|
||||||
|
if len(parts) == 3 and parts[0] == "apps" and parts[2] == "env":
|
||||||
|
name = parts[1]
|
||||||
|
app, err = read_app_info(name)
|
||||||
|
if err is not None:
|
||||||
|
self._json(404, err)
|
||||||
|
return
|
||||||
|
self._json(200, {
|
||||||
|
"ok": True,
|
||||||
|
"name": name,
|
||||||
|
"vars": read_app_env(name),
|
||||||
|
"inject": app.get("APP_ENV_INJECT", "true") != "false",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
# /status — every app with routes, container status and running operation.
|
# /status — every app with routes, container status and running operation.
|
||||||
# This is what the UI polls, so it is one request regardless of app count.
|
# This is what the UI polls, so it is one request regardless of app count.
|
||||||
if path == "/status":
|
if path == "/status":
|
||||||
|
|
@ -625,9 +919,14 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"name": name,
|
"name": name,
|
||||||
"url": redact_credentials(repo_url, ""),
|
"url": redact_credentials(repo_url, ""),
|
||||||
|
"web_url": repo_web_url(repo_url),
|
||||||
|
"provider": repo_provider(repo_url),
|
||||||
|
"ssh": is_ssh_url(repo_url),
|
||||||
"branch": branch,
|
"branch": branch,
|
||||||
"cloned": os.path.isdir(os.path.join(repo_dir, ".git")),
|
"cloned": os.path.isdir(os.path.join(repo_dir, ".git")),
|
||||||
}
|
}
|
||||||
|
if info["ssh"]:
|
||||||
|
info["public_key"] = ssh_public_key()
|
||||||
if info["cloned"]:
|
if info["cloned"]:
|
||||||
info["commit"] = repo_commit(repo_dir)
|
info["commit"] = repo_commit(repo_dir)
|
||||||
status = run_git(["status", "--porcelain", "--untracked-files=no"], cwd=repo_dir, timeout=15)
|
status = run_git(["status", "--porcelain", "--untracked-files=no"], cwd=repo_dir, timeout=15)
|
||||||
|
|
@ -636,7 +935,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
ref = branch or repo_current_branch(repo_dir)
|
ref = branch or repo_current_branch(repo_dir)
|
||||||
fetched = run_git(["fetch", "--quiet", "origin", ref], cwd=repo_dir)
|
fetched = run_git(["fetch", "--quiet", "origin", ref], cwd=repo_dir)
|
||||||
if not fetched["ok"]:
|
if not fetched["ok"]:
|
||||||
info["fetch_error"] = last_line(fetched["stderr"]) or "git fetch failed"
|
info["fetch_error"] = git_error(fetched["stderr"]) or "git fetch failed"
|
||||||
else:
|
else:
|
||||||
info["remote"] = repo_commit(repo_dir, "FETCH_HEAD")
|
info["remote"] = repo_commit(repo_dir, "FETCH_HEAD")
|
||||||
count = run_git(["rev-list", "--count", "HEAD..FETCH_HEAD"], cwd=repo_dir, timeout=15)
|
count = run_git(["rev-list", "--count", "HEAD..FETCH_HEAD"], cwd=repo_dir, timeout=15)
|
||||||
|
|
@ -921,21 +1220,33 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
self._json(400, {"ok": False, "error": f"invalid payload: {exc}"})
|
self._json(400, {"ok": False, "error": f"invalid payload: {exc}"})
|
||||||
return
|
return
|
||||||
|
|
||||||
if source_type not in ["default", "raw", "github"]:
|
if source_type == "github": # older clients
|
||||||
|
source_type = "git"
|
||||||
|
if source_type not in ["default", "raw", "git"]:
|
||||||
self._json(400, {"ok": False, "error": "invalid source_type"})
|
self._json(400, {"ok": False, "error": "invalid source_type"})
|
||||||
return
|
return
|
||||||
|
|
||||||
# Validate git parameters before creating anything.
|
# Validate everything before creating anything.
|
||||||
if source_type == "github":
|
try:
|
||||||
repo_url = str(payload.get("github_url", "")).strip()
|
env_items = validate_env(payload.get("env"))
|
||||||
branch = str(payload.get("github_branch", "")).strip()
|
except ValueError as exc:
|
||||||
token = str(payload.get("github_pat", "")).strip()
|
self._json(400, {"ok": False, "error": str(exc)})
|
||||||
if not REPO_URL_RE.match(repo_url):
|
return
|
||||||
self._json(400, {"ok": False, "error": "repository URL must be a plain http(s) URL"})
|
env_inject = payload.get("env_inject", True) is not False
|
||||||
|
|
||||||
|
if source_type == "git":
|
||||||
|
repo_url = str(payload.get("repo_url") or payload.get("github_url") or "").strip()
|
||||||
|
branch = str(payload.get("repo_branch") or payload.get("github_branch") or "").strip()
|
||||||
|
token = str(payload.get("repo_token") or payload.get("github_pat") or "").strip()
|
||||||
|
if not REPO_URL_RE.match(repo_url) or repo_url.startswith("-"):
|
||||||
|
self._json(400, {"ok": False, "error": "repository URL must be an https://, ssh:// or git@host:owner/repo URL"})
|
||||||
return
|
return
|
||||||
if branch and not BRANCH_RE.match(branch):
|
if branch and not BRANCH_RE.match(branch):
|
||||||
self._json(400, {"ok": False, "error": f"invalid branch name '{branch}'"})
|
self._json(400, {"ok": False, "error": f"invalid branch name '{branch}'"})
|
||||||
return
|
return
|
||||||
|
# Clone a Forgejo repository with the panel's stored token.
|
||||||
|
if not token and payload.get("use_forgejo_token") and is_forgejo_https_url(repo_url):
|
||||||
|
token = forgejo_token()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = run_panelctl(["init", name, routes_str, auth])
|
result = run_panelctl(["init", name, routes_str, auth])
|
||||||
|
|
@ -960,9 +1271,11 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
self._json(500, {"ok": False, "error": f"failed to write compose: {exc}"})
|
self._json(500, {"ok": False, "error": f"failed to write compose: {exc}"})
|
||||||
return
|
return
|
||||||
|
|
||||||
elif source_type == "github":
|
summary = "initialized successfully"
|
||||||
# Any http(s) git host works (GitHub, Forgejo, ...). A token is
|
if source_type == "git":
|
||||||
# embedded in the clone URL, so later syncs reuse it from .git/config.
|
# Any git host works (Forgejo, GitHub, ...), over https or ssh.
|
||||||
|
# An https token is embedded in the clone URL, so later syncs
|
||||||
|
# reuse it from .git/config; ssh uses the panel's deploy key.
|
||||||
target_dir = os.path.join(app["APP_STACK_DIR"], "repo")
|
target_dir = os.path.join(app["APP_STACK_DIR"], "repo")
|
||||||
if os.path.exists(target_dir):
|
if os.path.exists(target_dir):
|
||||||
shutil.rmtree(target_dir)
|
shutil.rmtree(target_dir)
|
||||||
|
|
@ -972,7 +1285,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
run_panelctl(["remove", name])
|
run_panelctl(["remove", name])
|
||||||
self._json(400, {
|
self._json(400, {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"error": f"git clone failed: {last_line(cloned['stderr'])}",
|
"error": f"git clone failed: {git_error(cloned['stderr'])}",
|
||||||
"stderr": cloned["stderr"],
|
"stderr": cloned["stderr"],
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|
@ -997,19 +1310,78 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
|
|
||||||
commit = repo_commit(target_dir)
|
commit = repo_commit(target_dir)
|
||||||
summary = f"cloned {branch} at {commit['short']}: {commit['subject']}" if commit else "cloned"
|
summary = f"cloned {branch} at {commit['short']}: {commit['subject']}" if commit else "cloned"
|
||||||
self._json(200, {"ok": True, "code": 0, "stdout": summary})
|
|
||||||
return
|
|
||||||
|
|
||||||
self._json(200, {"ok": True, "code": 0, "stdout": "initialized successfully"})
|
if env_items or not env_inject:
|
||||||
|
write_app_env(name, env_items)
|
||||||
|
update_manifest(name, {"APP_ENV_INJECT": "true" if env_inject else "false"})
|
||||||
|
summary += f"\n{len(env_items)} environment variable(s) set"
|
||||||
|
|
||||||
|
self._json(200, {"ok": True, "code": 0, "stdout": summary})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
run_panelctl(["remove", name])
|
run_panelctl(["remove", name])
|
||||||
self._json(500, {"ok": False, "error": f"init failed: {exc}"})
|
self._json(500, {"ok": False, "error": f"init failed: {exc}"})
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# POST /integrations/forgejo {"token": "..."} — verify and store ("" clears it)
|
||||||
|
if path == "/integrations/forgejo":
|
||||||
|
if not FORGEJO_URL:
|
||||||
|
self._json(400, {"ok": False, "error": "no Forgejo instance is configured (PANEL_FORGEJO_URL)"})
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
token = str(self._read_json().get("token", "")).strip()
|
||||||
|
except Exception as exc:
|
||||||
|
self._json(400, {"ok": False, "error": f"invalid payload: {exc}"})
|
||||||
|
return
|
||||||
|
if not token:
|
||||||
|
try:
|
||||||
|
os.remove(FORGEJO_TOKEN_FILE)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
self._json(200, {"ok": True, "has_token": False})
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
user = (forgejo_api("/api/v1/user", token=token) or {}).get("login")
|
||||||
|
except ForgejoError as exc:
|
||||||
|
self._json(400, {"ok": False, "error": str(exc)})
|
||||||
|
return
|
||||||
|
write_private_file(FORGEJO_TOKEN_FILE, token + "\n")
|
||||||
|
self._json(200, {"ok": True, "has_token": True, "user": user})
|
||||||
|
return
|
||||||
|
|
||||||
if len(parts) >= 3 and parts[0] == "apps":
|
if len(parts) >= 3 and parts[0] == "apps":
|
||||||
name = parts[1]
|
name = parts[1]
|
||||||
action = parts[2]
|
action = parts[2]
|
||||||
|
|
||||||
|
# POST /apps/<name>/env — replace environment variables, optionally redeploy
|
||||||
|
if action == "env":
|
||||||
|
app, err = read_app_info(name)
|
||||||
|
if err is not None:
|
||||||
|
self._json(404, err)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
payload = self._read_json()
|
||||||
|
items = validate_env(payload.get("vars", []))
|
||||||
|
except ValueError as exc:
|
||||||
|
self._json(400, {"ok": False, "error": str(exc)})
|
||||||
|
return
|
||||||
|
inject = payload.get("inject", True) is not False
|
||||||
|
try:
|
||||||
|
write_app_env(name, items)
|
||||||
|
update_manifest(name, {"APP_ENV_INJECT": "true" if inject else "false"})
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
self._json(500, {"ok": False, "error": f"failed to save variables: {exc}"})
|
||||||
|
return
|
||||||
|
result = {"ok": True, "name": name, "count": len(items),
|
||||||
|
"stdout": f"saved {len(items)} environment variable(s)"}
|
||||||
|
if payload.get("deploy"):
|
||||||
|
deployed = run_panelctl(["deploy", name])
|
||||||
|
deployed["stdout"] = "\n".join(filter(None, [result["stdout"], deployed["stdout"]]))
|
||||||
|
deployed["count"] = len(items)
|
||||||
|
self._json(200 if deployed["ok"] else 400, deployed)
|
||||||
|
return
|
||||||
|
self._json(200, result)
|
||||||
|
return
|
||||||
|
|
||||||
# POST /apps/<name>/compose — save compose file
|
# POST /apps/<name>/compose — save compose file
|
||||||
if action == "compose":
|
if action == "compose":
|
||||||
app, err = read_app_info(name)
|
app, err = read_app_info(name)
|
||||||
|
|
@ -1141,7 +1513,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
if not fetched["ok"]:
|
if not fetched["ok"]:
|
||||||
self._json(400, {
|
self._json(400, {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"error": f"git fetch failed: {last_line(fetched['stderr'])}",
|
"error": f"git fetch failed: {git_error(fetched['stderr'])}",
|
||||||
"stderr": fetched["stderr"],
|
"stderr": fetched["stderr"],
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|
@ -1149,7 +1521,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
if not reset["ok"]:
|
if not reset["ok"]:
|
||||||
self._json(400, {
|
self._json(400, {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"error": f"git reset failed: {last_line(reset['stderr'])}",
|
"error": f"git reset failed: {git_error(reset['stderr'])}",
|
||||||
"stderr": reset["stderr"],
|
"stderr": reset["stderr"],
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|
@ -1162,7 +1534,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
if not cloned["ok"]:
|
if not cloned["ok"]:
|
||||||
self._json(400, {
|
self._json(400, {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"error": f"git clone failed: {last_line(cloned['stderr'])}",
|
"error": f"git clone failed: {git_error(cloned['stderr'])}",
|
||||||
"stderr": cloned["stderr"],
|
"stderr": cloned["stderr"],
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,15 @@ VOLUMES_DIR="${BASE_DIR}/volumes"
|
||||||
ROUTES_DIR="${BASE_DIR}/routes"
|
ROUTES_DIR="${BASE_DIR}/routes"
|
||||||
STATE_DIR="${BASE_DIR}/state"
|
STATE_DIR="${BASE_DIR}/state"
|
||||||
APPS_DIR="${STATE_DIR}/apps"
|
APPS_DIR="${STATE_DIR}/apps"
|
||||||
|
ENV_DIR="${STATE_DIR}/env"
|
||||||
BACKUPS_DIR="${BASE_DIR}/backups"
|
BACKUPS_DIR="${BASE_DIR}/backups"
|
||||||
|
|
||||||
|
# Set by load_app: compose file arguments, and the app's environment variables
|
||||||
|
# as KEY=VALUE words (passed to compose via env(1), never sourced).
|
||||||
|
COMPOSE_ARGS=()
|
||||||
|
APP_ENV_ARGS=()
|
||||||
|
APP_ENV_KEYS=()
|
||||||
|
|
||||||
FORWARD_AUTH_BLOCK=' forward_auth 127.0.0.1:9091 {
|
FORWARD_AUTH_BLOCK=' forward_auth 127.0.0.1:9091 {
|
||||||
uri /api/authz/forward-auth
|
uri /api/authz/forward-auth
|
||||||
copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
|
copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
|
||||||
|
|
@ -186,6 +193,75 @@ load_app() {
|
||||||
done
|
done
|
||||||
APP_ROUTES="${routes}"
|
APP_ROUTES="${routes}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
load_app_env "${name}"
|
||||||
|
}
|
||||||
|
|
||||||
|
app_env_file() {
|
||||||
|
echo "${ENV_DIR}/$1.env"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Generated compose override that passes the app's variables into every service.
|
||||||
|
app_env_override() {
|
||||||
|
echo "${STACKS_DIR}/$1/.panel-env.yaml"
|
||||||
|
}
|
||||||
|
|
||||||
|
load_app_env() {
|
||||||
|
local name="$1"
|
||||||
|
local file line key override
|
||||||
|
file="$(app_env_file "${name}")"
|
||||||
|
APP_ENV_ARGS=()
|
||||||
|
APP_ENV_KEYS=()
|
||||||
|
if [[ -f "${file}" ]]; then
|
||||||
|
while IFS= read -r line || [[ -n "${line}" ]]; do
|
||||||
|
[[ -z "${line}" || "${line}" == \#* || "${line}" != *=* ]] && continue
|
||||||
|
key="${line%%=*}"
|
||||||
|
[[ "${key}" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue
|
||||||
|
APP_ENV_ARGS+=("${line}")
|
||||||
|
APP_ENV_KEYS+=("${key}")
|
||||||
|
done <"${file}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
COMPOSE_ARGS=(-f "${APP_COMPOSE_FILE}")
|
||||||
|
override="$(app_env_override "${name}")"
|
||||||
|
if [[ -f "${override}" ]]; then
|
||||||
|
COMPOSE_ARGS+=(-f "${override}")
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# (Re)generate the env override before containers are created. Variables are
|
||||||
|
# always available for ${VAR} interpolation; with APP_ENV_INJECT (default true)
|
||||||
|
# every service also receives them. Bare keys make compose read the values from
|
||||||
|
# its own environment, so values never have to be quoted into YAML.
|
||||||
|
prepare_env_override() {
|
||||||
|
local name="$1"
|
||||||
|
local override services svc key tmp
|
||||||
|
override="$(app_env_override "${name}")"
|
||||||
|
|
||||||
|
if [[ ${#APP_ENV_KEYS[@]} -eq 0 || "${APP_ENV_INJECT:-true}" != "true" ]]; then
|
||||||
|
rm -f "${override}"
|
||||||
|
COMPOSE_ARGS=(-f "${APP_COMPOSE_FILE}")
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
services="$(run_compose -f "${APP_COMPOSE_FILE}" config --services 2>/dev/null)" \
|
||||||
|
|| fail "could not list compose services to pass environment variables (is the compose file valid?)"
|
||||||
|
|
||||||
|
tmp="$(mktemp)"
|
||||||
|
{
|
||||||
|
echo "# Generated by panelctl from the app's environment variables. Do not edit."
|
||||||
|
echo "services:"
|
||||||
|
while IFS= read -r svc; do
|
||||||
|
[[ "${svc}" =~ ^[A-Za-z0-9._-]+$ ]] || continue
|
||||||
|
printf ' "%s":\n environment:\n' "${svc}"
|
||||||
|
for key in "${APP_ENV_KEYS[@]}"; do
|
||||||
|
printf ' - %s\n' "${key}"
|
||||||
|
done
|
||||||
|
done <<<"${services}"
|
||||||
|
} >"${tmp}"
|
||||||
|
install -m 0640 "${tmp}" "${override}"
|
||||||
|
rm -f "${tmp}"
|
||||||
|
COMPOSE_ARGS=(-f "${APP_COMPOSE_FILE}" -f "${override}")
|
||||||
}
|
}
|
||||||
|
|
||||||
compose_command() {
|
compose_command() {
|
||||||
|
|
@ -252,11 +328,11 @@ run_compose() {
|
||||||
|
|
||||||
if [[ "${compose}" == *" compose" ]]; then
|
if [[ "${compose}" == *" compose" ]]; then
|
||||||
local podman_bin="${compose% compose}"
|
local podman_bin="${compose% compose}"
|
||||||
"${podman_bin}" compose "$@"
|
env "${APP_ENV_ARGS[@]}" "${podman_bin}" compose "$@"
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
"${compose}" "$@"
|
env "${APP_ENV_ARGS[@]}" "${compose}" "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
write_default_compose() {
|
write_default_compose() {
|
||||||
|
|
@ -404,11 +480,12 @@ cmd_deploy() {
|
||||||
log info "Starting deployment for app '${name}'"
|
log info "Starting deployment for app '${name}'"
|
||||||
|
|
||||||
cmd_render_route "${name}"
|
cmd_render_route "${name}"
|
||||||
|
prepare_env_override "${name}"
|
||||||
|
|
||||||
# Capture compose output so callers (the web UI) can show why a deploy failed,
|
# Capture compose output so callers (the web UI) can show why a deploy failed,
|
||||||
# and still forward it to the journal.
|
# and still forward it to the journal.
|
||||||
local output
|
local output
|
||||||
if ! output="$(run_compose -f "${APP_COMPOSE_FILE}" up -d --build --remove-orphans 2>&1)"; then
|
if ! output="$(run_compose "${COMPOSE_ARGS[@]}" up -d --build --remove-orphans 2>&1)"; then
|
||||||
printf '%s\n' "${output}" | systemd-cat -t panelctl -p err 2>/dev/null || true
|
printf '%s\n' "${output}" | systemd-cat -t panelctl -p err 2>/dev/null || true
|
||||||
printf '%s\n' "${output}" >&2
|
printf '%s\n' "${output}" >&2
|
||||||
log err "Deployment failed for app '${name}'"
|
log err "Deployment failed for app '${name}'"
|
||||||
|
|
@ -427,9 +504,10 @@ cmd_restart() {
|
||||||
|
|
||||||
log info "Restarting app '${name}'"
|
log info "Restarting app '${name}'"
|
||||||
|
|
||||||
run_compose -f "${APP_COMPOSE_FILE}" down --remove-orphans || fail "compose down failed"
|
run_compose "${COMPOSE_ARGS[@]}" down --remove-orphans || fail "compose down failed"
|
||||||
|
prepare_env_override "${name}"
|
||||||
|
|
||||||
if ! run_compose -f "${APP_COMPOSE_FILE}" up -d --build --remove-orphans 2>&1; then
|
if ! run_compose "${COMPOSE_ARGS[@]}" up -d --build --remove-orphans 2>&1; then
|
||||||
fail "compose up failed during restart"
|
fail "compose up failed during restart"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -441,7 +519,7 @@ cmd_stop() {
|
||||||
validate_name "${name}"
|
validate_name "${name}"
|
||||||
load_app "${name}"
|
load_app "${name}"
|
||||||
|
|
||||||
run_compose -f "${APP_COMPOSE_FILE}" down --remove-orphans || fail "compose down failed"
|
run_compose "${COMPOSE_ARGS[@]}" down --remove-orphans || fail "compose down failed"
|
||||||
log info "stopped app '${name}'"
|
log info "stopped app '${name}'"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -450,8 +528,8 @@ cmd_status() {
|
||||||
validate_name "${name}"
|
validate_name "${name}"
|
||||||
load_app "${name}"
|
load_app "${name}"
|
||||||
|
|
||||||
run_compose -f "${APP_COMPOSE_FILE}" ps --format json 2>/dev/null || \
|
run_compose "${COMPOSE_ARGS[@]}" ps --format json 2>/dev/null || \
|
||||||
run_compose -f "${APP_COMPOSE_FILE}" ps 2>/dev/null || \
|
run_compose "${COMPOSE_ARGS[@]}" ps 2>/dev/null || \
|
||||||
log info "no containers running"
|
log info "no containers running"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -474,7 +552,7 @@ cmd_logs() {
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
run_compose -f "${APP_COMPOSE_FILE}" logs --tail "${tail_lines}" 2>&1 || log info "no logs available"
|
run_compose "${COMPOSE_ARGS[@]}" logs --tail "${tail_lines}" 2>&1 || log info "no logs available"
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd_validate_compose() {
|
cmd_validate_compose() {
|
||||||
|
|
@ -482,11 +560,11 @@ cmd_validate_compose() {
|
||||||
validate_name "${name}"
|
validate_name "${name}"
|
||||||
load_app "${name}"
|
load_app "${name}"
|
||||||
|
|
||||||
if run_compose -f "${APP_COMPOSE_FILE}" config >/dev/null 2>&1; then
|
if run_compose "${COMPOSE_ARGS[@]}" config >/dev/null 2>&1; then
|
||||||
log info "compose file is valid"
|
log info "compose file is valid"
|
||||||
else
|
else
|
||||||
local output
|
local output
|
||||||
output="$(run_compose -f "${APP_COMPOSE_FILE}" config 2>&1 || true)"
|
output="$(run_compose "${COMPOSE_ARGS[@]}" config 2>&1 || true)"
|
||||||
fail "compose validation failed: ${output}"
|
fail "compose validation failed: ${output}"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
@ -497,7 +575,7 @@ cmd_remove() {
|
||||||
validate_name "${name}"
|
validate_name "${name}"
|
||||||
load_app "${name}"
|
load_app "${name}"
|
||||||
|
|
||||||
run_compose -f "${APP_COMPOSE_FILE}" down --remove-orphans 2>/dev/null || true
|
run_compose "${COMPOSE_ARGS[@]}" down --remove-orphans 2>/dev/null || true
|
||||||
|
|
||||||
# Remove this app's block from the aggregate routes file.
|
# Remove this app's block from the aggregate routes file.
|
||||||
local route_file
|
local route_file
|
||||||
|
|
@ -512,7 +590,7 @@ cmd_remove() {
|
||||||
routes_unlock
|
routes_unlock
|
||||||
fi
|
fi
|
||||||
|
|
||||||
rm -f "$(app_manifest "${name}")"
|
rm -f "$(app_manifest "${name}")" "$(app_env_file "${name}")"
|
||||||
rm -rf "${APP_STACK_DIR}"
|
rm -rf "${APP_STACK_DIR}"
|
||||||
|
|
||||||
if [[ "${keep_volumes}" != "--keep-volumes" ]]; then
|
if [[ "${keep_volumes}" != "--keep-volumes" ]]; then
|
||||||
|
|
@ -578,10 +656,10 @@ cmd_backup() {
|
||||||
|
|
||||||
# Stop containers before backup for consistency
|
# Stop containers before backup for consistency
|
||||||
local was_running=false
|
local was_running=false
|
||||||
if run_compose -f "${APP_COMPOSE_FILE}" ps --format json 2>/dev/null | grep -q '"running"' 2>/dev/null; then
|
if run_compose "${COMPOSE_ARGS[@]}" ps --format json 2>/dev/null | grep -q '"running"' 2>/dev/null; then
|
||||||
was_running=true
|
was_running=true
|
||||||
log info "stopping containers for consistent backup..."
|
log info "stopping containers for consistent backup..."
|
||||||
run_compose -f "${APP_COMPOSE_FILE}" down 2>/dev/null || true
|
run_compose "${COMPOSE_ARGS[@]}" down 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
(cd "${volume_dir}" && zip -r "${backup_file}" .) || fail "zip failed"
|
(cd "${volume_dir}" && zip -r "${backup_file}" .) || fail "zip failed"
|
||||||
|
|
@ -596,7 +674,7 @@ cmd_backup() {
|
||||||
# Restart if it was running
|
# Restart if it was running
|
||||||
if [[ "${was_running}" == "true" ]]; then
|
if [[ "${was_running}" == "true" ]]; then
|
||||||
log info "restarting containers after backup..."
|
log info "restarting containers after backup..."
|
||||||
run_compose -f "${APP_COMPOSE_FILE}" up -d 2>/dev/null || true
|
run_compose "${COMPOSE_ARGS[@]}" up -d 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local size
|
local size
|
||||||
|
|
@ -633,7 +711,7 @@ cmd_volume_clear() {
|
||||||
load_app "${name}"
|
load_app "${name}"
|
||||||
|
|
||||||
log info "clearing volume data for app '${name}'"
|
log info "clearing volume data for app '${name}'"
|
||||||
run_compose -f "${APP_COMPOSE_FILE}" down --remove-orphans 2>/dev/null || true
|
run_compose "${COMPOSE_ARGS[@]}" down --remove-orphans 2>/dev/null || true
|
||||||
|
|
||||||
local data_dir="${APP_VOLUME_DIR}/data"
|
local data_dir="${APP_VOLUME_DIR}/data"
|
||||||
if [[ -d "${data_dir}" ]]; then
|
if [[ -d "${data_dir}" ]]; then
|
||||||
|
|
@ -670,7 +748,7 @@ cmd_restore() {
|
||||||
|
|
||||||
# Stop containers before restore
|
# Stop containers before restore
|
||||||
log info "stopping containers for restore..."
|
log info "stopping containers for restore..."
|
||||||
run_compose -f "${APP_COMPOSE_FILE}" down 2>/dev/null || true
|
run_compose "${COMPOSE_ARGS[@]}" down 2>/dev/null || true
|
||||||
|
|
||||||
# Clear existing volume data and extract backup
|
# Clear existing volume data and extract backup
|
||||||
rm -rf "${volume_dir:?}"/*
|
rm -rf "${volume_dir:?}"/*
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue