Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c4dd6da3d | ||
|
|
5379f34a0c | ||
|
|
533a821087 | ||
|
|
7f07efe05d |
@@ -0,0 +1,13 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
paket
|
||||||
|
proxdeploy-*.tar.gz
|
||||||
|
cli/bin
|
||||||
|
test-results
|
||||||
|
playwright-report
|
||||||
|
coverage
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.tsbuildinfo
|
||||||
|
src/generated
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
# ── Datenbank ────────────────────────────────────────────────────────────────
|
# ── Datenbank ────────────────────────────────────────────────────────────────
|
||||||
DATABASE_URL=postgresql://proxdeploy:proxdeploy@localhost:5437/proxdeploy
|
DATABASE_URL=postgresql://proxdeploy:proxdeploy@localhost:5437/proxdeploy
|
||||||
|
# Nur Docker Compose: Passwort der mitgelieferten PostgreSQL (DATABASE_URL setzt Compose selbst).
|
||||||
|
POSTGRES_PASSWORT=
|
||||||
# Nur für Tests; wird vor jedem Test geleert.
|
# Nur für Tests; wird vor jedem Test geleert.
|
||||||
TEST_DATABASE_URL=postgresql://proxdeploy:proxdeploy@localhost:5437/proxdeploy_test
|
TEST_DATABASE_URL=postgresql://proxdeploy:proxdeploy@localhost:5437/proxdeploy
|
||||||
|
# Nur Docker Compose: Passwort der mitgelieferten PostgreSQL (DATABASE_URL setzt Compose selbst).
|
||||||
|
POSTGRES_PASSWORT=_test
|
||||||
|
|
||||||
# ── Geheimnisse ──────────────────────────────────────────────────────────────
|
# ── Geheimnisse ──────────────────────────────────────────────────────────────
|
||||||
# Schlüssel für AES-256-GCM (Proxmox-Token, SSH-Schlüssel, OIDC-Secret): genau 32 Byte, base64.
|
# Schlüssel für AES-256-GCM (Proxmox-Token, SSH-Schlüssel, OIDC-Secret): genau 32 Byte, base64.
|
||||||
|
|||||||
@@ -13,3 +13,5 @@ cli/bin/
|
|||||||
playwright-report/
|
playwright-report/
|
||||||
test-results/
|
test-results/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
paket/
|
||||||
|
proxdeploy-*.tar.gz
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ cli/
|
|||||||
catalog/
|
catalog/
|
||||||
test-results
|
test-results
|
||||||
public/brand
|
public/brand
|
||||||
|
paket
|
||||||
|
|||||||
@@ -17,6 +17,20 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
### Sicherheit
|
### Sicherheit
|
||||||
|
|
||||||
|
## [0.6.0] – 2026-10-12
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- Betrieb per Docker Compose (postgres, migrate, app, worker) mit einem Image für alles, non-root (#019)
|
||||||
|
- LXC-Installer `deploy/install-lxc.sh`: legt auf dem Proxmox-Host einen Debian-13-Container an, installiert Node.js und PostgreSQL, richtet systemd-Dienste ein; mit Update-Pfad (#019)
|
||||||
|
- Migrationen im Betrieb ohne Prisma-CLI, kompatibel zu Prisma (#019)
|
||||||
|
- Übersicht zeigt Nodes, CPUs und freien RAM je Server (#018)
|
||||||
|
- README mit Banner, Screenshots, Installation, ersten Schritten und Katalog-Anleitungen (#018)
|
||||||
|
- Marken-Dateien nach Vorgabe unter `public/brand/`: Symbol (dunkel/hell), kleines Icon, Avatar, Logo (dunkel/hell), Banner 1280×640, Favicons, Apple-Touch- und Manifest-Icons; erzeugt mit `pnpm marke` (#017)
|
||||||
|
- Web-App-Manifest; Favicon passt sich hellem und dunklem Browser an (#017)
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- Die Übersicht zeigte statt der letzten Deployments nur einen Platzhalter (#018)
|
||||||
|
|
||||||
## [0.5.0] – 2026-10-12
|
## [0.5.0] – 2026-10-12
|
||||||
|
|
||||||
### Hinzugefügt
|
### Hinzugefügt
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Zeitübersicht
|
## Zeitübersicht
|
||||||
|
|
||||||
**Gesamt:** 6 h 59 min (Sitzung 1–3)
|
**Gesamt:** 7 h 39 min (Sitzung 1–3)
|
||||||
|
|
||||||
Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeldungen ist enthalten.
|
Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeldungen ist enthalten.
|
||||||
|
|
||||||
@@ -27,6 +27,9 @@ Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeld
|
|||||||
| #014 Verlauf, Wiederholen, Aufräumen | 1 h 21 min |
|
| #014 Verlauf, Wiederholen, Aufräumen | 1 h 21 min |
|
||||||
| #015 Benutzer, Passwort, Audit-Log | 0 h 10 min |
|
| #015 Benutzer, Passwort, Audit-Log | 0 h 10 min |
|
||||||
| #016 OIDC | 0 h 08 min |
|
| #016 OIDC | 0 h 08 min |
|
||||||
|
| #017 Marken-Dateien | 0 h 17 min |
|
||||||
|
| #018 README | 0 h 02 min (parallel zu #019) |
|
||||||
|
| #019 Docker und LXC-Installer | 0 h 20 min |
|
||||||
|
|
||||||
| Meilenstein | Zeit |
|
| Meilenstein | Zeit |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -35,12 +38,13 @@ Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeld
|
|||||||
| v0.3.0 | 1 h 48 min | **abgeschlossen** (Abnahme #021 offen)
|
| v0.3.0 | 1 h 48 min | **abgeschlossen** (Abnahme #021 offen)
|
||||||
| v0.4.0 | 0 h 42 min | **abgeschlossen**
|
| v0.4.0 | 0 h 42 min | **abgeschlossen**
|
||||||
| v0.5.0 | 1 h 39 min | **abgeschlossen**
|
| v0.5.0 | 1 h 39 min | **abgeschlossen**
|
||||||
|
| v0.6.0 | 0 h 40 min | **abgeschlossen**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2026-10-12 · Sitzung 3 · 14:39–17:00
|
## 2026-10-12 · Sitzung 3 · 14:39–17:40
|
||||||
**Issue:** #013–#016
|
**Issue:** #013–#019
|
||||||
**Version:** 0.4.0, 0.5.0
|
**Version:** 0.4.0, 0.5.0, 0.6.0
|
||||||
|
|
||||||
### Erledigt
|
### Erledigt
|
||||||
- v0.3.0 freigegeben (Abnahme #021 bleibt offen, braucht eine freie IP)
|
- v0.3.0 freigegeben (Abnahme #021 bleibt offen, braucht eine freie IP)
|
||||||
@@ -55,6 +59,8 @@ Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeld
|
|||||||
- CLI: `history` und `cleanup`
|
- CLI: `history` und `cleanup`
|
||||||
- Gefunden: ssh2 erzeugt in ~0,4 % unlesbare ed25519-Schlüssel (war die Ursache des wackligen SSH-Tests) – behoben; „Einstellungen“ fehlte seit v0.3.0 in der Navigation
|
- Gefunden: ssh2 erzeugt in ~0,4 % unlesbare ed25519-Schlüssel (war die Ursache des wackligen SSH-Tests) – behoben; „Einstellungen“ fehlte seit v0.3.0 in der Navigation
|
||||||
- 308 Vitest, 26 Playwright-Abläufe grün
|
- 308 Vitest, 26 Playwright-Abläufe grün
|
||||||
|
- Etappe 6 (v0.6.0): Marken-Dateien per Skript (Texte als Pfade), README mit Banner und Screenshots, Docker Compose (lokal hochgefahren) und LXC-Installer – auf pve-demo in CT 109 installiert, darin Server angelegt, CLI-Login, Dry-Run und ein echter LXC (pd-abnahme, CT 110); Update-Pfad geprüft
|
||||||
|
- Gefunden: die Übersicht war seit Etappe 1 ein Platzhalter; opentype.js schreibt gelegentlich „NaN“ in Pfade
|
||||||
|
|
||||||
### Entscheidungen
|
### Entscheidungen
|
||||||
- **Anfragen als Map, Antworten typisiert:** Go-Structs aus oapi-codegen schickten leere Pflichtfelder und überschrieben Vorlagenwerte.
|
- **Anfragen als Map, Antworten typisiert:** Go-Structs aus oapi-codegen schickten leere Pflichtfelder und überschrieben Vorlagenwerte.
|
||||||
@@ -64,6 +70,7 @@ Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeld
|
|||||||
- **Aufräumen löscht nur, was die Beschreibung „(Auftrag <kurzId>)“ trägt** – deshalb setzt das Klonen die Beschreibung jetzt sofort; Templates nie.
|
- **Aufräumen löscht nur, was die Beschreibung „(Auftrag <kurzId>)“ trägt** – deshalb setzt das Klonen die Beschreibung jetzt sofort; Templates nie.
|
||||||
- **Sitzungen beenden über `Benutzer.sitzungenAb`** statt einer Sitzungstabelle.
|
- **Sitzungen beenden über `Benutzer.sitzungenAb`** statt einer Sitzungstabelle.
|
||||||
- **SSO übernimmt nie ein lokales Konto über den Namen**; die Rolle kommt bei jeder Anmeldung aus den Gruppen.
|
- **SSO übernimmt nie ein lokales Konto über den Namen**; die Rolle kommt bei jeder Anmeldung aus den Gruppen.
|
||||||
|
- **Migrationen im Betrieb ohne Prisma-CLI**, kompatibel zu `_prisma_migrations`; ein gemeinsames Laufzeit-Paket für Docker und LXC; der worker bekommt nur die Pakete, die er importiert (103 statt 741 MB).
|
||||||
- Gefunden durch Tests: SSE-Daten zeigten in einen wiederverwendeten Puffer (jetzt kopiert); mehrzeilige Proxmox-Parameter brachen die Dry-Run-Zeile (jetzt `⏎`).
|
- Gefunden durch Tests: SSE-Daten zeigten in einen wiederverwendeten Puffer (jetzt kopiert); mehrzeilige Proxmox-Parameter brachen die Dry-Run-Zeile (jetzt `⏎`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# ProxDeploy – ein Image für app, worker und migrate. Gebaut wird mit scripts/paket-bauen.sh,
|
||||||
|
# derselben Struktur wie im LXC-Installer.
|
||||||
|
#
|
||||||
|
# docker build --build-arg VERSION=0.6.0 -t proxdeploy:0.6.0 .
|
||||||
|
|
||||||
|
FROM node:24.21.0-trixie-slim AS bau
|
||||||
|
ENV PNPM_HOME=/pnpm PATH=/pnpm:$PATH CI=true
|
||||||
|
RUN corepack enable && corepack prepare pnpm@12.6.0 --activate
|
||||||
|
WORKDIR /quelle
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
|
COPY prisma ./prisma
|
||||||
|
COPY prisma.config.ts ./
|
||||||
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
|
||||||
|
COPY . .
|
||||||
|
ARG VERSION
|
||||||
|
# Ohne .git im Kontext gibt es keinen Commit; dann steht nur die Version da.
|
||||||
|
RUN scripts/paket-bauen.sh "${VERSION:-$(node -p 'require("./package.json").version')}"
|
||||||
|
|
||||||
|
FROM node:24.21.0-trixie-slim
|
||||||
|
LABEL org.opencontainers.image.title="ProxDeploy" \
|
||||||
|
org.opencontainers.image.description="VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI." \
|
||||||
|
org.opencontainers.image.source="https://git.wilde-server.de/florian.wilde/proxdeploy"
|
||||||
|
ENV NODE_ENV=production PORT=3000 HOSTNAME=0.0.0.0 NEXT_TELEMETRY_DISABLED=1
|
||||||
|
WORKDIR /opt/proxdeploy
|
||||||
|
COPY --from=bau --chown=root:root /quelle/paket ./
|
||||||
|
# Läuft als „node“ (UID 1000), nie als root; der Code gehört root und ist nicht beschreibbar.
|
||||||
|
USER node
|
||||||
|
WORKDIR /opt/proxdeploy/app
|
||||||
|
EXPOSE 3000
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
|
||||||
|
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/v1/gesundheit').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||||
|
# app; worker und migrate setzen ihr Kommando in docker-compose.yml.
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -1,28 +1,80 @@
|
|||||||
# ProxDeploy
|
<p align="center"><img src="public/brand/banner.svg" alt="ProxDeploy – VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI." width="100%"></p>
|
||||||
|
|
||||||
**VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI.**
|
**ProxDeploy** ist eine selbst gehostete Webanwendung mit einer CLI (`proxdeploy`). Damit wählt man
|
||||||
|
einen von mehreren Proxmox-VE-Servern (8.x/9.x, Einzelhost oder Cluster) und rollt darauf vollständig
|
||||||
|
unattended eine VM oder einen LXC-Container mit Debian oder Ubuntu aus – mit Profilen wie `base`,
|
||||||
|
`docker` oder `zabbix-agent`. Distributionen und Profile sind YAML-Dateien im Katalog, kein Code.
|
||||||
|
|
||||||
ProxDeploy ist eine selbst gehostete Webanwendung mit einer CLI (`proxdeploy`). Damit wählt man einen von
|
> ProxDeploy ist ein unabhängiges Werkzeug und stammt nicht von der Proxmox Server Solutions GmbH.
|
||||||
mehreren Proxmox-VE-Servern (8.x/9.x, Einzelhost oder Cluster) und rollt darauf vollständig unattended
|
|
||||||
eine VM oder einen LXC-Container mit Debian oder Ubuntu aus, inklusive Profilen wie `base`, `docker`
|
|
||||||
oder `zabbix-agent`. Weitere Distributionen und Profile sind YAML-Dateien im Katalog, kein Code.
|
|
||||||
|
|
||||||
> ProxDeploy ist ein unabhängiges Werkzeug und stammt nicht von Proxmox Server Solutions GmbH.
|
|
||||||
> „Proxmox“ ist eine Marke der Proxmox Server Solutions GmbH.
|
> „Proxmox“ ist eine Marke der Proxmox Server Solutions GmbH.
|
||||||
|
|
||||||
## Stand
|
<p align="center"><img src="docs/bilder/live-ansicht.png" alt="Live-Ansicht eines Deployments: Phasen mit Status, Abschlussbox mit IP und SSH-Befehl" width="100%"></p>
|
||||||
|
|
||||||
In Entwicklung. Version und Fortschritt stehen im [CHANGELOG](CHANGELOG.md), der Plan in
|
## Was es kann
|
||||||
[docs/architektur.md](docs/architektur.md), die Aufgaben in [docs/issues/](docs/issues/README.md).
|
|
||||||
|
|
||||||
| Etappe | Inhalt | Stand |
|
- **Assistent in neun Schritten** – Server, Typ, Distribution, Name, Ressourcen, Netzwerk (DHCP/statisch,
|
||||||
|
VLAN), Zugang (SSH-Schlüssel), Profile mit Variablen – und am Ende ein **Dry-Run** mit allen geplanten
|
||||||
|
Proxmox-Aufrufen und dem passenden CLI-Befehl.
|
||||||
|
- **Live-Fortschritt** je Phase (Server-Sent Events), in Web und CLI mit denselben Texten; bei Fehlern
|
||||||
|
Ursache und Lösungshinweis, danach auf Wunsch **Aufräumen** des halb angelegten Gasts.
|
||||||
|
- **Templates** aus offiziellen Cloud-Images (Prüfsumme geprüft, UEFI, Guest-Agent eingebaut), auf
|
||||||
|
Wunsch mit eingebackenen Profilen – ein Docker-Host ist dann in Sekunden statt Minuten fertig.
|
||||||
|
- **Ohne SSH zum Node** per API und Guest-Agent; mit SSH zusätzlich LXC-Provisionierung und
|
||||||
|
cloud-init-Snippets. Was ein Server kann, zeigt der Verbindungstest.
|
||||||
|
- **Verlauf** mit Filtern und Wiederholen, **Vorlagen** („Docker-Host klein“), hinterlegte
|
||||||
|
**SSH-Schlüssel**, **Rollen** Admin/Operator, **Audit-Log**, Anmeldung lokal oder per **OIDC**.
|
||||||
|
- **CLI** als einzelnes Binary für Linux, macOS und Windows – mit Batch-Dateien für viele Gäste.
|
||||||
|
|
||||||
|
| Übersicht | Assistent mit Dry-Run | Am Handy |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 1 · v0.1.0 | Server-Verwaltung, Fingerprint-Pinning, Verbindungstest mit Fähigkeitserkennung | ✓ fertig, auf PVE 9.2 geprüft |
|
|  |  |  |
|
||||||
| 2 · v0.2.0 | Katalog, Template-Bau, Debian-13-VM, Live-Fortschritt, Assistent | ✓ fertig, auf PVE 9.2 geprüft |
|
|
||||||
| 3 · v0.3.0 | LXC, Ubuntu, Profile, Vorlagen, Profile im Template, SSH-Schlüssel | ✓ fertig, auf PVE 9.2 geprüft (Bau-Adresse #021: Abnahme offen) |
|
Weitere Ansichten: [Verlauf](docs/bilder/verlauf.png) · [Server mit Verbindungstest](docs/bilder/server.png) · [Katalog](docs/bilder/katalog.png).
|
||||||
| 4 · v0.4.0 | CLI `proxdeploy` (Go), API-Tokens | ✓ fertig, auf PVE 9.2 geprüft |
|
Erzeugt mit `scripts/screenshots.ts` aus dem gebauten Stand.
|
||||||
| 5 · v0.5.0 | Verlauf, Aufräumen, Audit-Log, Benutzer, OIDC | ✓ fertig (Aufräumen auf PVE 9.2 geprüft, OIDC gegen Mock-IdP) |
|
|
||||||
| 6 · v0.6.0 | Branding, README mit Screenshots, Installer | ◐ als Nächstes |
|
## Installation
|
||||||
|
|
||||||
|
ProxDeploy braucht PostgreSQL und läuft als zwei Prozesse (app und worker). Es gehört hinter einen
|
||||||
|
Reverse-Proxy mit TLS; dann `TRUST_PROXY=true` und `APP_URL` auf die öffentliche Adresse setzen.
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.wilde-server.de/florian.wilde/proxdeploy.git && cd proxdeploy
|
||||||
|
cp .env.example .env
|
||||||
|
# POSTGRES_PASSWORT=$(openssl rand -hex 24)
|
||||||
|
# PROXDEPLOY_SCHLUESSEL=$(openssl rand -base64 32) ← sichern! ohne ihn sind Tokens und Schlüssel verloren
|
||||||
|
# SITZUNG_GEHEIMNIS=$(openssl rand -base64 48)
|
||||||
|
# APP_URL=https://proxdeploy.example
|
||||||
|
# ADMIN_PASSWORT=… ← erster Admin, nur beim allerersten Start
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Compose startet `postgres`, spielt mit `migrate` die Datenbank ein und startet dann `app` (Port 3000 auf
|
||||||
|
127.0.0.1) und `worker`. Aktualisieren: `git pull && docker compose up -d --build`.
|
||||||
|
|
||||||
|
### In einem Debian-LXC auf dem Proxmox-Host
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/paket-bauen.sh 0.6.0 --tar # im Repository: proxdeploy-0.6.0.tar.gz
|
||||||
|
scp proxdeploy-0.6.0.tar.gz deploy/install-lxc.sh deploy/im-container.sh root@pve01:/root/
|
||||||
|
ssh root@pve01 ./install-lxc.sh --paket proxdeploy-0.6.0.tar.gz --url https://proxdeploy.example
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Installer legt einen unprivilegierten Debian-13-Container an (DHCP auf `vmbr0`; `--ip`, `--gw`,
|
||||||
|
`--vlan`, `--storage`, `--ctid` u. a. siehe `--help`), installiert Node.js 24 (Prüfsumme geprüft) und
|
||||||
|
PostgreSQL, richtet systemd-Dienste ein und zeigt am Ende Adresse und das Passwort des ersten Admins.
|
||||||
|
Aktualisieren mit `./install-lxc.sh --aktualisieren <ctid> --paket proxdeploy-0.6.1.tar.gz` – die
|
||||||
|
letzten drei Fassungen bleiben unter `/opt/proxdeploy/versionen/`.
|
||||||
|
|
||||||
|
## Erste Schritte
|
||||||
|
|
||||||
|
1. **Token in Proxmox:** eigenen Benutzer und die Rolle `ProxDeploy` anlegen, Token ohne
|
||||||
|
Privilege Separation – die Befehle für 8.x und 9.x stehen in [docs/proxmox-rechte.md](docs/proxmox-rechte.md).
|
||||||
|
2. **Server anlegen:** URL, Token, Zertifikat bestätigen (Fingerprint-Pinning für selbst signierte
|
||||||
|
Zertifikate), „Verbindung testen“. Optional SSH-Zugang zum Node hinterlegen.
|
||||||
|
3. **SSH-Schlüssel** unter Einstellungen hinterlegen – sie kommen automatisch in jeden Gast.
|
||||||
|
4. **Ausrollen:** fehlt das Template, baut ProxDeploy es vorher (oder unter „Templates“).
|
||||||
|
|
||||||
## Was ProxDeploy auf einem Server kann
|
## Was ProxDeploy auf einem Server kann
|
||||||
|
|
||||||
@@ -129,6 +181,28 @@ OIDC_GRUPPE_OPERATOR=proxdeploy-operators
|
|||||||
- Gesperrte Konten bleiben gesperrt, auch per SSO. Getestet gegen einen Mock-IdP (`tests/oidc.test.ts`);
|
- Gesperrte Konten bleiben gesperrt, auch per SSO. Getestet gegen einen Mock-IdP (`tests/oidc.test.ts`);
|
||||||
ein echter IdP folgt beim Feldtest.
|
ein echter IdP folgt beim Feldtest.
|
||||||
|
|
||||||
|
## Katalog erweitern
|
||||||
|
|
||||||
|
- **Neue Distribution:** eine YAML-Datei unter `catalog/distros/` – Image-URL, Prüfsummen-Quelle,
|
||||||
|
Standardbenutzer, LXC-Template-Muster. Für eine neue Familie (z. B. `rhel` mit dnf) kommt ein
|
||||||
|
Adapter unter `src/engine/adapter/` dazu. → [docs/katalog.md](docs/katalog.md#neue-distribution-hinzufügen)
|
||||||
|
- **Eigenes Profil:** in der Web-GUI unter Katalog → „Profil anlegen“ (YAML mit Live-Prüfung und
|
||||||
|
Skriptvorschau) oder als Datei unter `catalog/profiles/`. → [docs/katalog.md](docs/katalog.md#eigenes-profil-anlegen)
|
||||||
|
|
||||||
|
## Stand
|
||||||
|
|
||||||
|
Version und Änderungen stehen im [CHANGELOG](CHANGELOG.md), der Plan in
|
||||||
|
[docs/architektur.md](docs/architektur.md), die Aufgaben in [docs/issues/](docs/issues/README.md).
|
||||||
|
|
||||||
|
| Etappe | Inhalt | Stand |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 · v0.1.0 | Server-Verwaltung, Fingerprint-Pinning, Verbindungstest mit Fähigkeitserkennung | ✓ fertig, auf PVE 9.2 geprüft |
|
||||||
|
| 2 · v0.2.0 | Katalog, Template-Bau, Debian-13-VM, Live-Fortschritt, Assistent | ✓ fertig, auf PVE 9.2 geprüft |
|
||||||
|
| 3 · v0.3.0 | LXC, Ubuntu, Profile, Vorlagen, Profile im Template, SSH-Schlüssel | ✓ fertig, auf PVE 9.2 geprüft (Bau-Adresse #021: Abnahme offen) |
|
||||||
|
| 4 · v0.4.0 | CLI `proxdeploy` (Go), API-Tokens | ✓ fertig, auf PVE 9.2 geprüft |
|
||||||
|
| 5 · v0.5.0 | Verlauf, Aufräumen, Audit-Log, Benutzer, OIDC | ✓ fertig (Aufräumen auf PVE 9.2 geprüft, OIDC gegen Mock-IdP) |
|
||||||
|
| 6 · v0.6.0 | Branding, README mit Screenshots, Docker Compose, LXC-Installer | ✓ fertig, Installer auf PVE 9.2 geprüft |
|
||||||
|
|
||||||
## Entwicklung
|
## Entwicklung
|
||||||
|
|
||||||
Voraussetzungen: Node 24 (z. B. über nvm), pnpm 12 (corepack), Docker für PostgreSQL.
|
Voraussetzungen: Node 24 (z. B. über nvm), pnpm 12 (corepack), Docker für PostgreSQL.
|
||||||
@@ -154,12 +228,6 @@ Go-Client nutzt die aus `cli/api/openapi.json` erzeugten Typen.
|
|||||||
|
|
||||||
Konventionen (deutsche Bezeichner, Schichtung, Versionen) stehen in [CLAUDE.md](CLAUDE.md).
|
Konventionen (deutsche Bezeichner, Schichtung, Versionen) stehen in [CLAUDE.md](CLAUDE.md).
|
||||||
|
|
||||||
## Betrieb
|
|
||||||
|
|
||||||
Docker Compose (app, worker, postgres) und ein Installer für einen Debian-LXC auf Proxmox folgen mit
|
|
||||||
Etappe 6. ProxDeploy ist für den Betrieb hinter einem Reverse-Proxy mit TLS gedacht
|
|
||||||
(`TRUST_PROXY=true`, `APP_URL` auf die öffentliche Adresse).
|
|
||||||
|
|
||||||
## Lizenz
|
## Lizenz
|
||||||
|
|
||||||
Noch nicht festgelegt.
|
Noch nicht festgelegt.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"openapi": "3.0.3",
|
"openapi": "3.0.3",
|
||||||
"info": {
|
"info": {
|
||||||
"title": "ProxDeploy API",
|
"title": "ProxDeploy API",
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"description": "REST-API von ProxDeploy – dieselbe für Web-GUI und CLI. Fehler: `{ fehler: { code, nachricht, hinweis?, felder? } }`."
|
"description": "REST-API von ProxDeploy – dieselbe für Web-GUI und CLI. Fehler: `{ fehler: { code, nachricht, hinweis?, felder? } }`."
|
||||||
},
|
},
|
||||||
"servers": [
|
"servers": [
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Läuft im Debian-13-Container (von install-lxc.sh per `pct exec` aufgerufen):
|
||||||
|
#
|
||||||
|
# im-container.sh installieren --url http://10.0.10.50:3000
|
||||||
|
# im-container.sh aktualisieren
|
||||||
|
#
|
||||||
|
# Erwartet das Paket unter /root/proxdeploy-paket.tar.gz.
|
||||||
|
set -euo pipefail
|
||||||
|
# Über SSH/pct exec reisen die Locales des Aufrufers mit; im frischen Container gibt es sie nicht.
|
||||||
|
export LC_ALL=C.UTF-8 LANG=C.UTF-8
|
||||||
|
unset LANGUAGE
|
||||||
|
|
||||||
|
NODE_VERSION="24.21.0"
|
||||||
|
BASIS="/opt/proxdeploy"
|
||||||
|
ENV_DATEI="/etc/proxdeploy/proxdeploy.env"
|
||||||
|
PAKET="/root/proxdeploy-paket.tar.gz"
|
||||||
|
|
||||||
|
fehler() { echo "✗ $*" >&2; exit 1; }
|
||||||
|
schritt() { echo "◐ $*"; }
|
||||||
|
fertig() { echo "✓ $*"; }
|
||||||
|
|
||||||
|
node_installieren() {
|
||||||
|
case "$(uname -m)" in
|
||||||
|
x86_64) arch=x64 ;;
|
||||||
|
aarch64) arch=arm64 ;;
|
||||||
|
*) fehler "Architektur $(uname -m) wird nicht unterstützt." ;;
|
||||||
|
esac
|
||||||
|
if [ -x /opt/node/bin/node ] && [ "$(/opt/node/bin/node --version)" = "v$NODE_VERSION" ]; then return; fi
|
||||||
|
schritt "Node.js $NODE_VERSION"
|
||||||
|
local datei="node-v$NODE_VERSION-linux-$arch.tar.xz" tmp
|
||||||
|
tmp="$(mktemp -d)"
|
||||||
|
curl -fsSL -o "$tmp/$datei" "https://nodejs.org/dist/v$NODE_VERSION/$datei"
|
||||||
|
curl -fsSL -o "$tmp/SHASUMS256.txt" "https://nodejs.org/dist/v$NODE_VERSION/SHASUMS256.txt"
|
||||||
|
(cd "$tmp" && grep " $datei\$" SHASUMS256.txt | sha256sum -c --quiet) || fehler "Prüfsumme von $datei stimmt nicht."
|
||||||
|
rm -rf /opt/node && mkdir -p /opt/node
|
||||||
|
tar -xJf "$tmp/$datei" -C /opt/node --strip-components=1
|
||||||
|
rm -rf "$tmp"
|
||||||
|
fertig "Node.js $(/opt/node/bin/node --version)"
|
||||||
|
}
|
||||||
|
|
||||||
|
paket_auspacken() {
|
||||||
|
[ -f "$PAKET" ] || fehler "$PAKET fehlt."
|
||||||
|
local version ziel
|
||||||
|
version="$(tar -xzOf "$PAKET" ./VERSION)"
|
||||||
|
ziel="$BASIS/versionen/$version"
|
||||||
|
rm -rf "$ziel" && mkdir -p "$ziel"
|
||||||
|
tar -xzf "$PAKET" -C "$ziel"
|
||||||
|
chown -R root:root "$ziel"
|
||||||
|
ln -sfn "$ziel" "$BASIS/aktuell"
|
||||||
|
rm -f "$PAKET"
|
||||||
|
# Die letzten drei Fassungen behalten – für ein Zurück per Symlink.
|
||||||
|
find "$BASIS/versionen" -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\n' | sort -rn | tail -n +4 |
|
||||||
|
cut -d' ' -f2- | xargs -r rm -rf
|
||||||
|
echo "$version"
|
||||||
|
}
|
||||||
|
|
||||||
|
dienste_schreiben() {
|
||||||
|
cat > /etc/systemd/system/proxdeploy-migrate.service <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=ProxDeploy – Datenbank-Migrationen
|
||||||
|
After=postgresql.service network-online.target
|
||||||
|
Requires=postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
RemainAfterExit=yes
|
||||||
|
User=proxdeploy
|
||||||
|
EnvironmentFile=$ENV_DATEI
|
||||||
|
WorkingDirectory=$BASIS/aktuell/app
|
||||||
|
ExecStart=/opt/node/bin/node ../worker/migrieren.mjs
|
||||||
|
EOF
|
||||||
|
for dienst in app worker; do
|
||||||
|
if [ "$dienst" = app ]; then start="server.js"; beschreibung="Web-GUI und REST-API"; else start="../worker/worker.mjs"; beschreibung="worker (Aufträge)"; fi
|
||||||
|
cat > "/etc/systemd/system/proxdeploy-$dienst.service" <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=ProxDeploy – $beschreibung
|
||||||
|
After=proxdeploy-migrate.service
|
||||||
|
Requires=proxdeploy-migrate.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=proxdeploy
|
||||||
|
EnvironmentFile=$ENV_DATEI
|
||||||
|
Environment=NODE_ENV=production
|
||||||
|
WorkingDirectory=$BASIS/aktuell/app
|
||||||
|
ExecStart=/opt/node/bin/node $start
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
TimeoutStopSec=40
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
PrivateTmp=true
|
||||||
|
PrivateDevices=true
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
done
|
||||||
|
systemctl daemon-reload
|
||||||
|
}
|
||||||
|
|
||||||
|
auf_gesundheit_warten() {
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
if curl -fsS -o /dev/null "http://127.0.0.1:3000/api/v1/gesundheit" 2>/dev/null; then return 0; fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
journalctl -u proxdeploy-app -u proxdeploy-migrate --no-pager -n 30 >&2
|
||||||
|
fehler "Die app antwortet nicht."
|
||||||
|
}
|
||||||
|
|
||||||
|
installieren() {
|
||||||
|
local url=""
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--url) url="$2"; shift 2 ;;
|
||||||
|
*) fehler "Unbekannte Option $1" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[ -n "$url" ] || fehler "--url fehlt."
|
||||||
|
[ -f "$ENV_DATEI" ] && fehler "ProxDeploy ist hier schon eingerichtet – zum Aktualisieren: install-lxc.sh --aktualisieren <ctid>"
|
||||||
|
|
||||||
|
schritt "Pakete (PostgreSQL, curl)"
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq --no-install-recommends ca-certificates curl xz-utils openssl postgresql >/dev/null
|
||||||
|
fertig "PostgreSQL $(psql --version | awk '{print $3}')"
|
||||||
|
node_installieren
|
||||||
|
|
||||||
|
schritt "Benutzer und Datenbank"
|
||||||
|
id proxdeploy >/dev/null 2>&1 || useradd --system --home-dir /var/lib/proxdeploy --create-home --shell /usr/sbin/nologin proxdeploy
|
||||||
|
local db_passwort admin_passwort
|
||||||
|
db_passwort="$(openssl rand -hex 24)"
|
||||||
|
admin_passwort="$(openssl rand -base64 18 | tr -d '/+=' | cut -c1-20)"
|
||||||
|
su postgres -c "psql -q -v ON_ERROR_STOP=1" <<SQL
|
||||||
|
DO \$\$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'proxdeploy') THEN
|
||||||
|
CREATE ROLE proxdeploy LOGIN PASSWORD '$db_passwort';
|
||||||
|
ELSE
|
||||||
|
ALTER ROLE proxdeploy PASSWORD '$db_passwort';
|
||||||
|
END IF;
|
||||||
|
END \$\$;
|
||||||
|
SQL
|
||||||
|
su postgres -c "psql -tAc \"SELECT 1 FROM pg_database WHERE datname = 'proxdeploy'\"" | grep -q 1 ||
|
||||||
|
su postgres -c "createdb -O proxdeploy proxdeploy"
|
||||||
|
fertig "Datenbank proxdeploy"
|
||||||
|
|
||||||
|
schritt "Konfiguration $ENV_DATEI"
|
||||||
|
mkdir -p /etc/proxdeploy
|
||||||
|
umask 027
|
||||||
|
cat > "$ENV_DATEI" <<EOF
|
||||||
|
# ProxDeploy – erzeugt von im-container.sh am $(date -Iseconds)
|
||||||
|
DATABASE_URL=postgresql://proxdeploy:$db_passwort@127.0.0.1:5432/proxdeploy
|
||||||
|
PROXDEPLOY_SCHLUESSEL=$(openssl rand -base64 32)
|
||||||
|
SITZUNG_GEHEIMNIS=$(openssl rand -base64 48)
|
||||||
|
APP_URL=$url
|
||||||
|
# Hinter einem Reverse-Proxy auf true setzen (wertet X-Forwarded-* aus).
|
||||||
|
TRUST_PROXY=false
|
||||||
|
PORT=3000
|
||||||
|
HOSTNAME=0.0.0.0
|
||||||
|
LOG_LEVEL=info
|
||||||
|
WORKER_PARALLEL=4
|
||||||
|
ADMIN_BENUTZER=admin
|
||||||
|
ADMIN_PASSWORT=$admin_passwort
|
||||||
|
EOF
|
||||||
|
chown root:proxdeploy "$ENV_DATEI"
|
||||||
|
chmod 0640 "$ENV_DATEI"
|
||||||
|
umask 022
|
||||||
|
|
||||||
|
schritt "ProxDeploy"
|
||||||
|
local version
|
||||||
|
version="$(paket_auspacken)"
|
||||||
|
dienste_schreiben
|
||||||
|
systemctl enable --now proxdeploy-app proxdeploy-worker >/dev/null 2>&1
|
||||||
|
auf_gesundheit_warten
|
||||||
|
# Der erste Admin ist angelegt – das Passwort hat in der Datei nichts mehr zu suchen.
|
||||||
|
sed -i '/^ADMIN_PASSWORT=/d' "$ENV_DATEI"
|
||||||
|
fertig "ProxDeploy $version läuft"
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
ProxDeploy ist bereit: $url
|
||||||
|
Benutzer: admin
|
||||||
|
Passwort: $admin_passwort
|
||||||
|
(wird nur jetzt angezeigt – nach der Anmeldung unter
|
||||||
|
Einstellungen → Passwort ändern)
|
||||||
|
Konfiguration: $ENV_DATEI
|
||||||
|
Logs: journalctl -u proxdeploy-app -u proxdeploy-worker
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
aktualisieren() {
|
||||||
|
[ -f "$ENV_DATEI" ] || fehler "Hier ist ProxDeploy noch nicht eingerichtet."
|
||||||
|
node_installieren
|
||||||
|
local alt version
|
||||||
|
alt="$(cat "$BASIS/aktuell/VERSION" 2>/dev/null || echo –)"
|
||||||
|
systemctl stop proxdeploy-app proxdeploy-worker
|
||||||
|
version="$(paket_auspacken)"
|
||||||
|
dienste_schreiben
|
||||||
|
# migrate bleibt nach dem ersten Lauf „aktiv“ – für die neue Fassung ausdrücklich neu starten.
|
||||||
|
systemctl restart proxdeploy-migrate
|
||||||
|
systemctl start proxdeploy-app proxdeploy-worker
|
||||||
|
auf_gesundheit_warten
|
||||||
|
fertig "ProxDeploy $alt → $version"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
installieren) shift; installieren "$@" ;;
|
||||||
|
aktualisieren) shift; aktualisieren "$@" ;;
|
||||||
|
*) fehler "Aufruf: $0 installieren --url URL | aktualisieren" ;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ProxDeploy in einem Debian-13-LXC einrichten – auf dem Proxmox-Host als root ausführen.
|
||||||
|
#
|
||||||
|
# ./install-lxc.sh --paket proxdeploy-0.6.0.tar.gz [Optionen]
|
||||||
|
# ./install-lxc.sh --aktualisieren <ctid> --paket proxdeploy-0.6.1.tar.gz
|
||||||
|
#
|
||||||
|
# Optionen (alle optional außer --paket):
|
||||||
|
# --ctid N Container-ID (Standard: nächste freie)
|
||||||
|
# --hostname NAME Hostname (Standard: proxdeploy)
|
||||||
|
# --storage NAME Storage für die Disk (Standard: local-lvm)
|
||||||
|
# --disk GB Disk in GB (Standard: 8)
|
||||||
|
# --cores N CPU-Kerne (Standard: 2)
|
||||||
|
# --memory MB RAM (Standard: 2048)
|
||||||
|
# --bridge NAME Bridge (Standard: vmbr0)
|
||||||
|
# --vlan TAG VLAN-Tag
|
||||||
|
# --ip CIDR feste Adresse, z. B. 10.0.10.50/24 (Standard: DHCP); dazu --gw
|
||||||
|
# --gw ADRESSE Gateway bei fester Adresse
|
||||||
|
# --url URL öffentliche Adresse (APP_URL), z. B. https://proxdeploy.example
|
||||||
|
# (Standard: http://<IP des Containers>:3000)
|
||||||
|
# --tags LISTE zusätzliche Proxmox-Tags, z. B. proxdeploy-test
|
||||||
|
#
|
||||||
|
# Das Paket baut `scripts/paket-bauen.sh <version> --tar` im Repository.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
fehler() { echo "✗ $*" >&2; exit 1; }
|
||||||
|
schritt() { echo "◐ $*"; }
|
||||||
|
fertig() { echo "✓ $*"; }
|
||||||
|
|
||||||
|
[ "$(id -u)" = 0 ] || fehler "Bitte als root auf dem Proxmox-Host ausführen."
|
||||||
|
command -v pct >/dev/null || fehler "pct fehlt – läuft das auf einem Proxmox-VE-Host?"
|
||||||
|
|
||||||
|
ctid="" hostname="proxdeploy" storage="local-lvm" disk=8 cores=2 memory=2048 bridge="vmbr0"
|
||||||
|
vlan="" ip="dhcp" gw="" url="" paket="" aktualisieren="" tags=""
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--ctid) ctid="$2"; shift 2 ;;
|
||||||
|
--hostname) hostname="$2"; shift 2 ;;
|
||||||
|
--storage) storage="$2"; shift 2 ;;
|
||||||
|
--disk) disk="$2"; shift 2 ;;
|
||||||
|
--cores) cores="$2"; shift 2 ;;
|
||||||
|
--memory) memory="$2"; shift 2 ;;
|
||||||
|
--bridge) bridge="$2"; shift 2 ;;
|
||||||
|
--vlan) vlan="$2"; shift 2 ;;
|
||||||
|
--ip) ip="$2"; shift 2 ;;
|
||||||
|
--gw) gw="$2"; shift 2 ;;
|
||||||
|
--url) url="$2"; shift 2 ;;
|
||||||
|
--tags) tags="$2"; shift 2 ;;
|
||||||
|
--paket) paket="$2"; shift 2 ;;
|
||||||
|
--aktualisieren) aktualisieren="$2"; shift 2 ;;
|
||||||
|
-h|--help) sed -n '2,25p' "$0"; exit 0 ;;
|
||||||
|
*) fehler "Unbekannte Option $1 (siehe --help)" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[ -n "$paket" ] || fehler "--paket fehlt (proxdeploy-<version>.tar.gz)."
|
||||||
|
[ -f "$paket" ] || fehler "$paket gibt es nicht."
|
||||||
|
tar -tzf "$paket" ./VERSION >/dev/null 2>&1 || fehler "$paket ist kein ProxDeploy-Paket (VERSION fehlt)."
|
||||||
|
version="$(tar -xzOf "$paket" ./VERSION)"
|
||||||
|
innen="$(dirname "$0")/im-container.sh"
|
||||||
|
[ -f "$innen" ] || fehler "im-container.sh fehlt neben diesem Skript."
|
||||||
|
|
||||||
|
pushen() {
|
||||||
|
pct push "$1" "$paket" /root/proxdeploy-paket.tar.gz
|
||||||
|
pct push "$1" "$innen" /root/proxdeploy-im-container.sh --perms 0700
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Aktualisieren ───────────────────────────────────────────────────────────────────────────────
|
||||||
|
if [ -n "$aktualisieren" ]; then
|
||||||
|
pct status "$aktualisieren" | grep -q running || fehler "Container $aktualisieren läuft nicht."
|
||||||
|
schritt "Aktualisiere Container $aktualisieren auf $version"
|
||||||
|
pushen "$aktualisieren"
|
||||||
|
pct exec "$aktualisieren" -- bash /root/proxdeploy-im-container.sh aktualisieren
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Neu einrichten ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
[ -n "$ctid" ] || ctid="$(pvesh get /cluster/nextid)"
|
||||||
|
pct status "$ctid" >/dev/null 2>&1 && fehler "Container $ctid gibt es schon."
|
||||||
|
if [ "$ip" != dhcp ]; then
|
||||||
|
[[ "$ip" =~ ^[0-9.]+/[0-9]+$ ]] || fehler "--ip: Format 10.0.10.50/24"
|
||||||
|
[ -n "$gw" ] || fehler "Bei fester Adresse fehlt --gw."
|
||||||
|
fi
|
||||||
|
|
||||||
|
schritt "LXC-Template Debian 13"
|
||||||
|
pveam update >/dev/null
|
||||||
|
vorlage="$(pveam available --section system | awk '/debian-13-standard_.*_amd64\.tar\.zst/ {print $2}' | sort -V | tail -1)"
|
||||||
|
[ -n "$vorlage" ] || fehler "Kein Debian-13-Template in pveam gefunden."
|
||||||
|
template_storage="$(pvesm status --content vztmpl | awk 'NR>1 {print $1; exit}')"
|
||||||
|
[ -n "$template_storage" ] || fehler "Kein Storage für Container-Templates (Inhalt vztmpl)."
|
||||||
|
if ! pveam list "$template_storage" | grep -q "$vorlage"; then
|
||||||
|
pveam download "$template_storage" "$vorlage" >/dev/null
|
||||||
|
fi
|
||||||
|
fertig "$vorlage auf $template_storage"
|
||||||
|
|
||||||
|
netz="name=eth0,bridge=$bridge,firewall=1"
|
||||||
|
[ -n "$vlan" ] && netz="$netz,tag=$vlan"
|
||||||
|
if [ "$ip" = dhcp ]; then netz="$netz,ip=dhcp"; else netz="$netz,ip=$ip,gw=$gw"; fi
|
||||||
|
|
||||||
|
schritt "Container $ctid anlegen"
|
||||||
|
pct create "$ctid" "$template_storage:vztmpl/$vorlage" \
|
||||||
|
--hostname "$hostname" --cores "$cores" --memory "$memory" --swap 512 \
|
||||||
|
--rootfs "$storage:$disk" --net0 "$netz" \
|
||||||
|
--unprivileged 1 --features nesting=1 --onboot 1 \
|
||||||
|
--tags "proxdeploy${tags:+;$tags}" \
|
||||||
|
--description "ProxDeploy $version – eingerichtet mit install-lxc.sh" >/dev/null
|
||||||
|
pct start "$ctid"
|
||||||
|
fertig "Container $ctid ($hostname) läuft"
|
||||||
|
|
||||||
|
schritt "Netzwerk"
|
||||||
|
adresse=""
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
adresse="$(pct exec "$ctid" -- ip -4 -o addr show dev eth0 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -1)"
|
||||||
|
[ -n "$adresse" ] && pct exec "$ctid" -- getent hosts deb.debian.org >/dev/null 2>&1 && break
|
||||||
|
adresse=""
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
[ -n "$adresse" ] || fehler "Container $ctid bekommt keine Adresse oder kein DNS."
|
||||||
|
fertig "IP $adresse"
|
||||||
|
|
||||||
|
[ -n "$url" ] || url="http://$adresse:3000"
|
||||||
|
pushen "$ctid"
|
||||||
|
pct exec "$ctid" -- bash /root/proxdeploy-im-container.sh installieren --url "$url"
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# ProxDeploy im Betrieb: postgres, migrate (einmalig), app, worker.
|
||||||
|
#
|
||||||
|
# cp .env.example .env # Schlüssel erzeugen, ADMIN_PASSWORT und APP_URL setzen
|
||||||
|
# docker compose up -d --build
|
||||||
|
#
|
||||||
|
# Die app lauscht auf 127.0.0.1:3000 – davor gehört ein Reverse-Proxy mit TLS (TRUST_PROXY=true).
|
||||||
|
|
||||||
|
name: proxdeploy
|
||||||
|
|
||||||
|
x-proxdeploy: &proxdeploy
|
||||||
|
image: proxdeploy:${PROXDEPLOY_VERSION:-latest}
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
VERSION: ${PROXDEPLOY_VERSION:-}
|
||||||
|
env_file: ${PROXDEPLOY_ENV_DATEI:-.env}
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://proxdeploy:${POSTGRES_PASSWORT:?POSTGRES_PASSWORT in .env setzen}@postgres:5432/proxdeploy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17.11-trixie
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: proxdeploy
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORT:?POSTGRES_PASSWORT in .env setzen}
|
||||||
|
POSTGRES_DB: proxdeploy
|
||||||
|
volumes:
|
||||||
|
- datenbank:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U proxdeploy"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
migrate:
|
||||||
|
<<: *proxdeploy
|
||||||
|
command: ["node", "../worker/migrieren.mjs"]
|
||||||
|
restart: "no"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
app:
|
||||||
|
<<: *proxdeploy
|
||||||
|
ports:
|
||||||
|
- "${PROXDEPLOY_BIND:-127.0.0.1}:${PROXDEPLOY_PORT:-3000}:3000"
|
||||||
|
depends_on:
|
||||||
|
migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
|
||||||
|
worker:
|
||||||
|
<<: *proxdeploy
|
||||||
|
command: ["node", "../worker/worker.mjs"]
|
||||||
|
healthcheck:
|
||||||
|
disable: true
|
||||||
|
# Lange Aufträge (Template-Bau) dürfen beim Stoppen zu Ende laufen.
|
||||||
|
stop_grace_period: 40s
|
||||||
|
depends_on:
|
||||||
|
migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
datenbank:
|
||||||
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 185 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 137 KiB |
@@ -1,21 +1,28 @@
|
|||||||
# #017 Branding-Assets
|
# #017 Branding-Assets
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.6.0
|
**Meilenstein:** v0.6.0
|
||||||
**Labels:** design
|
**Labels:** design
|
||||||
**Geschätzt:** 4 h **Tatsächlich:** –
|
**Geschätzt:** 4 h **Tatsächlich:** 0 h 17 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Alle Logo-Varianten, Banner, Favicons, Manifest-Icons nach Vorgabe.
|
Alle Logo-Varianten, Banner, Favicons, Manifest-Icons nach Vorgabe.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] symbol(-light), icon-small, avatar, logo(-light), banner 1280×640
|
- [x] symbol(-light), icon-small, avatar, logo(-light), banner 1280×640 (+ banner.png für die Social Preview)
|
||||||
- [ ] Favicon 16/32, Apple-Touch-Icon, Manifest-Icons per Skript aus SVG
|
- [x] Favicon 16/32, Apple-Touch-Icon, Manifest-Icons (192, 512, maskierbar) per Skript aus SVG (`pnpm marke`)
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Darstellung in beiden Themes geprüft
|
- [x] Darstellung in beiden Themes geprüft (Vorschau-Tafel dunkel/hell, Favicons auf hellem und dunklem Tab)
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#001
|
#001
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
–
|
- **Texte als Pfade:** Wortmarke und Banner sehen überall gleich aus, auch ohne installierte Schrift.
|
||||||
|
Die TTFs (JetBrains Mono 2.304, Space Grotesk 2.0.0, OFL) lädt `pnpm marke` mit SHA-256-Prüfung
|
||||||
|
nach `node_modules/.cache`; eingecheckt werden nur die Ergebnisse.
|
||||||
|
- opentype.js 1.3.5 kennt die Ligatur-Regeln von JetBrains Mono nicht und schreibt in `toPathData`
|
||||||
|
gelegentlich „NaN“ – deshalb Glyph für Glyph gesetzt und Pfaddaten selbst geschrieben.
|
||||||
|
- `icon-small.svg` passt sich per `prefers-color-scheme` an; die PNG-Favicons sind dicke Linien auf
|
||||||
|
Teal, weil 16 px mit den dünnen Linien des Symbols nicht lesbar waren.
|
||||||
|
- Unterzeile im Banner 32 statt 34 px – sonst breiter als die Ränder.
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
# #018 README und Dokumentation
|
# #018 README und Dokumentation
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.6.0
|
**Meilenstein:** v0.6.0
|
||||||
**Labels:** doku
|
**Labels:** doku
|
||||||
**Geschätzt:** 4 h **Tatsächlich:** –
|
**Geschätzt:** 4 h **Tatsächlich:** 0 h 02 min (überwiegend parallel zu #019, während der Installer lief)
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
README mit Banner, Screenshots, Installation, CLI, Proxmox-Rechten, neuer Distribution, eigenem Profil.
|
README mit Banner, Screenshots, Installation, CLI, Proxmox-Rechten, neuer Distribution, eigenem Profil.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] Screenshots aus dem gebauten Stand
|
- [x] Screenshots aus dem gebauten Stand (`scripts/screenshots.ts`, dunkel, 1440 px und 390 px, echte Daten von pve-demo)
|
||||||
- [ ] Hinweis: unabhängiges Tool, nicht von Proxmox Server Solutions
|
- [x] Hinweis: unabhängiges Tool, nicht von Proxmox Server Solutions
|
||||||
|
- [x] Banner oben, Funktionen, Installation (Compose, LXC), Erste Schritte, CLI, OIDC, Katalog erweitern
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Anleitungen einmal Schritt für Schritt nachvollzogen
|
- [x] Anleitungen einmal Schritt für Schritt nachvollzogen: Compose (eigene Env-Datei, frische DB), LXC-Installer auf pve-demo, „Erste Schritte“ in der frisch installierten Instanz (Server anlegen, testen, Token, CLI, Dry-Run, echter LXC). „Neue Distribution“ ist der Weg, auf dem Ubuntu 26.04 dazukam (#010); „Eigenes Profil“ prüft der Playwright-Ablauf `profil-editor`.
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#013
|
#013
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
–
|
- Beim Erstellen der Screenshots fiel auf: die Übersicht zeigte seit Etappe 1 nur einen Platzhalter
|
||||||
|
(„Noch keine Deployments“) – jetzt Server mit Nodes, CPUs und freiem RAM sowie die letzten Aufträge.
|
||||||
|
|||||||
@@ -1,21 +1,29 @@
|
|||||||
# #019 Docker-Image und LXC-Installer
|
# #019 Docker-Image und LXC-Installer
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.6.0
|
**Meilenstein:** v0.6.0
|
||||||
**Labels:** infrastruktur
|
**Labels:** infrastruktur
|
||||||
**Geschätzt:** 6 h **Tatsächlich:** –
|
**Geschätzt:** 6 h **Tatsächlich:** 0 h 20 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Produktionsbetrieb per Docker Compose oder in einem Debian-LXC.
|
Produktionsbetrieb per Docker Compose oder in einem Debian-LXC.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] Dockerfile (standalone, non-root), Compose mit migrate
|
- [x] Dockerfile (standalone, non-root), Compose mit migrate – lokal komplett hochgefahren (migrate → app healthy, worker bereit, Anmeldung)
|
||||||
- [ ] `deploy/install-lxc.sh` (auf dem Proxmox-Host), Update-Pfad
|
- [x] `deploy/install-lxc.sh` (auf dem Proxmox-Host) + `deploy/im-container.sh`, Update-Pfad (`--aktualisieren`, drei Fassungen zum Zurückgehen)
|
||||||
|
- [x] Gemeinsames Laufzeit-Paket `scripts/paket-bauen.sh` für beide Wege
|
||||||
|
- [x] Migrationen ohne Prisma-CLI (`src/betrieb/migrieren.ts`), kompatibel zu `_prisma_migrations`
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Installer in echtem LXC durchgelaufen
|
- [x] Installer in echtem LXC durchgelaufen (pve-demo, CT 109 `pd-installer`); darin Server angelegt, Verbindungstest, CLI-Login, Dry-Run und ein echter LXC-Auftrag
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#001
|
#001
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
–
|
- **Eigener Migrationslauf statt `prisma migrate deploy`:** spart Prisma-CLI und Schema-Engine im
|
||||||
|
Betrieb; schreibt `_prisma_migrations` wie Prisma (Test prüft `prisma migrate status`).
|
||||||
|
- **worker als esbuild-Bundle** mit nur den Paketen, die er importiert (aus der Metadatei, exakte
|
||||||
|
Versionen aus der Entwicklung): 103 MB statt 741 MB.
|
||||||
|
- **Node im LXC von nodejs.org** mit SHA-256-Prüfung (Debian 13 hat nur Node 20).
|
||||||
|
- `ADMIN_PASSWORT` wird nach dem ersten Start aus der Env-Datei entfernt; das Passwort steht nur in der Ausgabe.
|
||||||
|
- Image 559 MB (node:24.21.0-trixie-slim), läuft als UID 1000; PostgreSQL 17.11.
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
|
|||||||
| [#014](014-verlauf.md) | Verlauf und Wiederholen | erledigt | v0.5.0 | 4 h | 1 h 21 min |
|
| [#014](014-verlauf.md) | Verlauf und Wiederholen | erledigt | v0.5.0 | 4 h | 1 h 21 min |
|
||||||
| [#015](015-rollen-audit.md) | Rollen, Benutzer, Tokens, Audit-Log | erledigt | v0.5.0 | 6 h | 0 h 10 min |
|
| [#015](015-rollen-audit.md) | Rollen, Benutzer, Tokens, Audit-Log | erledigt | v0.5.0 | 6 h | 0 h 10 min |
|
||||||
| [#016](016-oidc.md) | Anmeldung per OIDC | erledigt | v0.5.0 | 4 h | 0 h 08 min |
|
| [#016](016-oidc.md) | Anmeldung per OIDC | erledigt | v0.5.0 | 4 h | 0 h 08 min |
|
||||||
| [#017](017-branding.md) | Branding-Assets | offen | v0.6.0 | 4 h | – |
|
| [#017](017-branding.md) | Branding-Assets | erledigt | v0.6.0 | 4 h | 0 h 17 min |
|
||||||
| [#018](018-readme.md) | README und Dokumentation | offen | v0.6.0 | 4 h | – |
|
| [#018](018-readme.md) | README und Dokumentation | erledigt | v0.6.0 | 4 h | 0 h 02 min |
|
||||||
| [#019](019-installer.md) | Docker-Image und LXC-Installer | offen | v0.6.0 | 6 h | – |
|
| [#019](019-installer.md) | Docker-Image und LXC-Installer | erledigt | v0.6.0 | 6 h | 0 h 20 min |
|
||||||
| [#020](020-feldtest.md) | Feldtest 8.x und 9.x, Version 1.0.0 | offen | v1.0.0 | 6 h | – |
|
| [#020](020-feldtest.md) | Feldtest 8.x und 9.x, Version 1.0.0 | offen | v1.0.0 | 6 h | – |
|
||||||
| [#021](021-template-bau-ohne-ssh.md) | Template-Bau ohne SSH (Bau-Adresse) | umgesetzt, Abnahme offen | v0.3.0 | 3 h | 0 h 19 min |
|
| [#021](021-template-bau-ohne-ssh.md) | Template-Bau ohne SSH (Bau-Adresse) | umgesetzt, Abnahme offen | v0.3.0 | 3 h | 0 h 19 min |
|
||||||
| [#022](022-template-profile.md) | Templates mit eingebackenen Profilen | erledigt | v0.3.0 | 4 h | 0 h 22 min |
|
| [#022](022-template-profile.md) | Templates mit eingebackenen Profilen | erledigt | v0.3.0 | 4 h | 0 h 22 min |
|
||||||
|
|||||||
@@ -43,5 +43,7 @@ export default defineConfig([
|
|||||||
"playwright-report/**",
|
"playwright-report/**",
|
||||||
"dist/**",
|
"dist/**",
|
||||||
"cli/**",
|
"cli/**",
|
||||||
|
// Laufzeit-Paket aus scripts/paket-bauen.sh.
|
||||||
|
"paket/**",
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "proxdeploy",
|
"name": "proxdeploy",
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI",
|
"description": "VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI",
|
||||||
@@ -28,7 +28,8 @@
|
|||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"worker": "tsx src/worker/main.ts",
|
"worker": "tsx src/worker/main.ts",
|
||||||
"openapi": "tsx scripts/openapi.ts",
|
"openapi": "tsx scripts/openapi.ts",
|
||||||
"cli:pruefen": "scripts/cli-pruefen.sh"
|
"cli:pruefen": "scripts/cli-pruefen.sh",
|
||||||
|
"marke": "tsx scripts/marke-erzeugen.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
||||||
@@ -55,8 +56,10 @@
|
|||||||
"@commitlint/cli": "^21.2.3",
|
"@commitlint/cli": "^21.2.3",
|
||||||
"@commitlint/config-conventional": "^21.2.3",
|
"@commitlint/config-conventional": "^21.2.3",
|
||||||
"@playwright/test": "1.63.0",
|
"@playwright/test": "1.63.0",
|
||||||
|
"@resvg/resvg-js": "2.6.2",
|
||||||
"@tailwindcss/postcss": "^4.3.3",
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
"@types/node": "^24.13.6",
|
"@types/node": "^24.13.6",
|
||||||
|
"@types/opentype.js": "^1.3.10",
|
||||||
"@types/pg": "^8.23.1",
|
"@types/pg": "^8.23.1",
|
||||||
"@types/react": "^19.3.0",
|
"@types/react": "^19.3.0",
|
||||||
"@types/react-dom": "^19.3.0",
|
"@types/react-dom": "^19.3.0",
|
||||||
@@ -68,6 +71,7 @@
|
|||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"lint-staged": "^17.5.1",
|
"lint-staged": "^17.5.1",
|
||||||
|
"opentype.js": "1.3.5",
|
||||||
"prettier": "^3.9.9",
|
"prettier": "^3.9.9",
|
||||||
"prisma": "7.10.0",
|
"prisma": "7.10.0",
|
||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
|
|||||||
@@ -233,12 +233,18 @@ importers:
|
|||||||
'@playwright/test':
|
'@playwright/test':
|
||||||
specifier: 1.63.0
|
specifier: 1.63.0
|
||||||
version: 1.63.0
|
version: 1.63.0
|
||||||
|
'@resvg/resvg-js':
|
||||||
|
specifier: 2.6.2
|
||||||
|
version: 2.6.2
|
||||||
'@tailwindcss/postcss':
|
'@tailwindcss/postcss':
|
||||||
specifier: ^4.3.3
|
specifier: ^4.3.3
|
||||||
version: 4.3.3
|
version: 4.3.3
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^24.13.6
|
specifier: ^24.13.6
|
||||||
version: 24.19.2
|
version: 24.19.2
|
||||||
|
'@types/opentype.js':
|
||||||
|
specifier: ^1.3.10
|
||||||
|
version: 1.3.10
|
||||||
'@types/pg':
|
'@types/pg':
|
||||||
specifier: ^8.23.1
|
specifier: ^8.23.1
|
||||||
version: 8.23.1
|
version: 8.23.1
|
||||||
@@ -272,6 +278,9 @@ importers:
|
|||||||
lint-staged:
|
lint-staged:
|
||||||
specifier: ^17.5.1
|
specifier: ^17.5.1
|
||||||
version: 17.6.0
|
version: 17.6.0
|
||||||
|
opentype.js:
|
||||||
|
specifier: 1.3.5
|
||||||
|
version: 1.3.5
|
||||||
prettier:
|
prettier:
|
||||||
specifier: ^3.9.9
|
specifier: ^3.9.9
|
||||||
version: 3.9.10
|
version: 3.9.10
|
||||||
@@ -1357,6 +1366,86 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@resvg/resvg-js-android-arm-eabi@2.6.2':
|
||||||
|
resolution: {integrity: sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@resvg/resvg-js-android-arm64@2.6.2':
|
||||||
|
resolution: {integrity: sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@resvg/resvg-js-darwin-arm64@2.6.2':
|
||||||
|
resolution: {integrity: sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@resvg/resvg-js-darwin-x64@2.6.2':
|
||||||
|
resolution: {integrity: sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@resvg/resvg-js-linux-arm-gnueabihf@2.6.2':
|
||||||
|
resolution: {integrity: sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@resvg/resvg-js-linux-arm64-gnu@2.6.2':
|
||||||
|
resolution: {integrity: sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@resvg/resvg-js-linux-arm64-musl@2.6.2':
|
||||||
|
resolution: {integrity: sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@resvg/resvg-js-linux-x64-gnu@2.6.2':
|
||||||
|
resolution: {integrity: sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@resvg/resvg-js-linux-x64-musl@2.6.2':
|
||||||
|
resolution: {integrity: sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@resvg/resvg-js-win32-arm64-msvc@2.6.2':
|
||||||
|
resolution: {integrity: sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@resvg/resvg-js-win32-ia32-msvc@2.6.2':
|
||||||
|
resolution: {integrity: sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@resvg/resvg-js-win32-x64-msvc@2.6.2':
|
||||||
|
resolution: {integrity: sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@resvg/resvg-js@2.6.2':
|
||||||
|
resolution: {integrity: sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
|
||||||
'@rolldown/binding-android-arm-eabi@1.2.13':
|
'@rolldown/binding-android-arm-eabi@1.2.13':
|
||||||
resolution: {integrity: sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==}
|
resolution: {integrity: sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -1628,6 +1717,9 @@ packages:
|
|||||||
'@types/node@24.19.2':
|
'@types/node@24.19.2':
|
||||||
resolution: {integrity: sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==}
|
resolution: {integrity: sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==}
|
||||||
|
|
||||||
|
'@types/opentype.js@1.3.10':
|
||||||
|
resolution: {integrity: sha512-F67EFyk6j02okHz5JCgata3ZRAcZi9GLnzmkHw/rzJq3OCc8/ZVdoKrxMTYjcQP6IYHGBz2cav1cpzkOkPiPCQ==}
|
||||||
|
|
||||||
'@types/pg@8.23.1':
|
'@types/pg@8.23.1':
|
||||||
resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==}
|
resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==}
|
||||||
|
|
||||||
@@ -3235,6 +3327,11 @@ packages:
|
|||||||
openid-client@6.8.8:
|
openid-client@6.8.8:
|
||||||
resolution: {integrity: sha512-ZsucJA5Ad04Uv7YN4ql+s4GXNmb9uAYQwyJTsJx7CH/MX3JZioLE2pVKi1SC+YrbSLA4Px3Gi30dMjgOZtb6pA==}
|
resolution: {integrity: sha512-ZsucJA5Ad04Uv7YN4ql+s4GXNmb9uAYQwyJTsJx7CH/MX3JZioLE2pVKi1SC+YrbSLA4Px3Gi30dMjgOZtb6pA==}
|
||||||
|
|
||||||
|
opentype.js@1.3.5:
|
||||||
|
resolution: {integrity: sha512-thKDiELidAApOvXlncrpwDZKJCa9fXLEKM4+FoEWI+qTLDeNb+h7EkN+8a7KQODsB1GZ+Exz9KknkoPrEdXZDw==}
|
||||||
|
deprecated: This version should be ignored, it was meant to be published as 2.0.0 but got published as 1.3.5 by mistake. As of May 6th 2026 the latest v1 release is v1.3.4
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
optionator@0.9.4:
|
optionator@0.9.4:
|
||||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -4888,6 +4985,57 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.3.0
|
'@types/react': 19.3.0
|
||||||
|
|
||||||
|
'@resvg/resvg-js-android-arm-eabi@2.6.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@resvg/resvg-js-android-arm64@2.6.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@resvg/resvg-js-darwin-arm64@2.6.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@resvg/resvg-js-darwin-x64@2.6.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@resvg/resvg-js-linux-arm-gnueabihf@2.6.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@resvg/resvg-js-linux-arm64-gnu@2.6.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@resvg/resvg-js-linux-arm64-musl@2.6.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@resvg/resvg-js-linux-x64-gnu@2.6.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@resvg/resvg-js-linux-x64-musl@2.6.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@resvg/resvg-js-win32-arm64-msvc@2.6.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@resvg/resvg-js-win32-ia32-msvc@2.6.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@resvg/resvg-js-win32-x64-msvc@2.6.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@resvg/resvg-js@2.6.2':
|
||||||
|
optionalDependencies:
|
||||||
|
'@resvg/resvg-js-android-arm-eabi': 2.6.2
|
||||||
|
'@resvg/resvg-js-android-arm64': 2.6.2
|
||||||
|
'@resvg/resvg-js-darwin-arm64': 2.6.2
|
||||||
|
'@resvg/resvg-js-darwin-x64': 2.6.2
|
||||||
|
'@resvg/resvg-js-linux-arm-gnueabihf': 2.6.2
|
||||||
|
'@resvg/resvg-js-linux-arm64-gnu': 2.6.2
|
||||||
|
'@resvg/resvg-js-linux-arm64-musl': 2.6.2
|
||||||
|
'@resvg/resvg-js-linux-x64-gnu': 2.6.2
|
||||||
|
'@resvg/resvg-js-linux-x64-musl': 2.6.2
|
||||||
|
'@resvg/resvg-js-win32-arm64-msvc': 2.6.2
|
||||||
|
'@resvg/resvg-js-win32-ia32-msvc': 2.6.2
|
||||||
|
'@resvg/resvg-js-win32-x64-msvc': 2.6.2
|
||||||
|
|
||||||
'@rolldown/binding-android-arm-eabi@1.2.13':
|
'@rolldown/binding-android-arm-eabi@1.2.13':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -5078,6 +5226,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.24.6
|
undici-types: 7.24.6
|
||||||
|
|
||||||
|
'@types/opentype.js@1.3.10': {}
|
||||||
|
|
||||||
'@types/pg@8.23.1':
|
'@types/pg@8.23.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 24.19.2
|
'@types/node': 24.19.2
|
||||||
@@ -6848,6 +6998,8 @@ snapshots:
|
|||||||
jose: 6.2.12
|
jose: 6.2.12
|
||||||
oauth4webapi: 3.8.8
|
oauth4webapi: 3.8.8
|
||||||
|
|
||||||
|
opentype.js@1.3.5: {}
|
||||||
|
|
||||||
optionator@0.9.4:
|
optionator@0.9.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
deep-is: 0.1.4
|
deep-is: 0.1.4
|
||||||
|
|||||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" fill="none">
|
||||||
|
<rect width="64" height="64" rx="14" fill="#2DD4BF"/>
|
||||||
|
<g transform="translate(7.039999999999999 7.039999999999999) scale(0.78)">
|
||||||
|
<path d="M32 6 L54.5 19 V45 L32 58 L9.5 45 V19 Z" stroke="#11161C" stroke-width="3.5" stroke-linejoin="round"/>
|
||||||
|
<path d="M32 41 V20 M23.5 28.5 L32 20 L40.5 28.5" stroke="#11161C" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M24 47 H40" stroke="#11161C" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 574 B |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 496 B |
|
After Width: | Height: | Size: 887 B |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" fill="none">
|
||||||
|
<style>.h{stroke:#E6EDF3}.p{stroke:#2DD4BF}@media (prefers-color-scheme: light){.h{stroke:#11161C}.p{stroke:#0F766E}}</style>
|
||||||
|
<path class="h" d="M32 6 L54.5 19 V45 L32 58 L9.5 45 V19 Z" stroke-width="5.5" stroke-linejoin="round"/>
|
||||||
|
<path class="p" d="M32 43 V18 M22 28 L32 18 L42 28" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 458 B |
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 282 64" width="282" height="64" fill="none">
|
||||||
|
<path d="M32 6 L54.5 19 V45 L32 58 L9.5 45 V19 Z" stroke="#11161C" stroke-width="3.5" stroke-linejoin="round"/>
|
||||||
|
<path d="M32 41 V20 M23.5 28.5 L32 20 L40.5 28.5" stroke="#0F766E" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M24 47 H40" stroke="#0F766E" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
<path d="M83.46 43L80.76 43L80.76 21.1L87.81 21.1Q90.87 21.1 92.69 22.86Q94.5 24.61 94.5 27.55L94.5 27.55Q94.5 30.49 92.69 32.25Q90.87 34 87.81 34L87.81 34L83.46 34L83.46 43M83.46 23.53L83.46 31.57L87.81 31.57Q89.58 31.57 90.65 30.48Q91.71 29.38 91.71 27.55L91.71 27.55Q91.71 25.69 90.65 24.61Q89.58 23.53 87.81 23.53L87.81 23.53L83.46 23.53 M102.06 43L99.36 43L99.36 26.5L102.06 26.5L102.06 29.65L102.12 29.65Q102.33 28.06 103.47 27.13Q104.61 26.2 106.53 26.2L106.53 26.2Q109.11 26.2 110.51 27.75Q111.9 29.29 111.9 32.14L111.9 32.14L111.9 33.55L109.2 33.55L109.2 32.14Q109.2 28.54 105.69 28.54L105.69 28.54Q103.92 28.54 102.99 29.56Q102.06 30.58 102.06 32.5L102.06 32.5L102.06 43 M123 43.24L123 43.24Q120.03 43.24 118.28 41.5Q116.52 39.76 116.52 36.64L116.52 36.64L116.52 32.86Q116.52 29.71 118.26 27.99Q120 26.26 123 26.26L123 26.26Q126 26.26 127.74 27.99Q129.48 29.71 129.48 32.86L129.48 32.86L129.48 36.64Q129.48 39.76 127.73 41.5Q125.97 43.24 123 43.24M123 40.84L123 40.84Q124.77 40.84 125.78 39.85Q126.78 38.86 126.78 36.94L126.78 36.94L126.78 32.56Q126.78 30.64 125.78 29.65Q124.77 28.66 123 28.66L123 28.66Q121.26 28.66 120.24 29.65Q119.22 30.64 119.22 32.56L119.22 32.56L119.22 36.94Q119.22 38.86 120.24 39.85Q121.26 40.84 123 40.84 M136.68 43L133.5 43L139.35 34.51L133.86 26.5L137.04 26.5L140.31 31.6Q140.52 31.93 140.72 32.32Q140.91 32.71 141.03 32.95L141.03 32.95Q141.12 32.71 141.32 32.32Q141.51 31.93 141.72 31.6L141.72 31.6L144.99 26.5L148.17 26.5L142.68 34.54L148.5 43L145.32 43L141.78 37.6Q141.57 37.27 141.38 36.84Q141.18 36.4 141.03 36.13L141.03 36.13Q140.91 36.4 140.69 36.84Q140.46 37.27 140.22 37.6L140.22 37.6L136.68 43" fill="#11161C"/>
|
||||||
|
<path d="M158.76 43L152.25 43L152.25 21.1L158.76 21.1Q160.98 21.1 162.62 21.96Q164.25 22.81 165.15 24.34Q166.05 25.87 166.05 27.94L166.05 27.94L166.05 36.13Q166.05 38.2 165.15 39.74Q164.25 41.29 162.62 42.15Q160.98 43 158.76 43L158.76 43M156 24.46L156 39.64L158.76 39.64Q160.38 39.64 161.34 38.7Q162.3 37.75 162.3 36.13L162.3 36.13L162.3 27.94Q162.3 26.35 161.34 25.41Q160.38 24.46 158.76 24.46L158.76 24.46L156 24.46 M177.03 43.3L177.03 43.3Q174.93 43.3 173.37 42.49Q171.81 41.68 170.96 40.2Q170.1 38.71 170.1 36.7L170.1 36.7L170.1 32.8Q170.1 30.79 170.96 29.31Q171.81 27.82 173.37 27.01Q174.93 26.2 177.03 26.2L177.03 26.2Q179.1 26.2 180.65 27.01Q182.19 27.82 183.05 29.28Q183.9 30.73 183.9 32.68L183.9 32.68L183.9 35.68L173.7 35.68L173.7 36.82Q173.7 38.5 174.57 39.35Q175.44 40.21 177.03 40.21L177.03 40.21Q178.2 40.21 179.03 39.81Q179.85 39.4 180 38.62L180 38.62L183.72 38.62Q183.33 40.75 181.5 42.03Q179.67 43.3 177.03 43.3M173.7 32.68L173.7 32.68L173.7 33.25L180.3 33.22L180.3 32.65Q180.3 30.94 179.46 30.01Q178.62 29.08 177.03 29.08L177.03 29.08Q175.38 29.08 174.54 30.03Q173.7 30.97 173.7 32.68 M192.06 48.4L188.31 48.4L188.31 26.5L191.97 26.5L191.97 29.53Q192.42 27.97 193.59 27.09Q194.76 26.2 196.5 26.2L196.5 26.2Q198.09 26.2 199.31 26.98Q200.52 27.76 201.2 29.19Q201.87 30.61 201.87 32.5L201.87 32.5L201.87 36.97Q201.87 39.82 200.4 41.56Q198.93 43.3 196.5 43.3L196.5 43.3Q194.76 43.3 193.58 42.4Q192.39 41.5 191.94 39.88L191.94 39.88L192.06 43.78L192.06 48.4M195.09 40.06L195.09 40.06Q196.53 40.06 197.33 39.28Q198.12 38.5 198.12 36.88L198.12 36.88L198.12 32.62Q198.12 31 197.33 30.22Q196.53 29.44 195.09 29.44L195.09 29.44Q193.65 29.44 192.86 30.31Q192.06 31.18 192.06 32.77L192.06 32.77L192.06 36.73Q192.06 38.32 192.86 39.19Q193.65 40.06 195.09 40.06 M220.77 43L215.67 43Q213.21 43 211.73 41.53Q210.24 40.06 210.24 37.66L210.24 37.66L210.24 24.49L204.81 24.49L204.81 21.1L213.99 21.1L213.99 37.57Q213.99 38.5 214.52 39.06Q215.04 39.61 215.94 39.61L215.94 39.61L220.77 39.61L220.77 43 M231 43.27L231 43.27Q227.82 43.27 225.96 41.49Q224.1 39.7 224.1 36.67L224.1 36.67L224.1 32.83Q224.1 29.77 225.96 28Q227.82 26.23 231 26.23L231 26.23Q234.18 26.23 236.04 28Q237.9 29.77 237.9 32.83L237.9 32.83L237.9 36.67Q237.9 39.7 236.04 41.49Q234.18 43.27 231 43.27M231 40L231 40Q232.5 40 233.33 39.17Q234.15 38.35 234.15 36.79L234.15 36.79L234.15 32.71Q234.15 31.15 233.33 30.33Q232.5 29.5 231 29.5L231 29.5Q229.5 29.5 228.68 30.33Q227.85 31.15 227.85 32.71L227.85 32.71L227.85 36.79Q227.85 38.35 228.68 39.17Q229.5 40 231 40 M248.88 48.4L244.95 48.4L247.29 42.1L241.08 26.5L245.04 26.5L248.22 34.93Q248.49 35.68 248.76 36.57Q249.03 37.45 249.15 38.2L249.15 38.2Q249.24 37.45 249.5 36.57Q249.75 35.68 250.02 34.93L250.02 34.93L252.96 26.5L256.92 26.5L248.88 48.4" fill="#0F766E"/>
|
||||||
|
<rect x="261.60" y="19.30" width="15.60" height="27.90" fill="#0F766E"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.8 KiB |
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 282 64" width="282" height="64" fill="none">
|
||||||
|
<path d="M32 6 L54.5 19 V45 L32 58 L9.5 45 V19 Z" stroke="#E6EDF3" stroke-width="3.5" stroke-linejoin="round"/>
|
||||||
|
<path d="M32 41 V20 M23.5 28.5 L32 20 L40.5 28.5" stroke="#2DD4BF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M24 47 H40" stroke="#2DD4BF" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
<path d="M83.46 43L80.76 43L80.76 21.1L87.81 21.1Q90.87 21.1 92.69 22.86Q94.5 24.61 94.5 27.55L94.5 27.55Q94.5 30.49 92.69 32.25Q90.87 34 87.81 34L87.81 34L83.46 34L83.46 43M83.46 23.53L83.46 31.57L87.81 31.57Q89.58 31.57 90.65 30.48Q91.71 29.38 91.71 27.55L91.71 27.55Q91.71 25.69 90.65 24.61Q89.58 23.53 87.81 23.53L87.81 23.53L83.46 23.53 M102.06 43L99.36 43L99.36 26.5L102.06 26.5L102.06 29.65L102.12 29.65Q102.33 28.06 103.47 27.13Q104.61 26.2 106.53 26.2L106.53 26.2Q109.11 26.2 110.51 27.75Q111.9 29.29 111.9 32.14L111.9 32.14L111.9 33.55L109.2 33.55L109.2 32.14Q109.2 28.54 105.69 28.54L105.69 28.54Q103.92 28.54 102.99 29.56Q102.06 30.58 102.06 32.5L102.06 32.5L102.06 43 M123 43.24L123 43.24Q120.03 43.24 118.28 41.5Q116.52 39.76 116.52 36.64L116.52 36.64L116.52 32.86Q116.52 29.71 118.26 27.99Q120 26.26 123 26.26L123 26.26Q126 26.26 127.74 27.99Q129.48 29.71 129.48 32.86L129.48 32.86L129.48 36.64Q129.48 39.76 127.73 41.5Q125.97 43.24 123 43.24M123 40.84L123 40.84Q124.77 40.84 125.78 39.85Q126.78 38.86 126.78 36.94L126.78 36.94L126.78 32.56Q126.78 30.64 125.78 29.65Q124.77 28.66 123 28.66L123 28.66Q121.26 28.66 120.24 29.65Q119.22 30.64 119.22 32.56L119.22 32.56L119.22 36.94Q119.22 38.86 120.24 39.85Q121.26 40.84 123 40.84 M136.68 43L133.5 43L139.35 34.51L133.86 26.5L137.04 26.5L140.31 31.6Q140.52 31.93 140.72 32.32Q140.91 32.71 141.03 32.95L141.03 32.95Q141.12 32.71 141.32 32.32Q141.51 31.93 141.72 31.6L141.72 31.6L144.99 26.5L148.17 26.5L142.68 34.54L148.5 43L145.32 43L141.78 37.6Q141.57 37.27 141.38 36.84Q141.18 36.4 141.03 36.13L141.03 36.13Q140.91 36.4 140.69 36.84Q140.46 37.27 140.22 37.6L140.22 37.6L136.68 43" fill="#E6EDF3"/>
|
||||||
|
<path d="M158.76 43L152.25 43L152.25 21.1L158.76 21.1Q160.98 21.1 162.62 21.96Q164.25 22.81 165.15 24.34Q166.05 25.87 166.05 27.94L166.05 27.94L166.05 36.13Q166.05 38.2 165.15 39.74Q164.25 41.29 162.62 42.15Q160.98 43 158.76 43L158.76 43M156 24.46L156 39.64L158.76 39.64Q160.38 39.64 161.34 38.7Q162.3 37.75 162.3 36.13L162.3 36.13L162.3 27.94Q162.3 26.35 161.34 25.41Q160.38 24.46 158.76 24.46L158.76 24.46L156 24.46 M177.03 43.3L177.03 43.3Q174.93 43.3 173.37 42.49Q171.81 41.68 170.96 40.2Q170.1 38.71 170.1 36.7L170.1 36.7L170.1 32.8Q170.1 30.79 170.96 29.31Q171.81 27.82 173.37 27.01Q174.93 26.2 177.03 26.2L177.03 26.2Q179.1 26.2 180.65 27.01Q182.19 27.82 183.05 29.28Q183.9 30.73 183.9 32.68L183.9 32.68L183.9 35.68L173.7 35.68L173.7 36.82Q173.7 38.5 174.57 39.35Q175.44 40.21 177.03 40.21L177.03 40.21Q178.2 40.21 179.03 39.81Q179.85 39.4 180 38.62L180 38.62L183.72 38.62Q183.33 40.75 181.5 42.03Q179.67 43.3 177.03 43.3M173.7 32.68L173.7 32.68L173.7 33.25L180.3 33.22L180.3 32.65Q180.3 30.94 179.46 30.01Q178.62 29.08 177.03 29.08L177.03 29.08Q175.38 29.08 174.54 30.03Q173.7 30.97 173.7 32.68 M192.06 48.4L188.31 48.4L188.31 26.5L191.97 26.5L191.97 29.53Q192.42 27.97 193.59 27.09Q194.76 26.2 196.5 26.2L196.5 26.2Q198.09 26.2 199.31 26.98Q200.52 27.76 201.2 29.19Q201.87 30.61 201.87 32.5L201.87 32.5L201.87 36.97Q201.87 39.82 200.4 41.56Q198.93 43.3 196.5 43.3L196.5 43.3Q194.76 43.3 193.58 42.4Q192.39 41.5 191.94 39.88L191.94 39.88L192.06 43.78L192.06 48.4M195.09 40.06L195.09 40.06Q196.53 40.06 197.33 39.28Q198.12 38.5 198.12 36.88L198.12 36.88L198.12 32.62Q198.12 31 197.33 30.22Q196.53 29.44 195.09 29.44L195.09 29.44Q193.65 29.44 192.86 30.31Q192.06 31.18 192.06 32.77L192.06 32.77L192.06 36.73Q192.06 38.32 192.86 39.19Q193.65 40.06 195.09 40.06 M220.77 43L215.67 43Q213.21 43 211.73 41.53Q210.24 40.06 210.24 37.66L210.24 37.66L210.24 24.49L204.81 24.49L204.81 21.1L213.99 21.1L213.99 37.57Q213.99 38.5 214.52 39.06Q215.04 39.61 215.94 39.61L215.94 39.61L220.77 39.61L220.77 43 M231 43.27L231 43.27Q227.82 43.27 225.96 41.49Q224.1 39.7 224.1 36.67L224.1 36.67L224.1 32.83Q224.1 29.77 225.96 28Q227.82 26.23 231 26.23L231 26.23Q234.18 26.23 236.04 28Q237.9 29.77 237.9 32.83L237.9 32.83L237.9 36.67Q237.9 39.7 236.04 41.49Q234.18 43.27 231 43.27M231 40L231 40Q232.5 40 233.33 39.17Q234.15 38.35 234.15 36.79L234.15 36.79L234.15 32.71Q234.15 31.15 233.33 30.33Q232.5 29.5 231 29.5L231 29.5Q229.5 29.5 228.68 30.33Q227.85 31.15 227.85 32.71L227.85 32.71L227.85 36.79Q227.85 38.35 228.68 39.17Q229.5 40 231 40 M248.88 48.4L244.95 48.4L247.29 42.1L241.08 26.5L245.04 26.5L248.22 34.93Q248.49 35.68 248.76 36.57Q249.03 37.45 249.15 38.2L249.15 38.2Q249.24 37.45 249.5 36.57Q249.75 35.68 250.02 34.93L250.02 34.93L252.96 26.5L256.92 26.5L248.88 48.4" fill="#2DD4BF"/>
|
||||||
|
<rect x="261.60" y="19.30" width="15.60" height="27.90" fill="#2DD4BF"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.8 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" fill="none">
|
||||||
|
<path d="M32 6 L54.5 19 V45 L32 58 L9.5 45 V19 Z" stroke="#11161C" stroke-width="3.5" stroke-linejoin="round"/>
|
||||||
|
<path d="M32 41 V20 M23.5 28.5 L32 20 L40.5 28.5" stroke="#0F766E" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M24 47 H40" stroke="#0F766E" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 434 B |
@@ -1,4 +1,4 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" fill="none">
|
||||||
<path d="M32 6 L54.5 19 V45 L32 58 L9.5 45 V19 Z" stroke="#E6EDF3" stroke-width="3.5" stroke-linejoin="round"/>
|
<path d="M32 6 L54.5 19 V45 L32 58 L9.5 45 V19 Z" stroke="#E6EDF3" stroke-width="3.5" stroke-linejoin="round"/>
|
||||||
<path d="M32 41 V20 M23.5 28.5 L32 20 L40.5 28.5" stroke="#2DD4BF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
<path d="M32 41 V20 M23.5 28.5 L32 20 L40.5 28.5" stroke="#2DD4BF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
<path d="M24 47 H40" stroke="#2DD4BF" stroke-width="4" stroke-linecap="round"/>
|
<path d="M24 47 H40" stroke="#2DD4BF" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 411 B After Width: | Height: | Size: 434 B |
@@ -0,0 +1,296 @@
|
|||||||
|
/**
|
||||||
|
* Erzeugt alle Marken-Dateien unter public/brand/ aus dem Symbol der Vorgabe:
|
||||||
|
*
|
||||||
|
* symbol.svg, symbol-light.svg, icon-small.svg, avatar.svg, logo.svg, logo-light.svg, banner.svg,
|
||||||
|
* favicon-16.png, favicon-32.png, apple-touch-icon.png, icon-192.png, icon-512.png,
|
||||||
|
* icon-maskable-512.png
|
||||||
|
*
|
||||||
|
* Texte (Wortmarke, Banner) werden zu Pfaden – die SVGs sehen damit überall gleich aus, auch ohne
|
||||||
|
* installierte Schrift (README auf Gitea, Social Preview). Die Schriften (JetBrains Mono 2.304,
|
||||||
|
* Space Grotesk 2.0.0, beide SIL OFL) werden dafür einmal geladen, per SHA-256 geprüft und unter
|
||||||
|
* node_modules/.cache abgelegt; eingecheckt werden nur die Ergebnisse.
|
||||||
|
*
|
||||||
|
* pnpm marke
|
||||||
|
*/
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { Resvg } from "@resvg/resvg-js";
|
||||||
|
import opentype from "opentype.js";
|
||||||
|
|
||||||
|
const ZIEL = "public/brand";
|
||||||
|
const CACHE = "node_modules/.cache/proxdeploy-schriften";
|
||||||
|
|
||||||
|
const FARBE = {
|
||||||
|
ink: "#11161C",
|
||||||
|
panel: "#1A2129",
|
||||||
|
linie: "#2A333D",
|
||||||
|
muted: "#8B98A5",
|
||||||
|
paper: "#E6EDF3",
|
||||||
|
teal: "#2DD4BF",
|
||||||
|
tealDunkel: "#0F766E",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Schriften ───────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ARCHIVE = {
|
||||||
|
jetbrains: {
|
||||||
|
url: "https://github.com/JetBrains/JetBrainsMono/releases/download/v2.304/JetBrainsMono-2.304.zip",
|
||||||
|
sha256: "6f6376c6ed2960ea8a963cd7387ec9d76e3f629125bc33d1fdcd7eb7012f7bbf",
|
||||||
|
},
|
||||||
|
grotesk: {
|
||||||
|
url: "https://github.com/floriankarsten/space-grotesk/releases/download/2.0.0/SpaceGrotesk-2.0.0.zip",
|
||||||
|
sha256: "53b415577d4139248555300710bea0d268c7a5be67b93de53b716a9736cabffd",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async function archivHolen(name: keyof typeof ARCHIVE): Promise<string> {
|
||||||
|
const { url, sha256 } = ARCHIVE[name];
|
||||||
|
mkdirSync(CACHE, { recursive: true });
|
||||||
|
const pfad = join(CACHE, `${name}.zip`);
|
||||||
|
if (!existsSync(pfad)) {
|
||||||
|
const antwort = await fetch(url);
|
||||||
|
if (!antwort.ok) throw new Error(`${url}: HTTP ${antwort.status}`);
|
||||||
|
writeFileSync(pfad, Buffer.from(await antwort.arrayBuffer()));
|
||||||
|
}
|
||||||
|
const ist = createHash("sha256").update(readFileSync(pfad)).digest("hex");
|
||||||
|
if (ist !== sha256) throw new Error(`${pfad}: Prüfsumme ${ist} statt ${sha256}`);
|
||||||
|
return pfad;
|
||||||
|
}
|
||||||
|
|
||||||
|
function schriftAus(archiv: string, datei: string): opentype.Font {
|
||||||
|
const daten = execFileSync("unzip", ["-p", archiv, datei], { maxBuffer: 16 * 1024 * 1024 });
|
||||||
|
return opentype.parse(daten.buffer.slice(daten.byteOffset, daten.byteOffset + daten.byteLength));
|
||||||
|
}
|
||||||
|
|
||||||
|
type Schriften = { mono: opentype.Font; monoFett: opentype.Font; grotesk: opentype.Font };
|
||||||
|
|
||||||
|
async function schriftenLaden(): Promise<Schriften> {
|
||||||
|
const jb = await archivHolen("jetbrains");
|
||||||
|
const sg = await archivHolen("grotesk");
|
||||||
|
return {
|
||||||
|
mono: schriftAus(jb, "fonts/ttf/JetBrainsMono-Regular.ttf"),
|
||||||
|
monoFett: schriftAus(jb, "fonts/ttf/JetBrainsMono-Bold.ttf"),
|
||||||
|
grotesk: schriftAus(sg, "SpaceGrotesk-2.0.0/ttf/static/SpaceGrotesk-Regular.ttf"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pfaddaten selbst schreiben: `toPathData` von opentype.js 1.3.5 liefert an manchen Positionen
|
||||||
|
* „NaN“ (beim Runden), und resvg bricht den Pfad dort ab.
|
||||||
|
*/
|
||||||
|
function pfadDaten(pfad: opentype.Path): string {
|
||||||
|
const z = (wert: number) => {
|
||||||
|
if (!Number.isFinite(wert)) throw new Error(`Ungültige Koordinate ${wert}`);
|
||||||
|
return String(Math.round(wert * 100) / 100);
|
||||||
|
};
|
||||||
|
return pfad.commands
|
||||||
|
.map((b) => {
|
||||||
|
switch (b.type) {
|
||||||
|
case "M":
|
||||||
|
case "L":
|
||||||
|
return `${b.type}${z(b.x)} ${z(b.y)}`;
|
||||||
|
case "Q":
|
||||||
|
return `Q${z(b.x1)} ${z(b.y1)} ${z(b.x)} ${z(b.y)}`;
|
||||||
|
case "C":
|
||||||
|
return `C${z(b.x1)} ${z(b.y1)} ${z(b.x2)} ${z(b.y2)} ${z(b.x)} ${z(b.y)}`;
|
||||||
|
default:
|
||||||
|
return "Z";
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Text als Pfad; liefert Pfaddaten und die Breite. */
|
||||||
|
function text(schrift: opentype.Font, inhalt: string, x: number, y: number, groesse: number) {
|
||||||
|
for (const zeichen of inhalt) {
|
||||||
|
if (schrift.charToGlyph(zeichen).index === 0 && zeichen !== " ") {
|
||||||
|
throw new Error(`${schrift.names.fullName?.en}: kein Glyph für „${zeichen}“`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Glyph für Glyph: opentype.js 1.3.5 scheitert an den kontextuellen Ersetzungen (Ligaturen)
|
||||||
|
// von JetBrains Mono; Logo und Banner brauchen keine.
|
||||||
|
const faktor = groesse / schrift.unitsPerEm;
|
||||||
|
let lauf = x;
|
||||||
|
const teile: string[] = [];
|
||||||
|
for (const zeichen of inhalt) {
|
||||||
|
const glyph = schrift.charToGlyph(zeichen);
|
||||||
|
teile.push(pfadDaten(glyph.getPath(lauf, y, groesse)));
|
||||||
|
lauf += (glyph.advanceWidth ?? 0) * faktor;
|
||||||
|
}
|
||||||
|
return { d: teile.filter(Boolean).join(" "), breite: lauf - x };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Symbol ──────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const SECHSECK = "M32 6 L54.5 19 V45 L32 58 L9.5 45 V19 Z";
|
||||||
|
|
||||||
|
function symbolInhalt(rahmen: string, akzent: string): string {
|
||||||
|
return [
|
||||||
|
`<path d="${SECHSECK}" stroke="${rahmen}" stroke-width="3.5" stroke-linejoin="round"/>`,
|
||||||
|
`<path d="M32 41 V20 M23.5 28.5 L32 20 L40.5 28.5" stroke="${akzent}" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,
|
||||||
|
`<path d="M24 47 H40" stroke="${akzent}" stroke-width="4" stroke-linecap="round"/>`,
|
||||||
|
].join("\n ");
|
||||||
|
}
|
||||||
|
|
||||||
|
const svg = (breite: number, hoehe: number, inhalt: string, extra = "") =>
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${breite} ${hoehe}" width="${breite}" height="${hoehe}" fill="none"${extra}>\n ${inhalt}\n</svg>\n`;
|
||||||
|
|
||||||
|
/** Favicon-Variante: ohne Sockel, dicke Linien, passt sich dem hellen oder dunklen Browser an. */
|
||||||
|
function iconKlein(): string {
|
||||||
|
return svg(
|
||||||
|
64,
|
||||||
|
64,
|
||||||
|
[
|
||||||
|
`<style>.h{stroke:${FARBE.paper}}.p{stroke:${FARBE.teal}}@media (prefers-color-scheme: light){.h{stroke:${FARBE.ink}}.p{stroke:${FARBE.tealDunkel}}}</style>`,
|
||||||
|
`<path class="h" d="${SECHSECK}" stroke-width="5.5" stroke-linejoin="round"/>`,
|
||||||
|
`<path class="p" d="M32 43 V18 M22 28 L32 18 L42 28" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/>`,
|
||||||
|
].join("\n "),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Avatar/App-Icon: abgerundetes Quadrat in Teal, Symbol in Ink. `randlos` für maskierbare Icons. */
|
||||||
|
function avatar(randlos = false): string {
|
||||||
|
const hintergrund = randlos
|
||||||
|
? `<rect width="64" height="64" fill="${FARBE.teal}"/>`
|
||||||
|
: `<rect width="64" height="64" rx="14" fill="${FARBE.teal}"/>`;
|
||||||
|
// Maskierbar: Inhalt in der sicheren Zone (80 % in der Mitte).
|
||||||
|
const massstab = randlos ? 0.62 : 0.78;
|
||||||
|
const versatz = 32 * (1 - massstab);
|
||||||
|
return svg(
|
||||||
|
64,
|
||||||
|
64,
|
||||||
|
`${hintergrund}\n <g transform="translate(${versatz} ${versatz}) scale(${massstab})">\n ${symbolInhalt(FARBE.ink, FARBE.ink)}\n </g>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Favicon als Rasterbild (16/32 px): dicke Linien ohne Sockel auf Teal – auf jedem Tab-Hintergrund lesbar. */
|
||||||
|
function faviconRaster(): string {
|
||||||
|
return svg(
|
||||||
|
64,
|
||||||
|
64,
|
||||||
|
[
|
||||||
|
`<rect width="64" height="64" rx="12" fill="${FARBE.teal}"/>`,
|
||||||
|
`<g transform="translate(4 4) scale(0.875)">`,
|
||||||
|
`<path d="${SECHSECK}" stroke="${FARBE.ink}" stroke-width="6" stroke-linejoin="round"/>`,
|
||||||
|
`<path d="M32 43 V18 M22 28 L32 18 L42 28" stroke="${FARBE.ink}" stroke-width="7.5" stroke-linecap="round" stroke-linejoin="round"/>`,
|
||||||
|
`</g>`,
|
||||||
|
].join("\n "),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Logo: Symbol + Wortmarke ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function wortmarke(s: Schriften, x: number, grundlinie: number, groesse: number, hell: boolean) {
|
||||||
|
const prox = text(s.mono, "Prox", x, grundlinie, groesse);
|
||||||
|
const deploy = text(s.monoFett, "Deploy", x + prox.breite, grundlinie, groesse);
|
||||||
|
const versalhoehe = (s.mono.tables["os2"]?.sCapHeight ?? 730) / s.mono.unitsPerEm;
|
||||||
|
const cursorX = x + prox.breite + deploy.breite + groesse * 0.12;
|
||||||
|
const cursorBreite = groesse * 0.52;
|
||||||
|
const cursorOben = grundlinie - versalhoehe * groesse - groesse * 0.06;
|
||||||
|
const cursorHoehe = versalhoehe * groesse + groesse * 0.2;
|
||||||
|
const text1 = hell ? FARBE.ink : FARBE.paper;
|
||||||
|
const akzent = hell ? FARBE.tealDunkel : FARBE.teal;
|
||||||
|
return {
|
||||||
|
inhalt: [
|
||||||
|
`<path d="${prox.d}" fill="${text1}"/>`,
|
||||||
|
`<path d="${deploy.d}" fill="${akzent}"/>`,
|
||||||
|
`<rect x="${cursorX.toFixed(2)}" y="${cursorOben.toFixed(2)}" width="${cursorBreite.toFixed(2)}" height="${cursorHoehe.toFixed(2)}" fill="${akzent}"/>`,
|
||||||
|
].join("\n "),
|
||||||
|
ende: cursorX + cursorBreite,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function logo(s: Schriften, hell: boolean): string {
|
||||||
|
const w = wortmarke(s, 78, 43, 30, hell);
|
||||||
|
const breite = Math.ceil(w.ende + 4);
|
||||||
|
return svg(
|
||||||
|
breite,
|
||||||
|
64,
|
||||||
|
`${symbolInhalt(hell ? FARBE.ink : FARBE.paper, hell ? FARBE.tealDunkel : FARBE.teal)}\n ${w.inhalt}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Banner 1280×640 ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function banner(s: Schriften): string {
|
||||||
|
const teile: string[] = [`<rect width="1280" height="640" fill="${FARBE.ink}"/>`];
|
||||||
|
// Großes, sehr transparentes Sechseck im Hintergrund.
|
||||||
|
teile.push(
|
||||||
|
`<g transform="translate(700 -60) scale(12)" opacity="0.07"><path d="${SECHSECK}" stroke="${FARBE.paper}" stroke-width="1.2" stroke-linejoin="round"/></g>`,
|
||||||
|
);
|
||||||
|
// Logo groß, links.
|
||||||
|
const massstab = 2;
|
||||||
|
teile.push(
|
||||||
|
`<g transform="translate(96 120) scale(${massstab})">\n ${symbolInhalt(FARBE.paper, FARBE.teal)}\n ${wortmarke(s, 78, 43, 30, false).inhalt}\n </g>`,
|
||||||
|
);
|
||||||
|
// Unterzeile in Space Grotesk.
|
||||||
|
const zeile = text(
|
||||||
|
s.grotesk,
|
||||||
|
"VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI.",
|
||||||
|
96,
|
||||||
|
330,
|
||||||
|
32,
|
||||||
|
);
|
||||||
|
if (zeile.breite > 1280 - 2 * 96) throw new Error(`Unterzeile zu breit (${zeile.breite})`);
|
||||||
|
teile.push(`<path d="${zeile.d}" fill="${FARBE.paper}"/>`);
|
||||||
|
// Tags als Outline-Pills.
|
||||||
|
let x = 96;
|
||||||
|
for (const tag of ["Web-GUI", "CLI", "cloud-init", "LXC", "Debian", "Ubuntu"]) {
|
||||||
|
const t = text(s.mono, tag, 0, 0, 20);
|
||||||
|
const breite = t.breite + 36;
|
||||||
|
teile.push(
|
||||||
|
`<rect x="${x}" y="378" width="${breite.toFixed(1)}" height="40" rx="20" stroke="${FARBE.linie}" stroke-width="2"/>`,
|
||||||
|
`<path d="${text(s.mono, tag, x + 18, 405, 20).d}" fill="${FARBE.muted}"/>`,
|
||||||
|
);
|
||||||
|
x += breite + 12;
|
||||||
|
}
|
||||||
|
// Kleine Terminal-Karte rechts unten.
|
||||||
|
const karte = { x: 744, y: 470, b: 440, h: 110 };
|
||||||
|
teile.push(
|
||||||
|
`<rect x="${karte.x}" y="${karte.y}" width="${karte.b}" height="${karte.h}" rx="14" fill="${FARBE.panel}" stroke="${FARBE.linie}" stroke-width="2"/>`,
|
||||||
|
`<path d="${text(s.mono, "# proxdeploy lxc dns01 --profile base", karte.x + 24, karte.y + 44, 18).d}" fill="${FARBE.muted}"/>`,
|
||||||
|
);
|
||||||
|
const haken = text(s.mono, "✓", karte.x + 24, karte.y + 80, 18);
|
||||||
|
teile.push(
|
||||||
|
`<path d="${haken.d}" fill="${FARBE.teal}"/>`,
|
||||||
|
`<path d="${text(s.mono, " dns01 bereit", karte.x + 24 + haken.breite, karte.y + 80, 18).d}" fill="${FARBE.paper}"/>`,
|
||||||
|
);
|
||||||
|
return svg(1280, 640, teile.join("\n "));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PNG ─────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function png(quelle: string, breite: number, datei: string): void {
|
||||||
|
const bild = new Resvg(quelle, { fitTo: { mode: "width", value: breite } }).render();
|
||||||
|
writeFileSync(join(ZIEL, datei), bild.asPng());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const s = await schriftenLaden();
|
||||||
|
mkdirSync(ZIEL, { recursive: true });
|
||||||
|
const dateien: Record<string, string> = {
|
||||||
|
"symbol.svg": svg(64, 64, symbolInhalt(FARBE.paper, FARBE.teal)),
|
||||||
|
"symbol-light.svg": svg(64, 64, symbolInhalt(FARBE.ink, FARBE.tealDunkel)),
|
||||||
|
"icon-small.svg": iconKlein(),
|
||||||
|
"avatar.svg": avatar(),
|
||||||
|
"logo.svg": logo(s, false),
|
||||||
|
"logo-light.svg": logo(s, true),
|
||||||
|
"banner.svg": banner(s),
|
||||||
|
};
|
||||||
|
for (const [name, inhalt] of Object.entries(dateien)) writeFileSync(join(ZIEL, name), inhalt);
|
||||||
|
|
||||||
|
// Raster-Icons aus dem Avatar: auf hellem und dunklem Grund gleich gut sichtbar.
|
||||||
|
png(faviconRaster(), 16, "favicon-16.png");
|
||||||
|
png(faviconRaster(), 32, "favicon-32.png");
|
||||||
|
png(dateien["avatar.svg"]!, 192, "icon-192.png");
|
||||||
|
png(dateien["avatar.svg"]!, 512, "icon-512.png");
|
||||||
|
png(avatar(true), 180, "apple-touch-icon.png");
|
||||||
|
png(avatar(true), 512, "icon-maskable-512.png");
|
||||||
|
// Vorschau des Banners (Social Preview verlangt ein Rasterbild).
|
||||||
|
png(dateien["banner.svg"]!, 1280, "banner.png");
|
||||||
|
console.log(`${Object.keys(dateien).length} SVG und 7 PNG unter ${ZIEL}/ geschrieben`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await main();
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Baut das Laufzeit-Paket von ProxDeploy – dieselbe Struktur für Docker-Image und LXC-Installer:
|
||||||
|
#
|
||||||
|
# paket/
|
||||||
|
# app/ Next standalone (server.js, .next, node_modules), dazu public/, catalog/,
|
||||||
|
# prisma/migrations/ – Arbeitsverzeichnis für app und worker
|
||||||
|
# worker/worker.mjs worker als ein Bundle (esbuild); Pakete aus worker/node_modules
|
||||||
|
# worker/migrieren.mjs Migrationen ohne Prisma-CLI
|
||||||
|
# worker/node_modules/ Produktions-Abhängigkeiten, flach (ohne Symlinks)
|
||||||
|
# VERSION
|
||||||
|
#
|
||||||
|
# scripts/paket-bauen.sh [version] → paket/
|
||||||
|
# scripts/paket-bauen.sh 0.6.0 --tar → zusätzlich proxdeploy-0.6.0.tar.gz
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
version="${1:-$(node -p 'require("./package.json").version')}"
|
||||||
|
commit="$(git rev-parse --short HEAD 2>/dev/null || echo unbekannt)"
|
||||||
|
|
||||||
|
rm -rf paket
|
||||||
|
mkdir -p paket/worker
|
||||||
|
|
||||||
|
# 1. Web-App (standalone). Version und Commit landen im Build (Seitenleiste, /api/v1/ich).
|
||||||
|
NEXT_PUBLIC_APP_VERSION="$version" NEXT_PUBLIC_GIT_SHA="$commit" pnpm build >/dev/null
|
||||||
|
cp -a .next/standalone paket/app
|
||||||
|
cp -a .next/static paket/app/.next/static
|
||||||
|
cp -a public paket/app/public
|
||||||
|
cp -a catalog paket/app/catalog
|
||||||
|
mkdir -p paket/app/prisma && cp -a prisma/migrations paket/app/prisma/migrations
|
||||||
|
|
||||||
|
# 2. worker und Migrationslauf als Bundles; eigener Code (inkl. Prisma-Client aus src/generated)
|
||||||
|
# wird gebündelt, Pakete bleiben extern.
|
||||||
|
for eintrag in "src/worker/main.ts:worker.mjs" "src/betrieb/migrieren-start.ts:migrieren.mjs"; do
|
||||||
|
pnpm exec esbuild "${eintrag%%:*}" --bundle --platform=node --format=esm --target=node24 \
|
||||||
|
--packages=external --log-level=warning \
|
||||||
|
--define:process.env.NEXT_PUBLIC_APP_VERSION="\"$version\"" \
|
||||||
|
--define:process.env.NEXT_PUBLIC_GIT_SHA="\"$commit\"" \
|
||||||
|
--metafile="paket/worker/${eintrag##*:}.meta.json" \
|
||||||
|
--outfile="paket/worker/${eintrag##*:}"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 3. Nur die Pakete, die die Bundles wirklich importieren (aus den esbuild-Metadateien), in den
|
||||||
|
# Versionen aus package.json – flach, ohne Build-Skripte. Next, React & Co. braucht der worker nicht.
|
||||||
|
tmp="$(mktemp -d)"
|
||||||
|
node - "$tmp" paket/worker/*.meta.json <<'JS'
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const [ziel, ...metas] = process.argv.slice(2);
|
||||||
|
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"));
|
||||||
|
const benoetigt = new Set();
|
||||||
|
for (const datei of metas) {
|
||||||
|
const meta = JSON.parse(fs.readFileSync(datei, "utf8"));
|
||||||
|
for (const eingabe of Object.values(meta.inputs))
|
||||||
|
for (const imp of eingabe.imports ?? [])
|
||||||
|
if (imp.external && !imp.path.startsWith("node:")) {
|
||||||
|
const teile = imp.path.split("/");
|
||||||
|
benoetigt.add(imp.path.startsWith("@") ? teile.slice(0, 2).join("/") : teile[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const abhaengigkeiten = {};
|
||||||
|
for (const name of [...benoetigt].sort()) {
|
||||||
|
if (!pkg.dependencies[name]) throw new Error(`${name} wird vom worker importiert, steht aber nicht in dependencies`);
|
||||||
|
// Exakt die Version, die in der Entwicklung installiert ist (aus dem Lockfile aufgelöst).
|
||||||
|
abhaengigkeiten[name] = JSON.parse(fs.readFileSync(`node_modules/${name}/package.json`, "utf8")).version;
|
||||||
|
}
|
||||||
|
fs.writeFileSync(`${ziel}/package.json`, JSON.stringify({ name: "proxdeploy-worker", private: true, type: "module", dependencies: abhaengigkeiten }, null, 2));
|
||||||
|
console.log(`worker braucht: ${Object.entries(abhaengigkeiten).map(([n, v]) => `${n}@${v}`).join(", ")}`);
|
||||||
|
JS
|
||||||
|
rm -f paket/worker/*.meta.json
|
||||||
|
cp pnpm-workspace.yaml "$tmp/"
|
||||||
|
(cd "$tmp" && pnpm install --prod --ignore-scripts --config.node-linker=hoisted >/dev/null)
|
||||||
|
mv "$tmp/node_modules" paket/worker/node_modules
|
||||||
|
rm -rf "$tmp"
|
||||||
|
|
||||||
|
echo "$version+$commit" > paket/VERSION
|
||||||
|
echo "paket/ gebaut ($version+$commit, $(du -sh paket | cut -f1))"
|
||||||
|
|
||||||
|
if [ "${2:-}" = "--tar" ]; then
|
||||||
|
tar -czf "proxdeploy-$version.tar.gz" -C paket .
|
||||||
|
sha256sum "proxdeploy-$version.tar.gz"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* Screenshots für das README aus dem gebauten Stand (`scripts/neu-starten.sh`, Port 3310), dunkles
|
||||||
|
* Theme, 1440 px und 390 px. Ergebnis: docs/bilder/*.png
|
||||||
|
*
|
||||||
|
* pnpm exec tsx scripts/screenshots.ts [auftrag-kurzid]
|
||||||
|
*/
|
||||||
|
import { mkdirSync } from "node:fs";
|
||||||
|
import { chromium, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
const BASIS = process.env["SCREENSHOT_URL"] ?? "http://127.0.0.1:3310";
|
||||||
|
const BENUTZER = process.env["E2E_BENUTZER"] ?? "admin";
|
||||||
|
const PASSWORT = process.env["E2E_PASSWORT"] ?? "proxdeploy-dev-admin";
|
||||||
|
const ZIEL = "docs/bilder";
|
||||||
|
|
||||||
|
async function anmelden(seite: Page) {
|
||||||
|
await seite.goto(`${BASIS}/anmelden`);
|
||||||
|
await seite.getByLabel("Benutzername").fill(BENUTZER);
|
||||||
|
await seite.getByLabel("Passwort").fill(PASSWORT);
|
||||||
|
await seite.getByRole("button", { name: "Anmelden" }).click();
|
||||||
|
await seite.waitForURL((url) => !url.pathname.startsWith("/anmelden"));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bild(seite: Page, name: string, ganz = false) {
|
||||||
|
await seite.waitForLoadState("networkidle");
|
||||||
|
// Blinkender Cursor und Spinner sollen nicht zufällig halb im Bild stehen.
|
||||||
|
await seite.addStyleTag({ content: "*{animation:none!important;transition:none!important}" });
|
||||||
|
await seite.screenshot({ path: `${ZIEL}/${name}.png`, fullPage: ganz });
|
||||||
|
console.log(`${ZIEL}/${name}.png`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const auftrag = process.argv[2];
|
||||||
|
if (!auftrag) throw new Error("Kurz-ID eines erfolgreichen Auftrags angeben (für die Live-Ansicht).");
|
||||||
|
mkdirSync(ZIEL, { recursive: true });
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
try {
|
||||||
|
const kontext = await browser.newContext({
|
||||||
|
viewport: { width: 1440, height: 900 },
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
});
|
||||||
|
await kontext.addCookies([{ name: "pd_theme", value: "dunkel", url: BASIS }]);
|
||||||
|
const seite = await kontext.newPage();
|
||||||
|
await anmelden(seite);
|
||||||
|
|
||||||
|
await seite.goto(`${BASIS}/`);
|
||||||
|
await bild(seite, "uebersicht");
|
||||||
|
|
||||||
|
await seite.goto(`${BASIS}/auftraege/${auftrag}`);
|
||||||
|
await bild(seite, "live-ansicht");
|
||||||
|
|
||||||
|
await seite.goto(`${BASIS}/auftraege`);
|
||||||
|
await bild(seite, "verlauf");
|
||||||
|
|
||||||
|
await seite.goto(`${BASIS}/server/demo`);
|
||||||
|
await bild(seite, "server", true);
|
||||||
|
|
||||||
|
await seite.goto(`${BASIS}/katalog`);
|
||||||
|
await bild(seite, "katalog");
|
||||||
|
|
||||||
|
// Assistent bis zur Zusammenfassung – mit echtem Dry-Run gegen den Server.
|
||||||
|
await seite.goto(`${BASIS}/ausrollen`);
|
||||||
|
await seite.waitForLoadState("networkidle");
|
||||||
|
const weiter = async (bis: number) => {
|
||||||
|
await seite.getByRole("button", { name: "Weiter" }).click();
|
||||||
|
await seite.getByText(`Schritt ${bis} von 9`).waitFor();
|
||||||
|
};
|
||||||
|
for (let s = 2; s <= 4; s++) await weiter(s);
|
||||||
|
await seite.getByLabel("Name (Hostname)").fill("web01");
|
||||||
|
for (let s = 5; s <= 7; s++) await weiter(s);
|
||||||
|
// Ohne hinterlegten Schlüssel einen angeben (öffentlicher Beispielschlüssel).
|
||||||
|
const feld = seite.locator("textarea#schluessel");
|
||||||
|
if ((await feld.inputValue()) === "")
|
||||||
|
await feld.fill(
|
||||||
|
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEFTdkCuqQ6mF7I8QzmkNaQpKj7S/I2+yuxWeYasJjLd florian@laptop",
|
||||||
|
);
|
||||||
|
for (let s = 8; s <= 9; s++) await weiter(s);
|
||||||
|
await seite.getByText("Geplante Proxmox-Aufrufe").waitFor({ timeout: 60_000 });
|
||||||
|
await bild(seite, "assistent-zusammenfassung");
|
||||||
|
|
||||||
|
const handy = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2 });
|
||||||
|
await handy.addCookies([{ name: "pd_theme", value: "dunkel", url: BASIS }]);
|
||||||
|
const klein = await handy.newPage();
|
||||||
|
await anmelden(klein);
|
||||||
|
await klein.goto(`${BASIS}/auftraege/${auftrag}`);
|
||||||
|
await bild(klein, "handy-live-ansicht");
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await main();
|
||||||
@@ -1,16 +1,21 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
import { Karte } from "@/components/karte";
|
import { Karte } from "@/components/karte";
|
||||||
import { Status } from "@/components/status";
|
import { Status } from "@/components/status";
|
||||||
import Link from "next/link";
|
import { ART_TEXT, STATUS_ANZEIGE } from "@/lib/auftrag-text";
|
||||||
|
import { bytes, zeitpunkt } from "@/lib/formate";
|
||||||
import { seiteSchuetzen } from "@/server/auth/zugang";
|
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||||
|
import { auftraegeSuchen } from "@/server/services/auftraege";
|
||||||
import { serverListe } from "@/server/services/server";
|
import { serverListe } from "@/server/services/server";
|
||||||
import { ServerStatus } from "./server/server-status";
|
import { ServerStatus } from "./server/server-status";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Übersicht" };
|
export const metadata: Metadata = { title: "Übersicht" };
|
||||||
|
|
||||||
|
/** Server mit Status und freien Ressourcen (aus dem letzten Verbindungstest), letzte Aufträge. */
|
||||||
export default async function Uebersicht() {
|
export default async function Uebersicht() {
|
||||||
const benutzer = await seiteSchuetzen("lesen", "/");
|
const benutzer = await seiteSchuetzen("lesen", "/");
|
||||||
const server = await serverListe();
|
const [server, { eintraege }] = await Promise.all([serverListe(), auftraegeSuchen({ seite: 1 })]);
|
||||||
|
const letzte = eintraege.slice(0, 8);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
@@ -32,23 +37,73 @@ export default async function Uebersicht() {
|
|||||||
<Status art="offen">Noch kein Proxmox-Server angelegt.</Status>
|
<Status art="offen">Noch kein Proxmox-Server angelegt.</Status>
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-linie flex flex-col divide-y">
|
<ul className="divide-linie flex flex-col divide-y">
|
||||||
{server.map((eintrag) => (
|
{server.map((eintrag) => {
|
||||||
<li
|
const knoten = eintrag.letzterTest?.knoten ?? [];
|
||||||
key={eintrag.name}
|
const online = knoten.filter((k) => k.online);
|
||||||
className="flex items-center justify-between gap-3 py-2.5 first:pt-0 last:pb-0"
|
const frei = online.reduce(
|
||||||
>
|
(s, k) => s + ((k.speicherGesamt ?? 0) - (k.speicherBelegt ?? 0)),
|
||||||
<Link href={`/server/${eintrag.name}`} className="min-w-0 hover:underline">
|
0,
|
||||||
<span className="font-medium">{eintrag.titel}</span>{" "}
|
);
|
||||||
<span className="text-muted font-mono text-sm">{eintrag.name}</span>
|
const cpus = online.reduce((s, k) => s + (k.cpus ?? 0), 0);
|
||||||
</Link>
|
return (
|
||||||
<ServerStatus server={eintrag} />
|
<li key={eintrag.name} className="flex flex-col gap-1 py-2.5 first:pt-0 last:pb-0">
|
||||||
</li>
|
<div className="flex items-center justify-between gap-3">
|
||||||
))}
|
<Link href={`/server/${eintrag.name}`} className="min-w-0 hover:underline">
|
||||||
|
<span className="font-medium">{eintrag.titel}</span>{" "}
|
||||||
|
<span className="text-muted font-mono text-sm">{eintrag.name}</span>
|
||||||
|
</Link>
|
||||||
|
<ServerStatus server={eintrag} />
|
||||||
|
</div>
|
||||||
|
{knoten.length > 0 ? (
|
||||||
|
<p className="text-muted text-xs">
|
||||||
|
{online.length} von {knoten.length} {knoten.length === 1 ? "Node" : "Nodes"} online
|
||||||
|
{online.length > 0 ? ` · ${cpus} CPUs · ${bytes(frei)} RAM frei` : ""}
|
||||||
|
{eintrag.letzterTest?.cluster ? ` · Cluster ${eintrag.letzterTest.cluster.name}` : ""}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</Karte>
|
</Karte>
|
||||||
<Karte titel="Letzte Deployments">
|
<Karte
|
||||||
<Status art="offen">Noch keine Deployments.</Status>
|
titel="Letzte Deployments"
|
||||||
|
aktion={
|
||||||
|
<Link href="/auftraege" className="text-akzent text-sm">
|
||||||
|
Verlauf
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{letzte.length === 0 ? (
|
||||||
|
<Status art="offen">Noch keine Deployments.</Status>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-linie flex flex-col divide-y">
|
||||||
|
{letzte.map((a) => {
|
||||||
|
const [art, text] = STATUS_ANZEIGE[a.status];
|
||||||
|
return (
|
||||||
|
<li key={a.id}>
|
||||||
|
<Link
|
||||||
|
href={`/auftraege/${a.kurzId}`}
|
||||||
|
className="hover:bg-panel-hoch/60 -mx-2 flex items-center justify-between gap-3 rounded-lg px-2 py-2"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 truncate">
|
||||||
|
<span className="font-medium">{a.name ?? ART_TEXT[a.art]}</span>
|
||||||
|
<span className="text-muted text-sm">
|
||||||
|
{" "}
|
||||||
|
{a.art !== "AUSROLLEN" && a.name ? `· ${ART_TEXT[a.art]} ` : ""}· {a.server.name}
|
||||||
|
{a.vmid ? ` · ${a.vmid}` : ""} · {zeitpunkt(a.erstelltAm)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<Status art={art} className="shrink-0 text-sm">
|
||||||
|
{text}
|
||||||
|
</Status>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
</Karte>
|
</Karte>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,15 @@ import { THEME_COOKIE, themeAusCookie } from "@/lib/theme";
|
|||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: { default: "ProxDeploy", template: "%s · ProxDeploy" },
|
title: { default: "ProxDeploy", template: "%s · ProxDeploy" },
|
||||||
description: "VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI.",
|
description: "VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI.",
|
||||||
icons: { icon: "/brand/symbol.svg" },
|
icons: {
|
||||||
|
// SVG passt sich hellem und dunklem Browser an; PNG für Browser ohne SVG-Favicons.
|
||||||
|
icon: [
|
||||||
|
{ url: "/brand/icon-small.svg", type: "image/svg+xml" },
|
||||||
|
{ url: "/brand/favicon-32.png", sizes: "32x32", type: "image/png" },
|
||||||
|
{ url: "/brand/favicon-16.png", sizes: "16x16", type: "image/png" },
|
||||||
|
],
|
||||||
|
apple: { url: "/brand/apple-touch-icon.png", sizes: "180x180" },
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// themeColor verträgt keine CSS-Variablen; Ink ist der Hintergrund des dunklen Themes.
|
// themeColor verträgt keine CSS-Variablen; Ink ist der Hintergrund des dunklen Themes.
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
|
/** Web-App-Manifest unter /manifest.webmanifest – öffentlich (siehe proxy.ts). */
|
||||||
|
export default function manifest(): MetadataRoute.Manifest {
|
||||||
|
return {
|
||||||
|
name: "ProxDeploy",
|
||||||
|
short_name: "ProxDeploy",
|
||||||
|
description: "VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI.",
|
||||||
|
start_url: "/",
|
||||||
|
display: "standalone",
|
||||||
|
background_color: "#11161C",
|
||||||
|
theme_color: "#11161C",
|
||||||
|
lang: "de",
|
||||||
|
icons: [
|
||||||
|
{ src: "/brand/icon-192.png", sizes: "192x192", type: "image/png" },
|
||||||
|
{ src: "/brand/icon-512.png", sizes: "512x512", type: "image/png" },
|
||||||
|
{ src: "/brand/icon-maskable-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* Einstieg für den Betrieb: `node migrieren.mjs` (Docker: Dienst „migrate“, LXC: vor app und worker).
|
||||||
|
* Liest DATABASE_URL und den Ordner der Migrationen (Standard: prisma/migrations neben dem Aufruf).
|
||||||
|
*/
|
||||||
|
import { migrieren } from "./migrieren";
|
||||||
|
|
||||||
|
const url = process.env["DATABASE_URL"];
|
||||||
|
if (!url) {
|
||||||
|
console.error("DATABASE_URL fehlt.");
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
const ordner = process.env["MIGRATIONEN"] ?? "prisma/migrations";
|
||||||
|
try {
|
||||||
|
const { eingespielt, vorhanden } = await migrieren(url, ordner, (text) => console.log(text));
|
||||||
|
console.log(
|
||||||
|
eingespielt.length > 0
|
||||||
|
? `${eingespielt.length} Migration(en) eingespielt, ${vorhanden} waren schon da.`
|
||||||
|
: `Datenbank aktuell (${vorhanden} Migrationen).`,
|
||||||
|
);
|
||||||
|
} catch (fehler) {
|
||||||
|
console.error((fehler as Error).message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
|
import { readdirSync, readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import pg from "pg";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spielt die Migrationen aus `prisma/migrations/` ein – im Betrieb statt `prisma migrate deploy`,
|
||||||
|
* damit weder Docker-Image noch LXC das Prisma-CLI samt Schema-Engine brauchen.
|
||||||
|
*
|
||||||
|
* Kompatibel zu Prisma: dieselbe Tabelle `_prisma_migrations`, Prüfsumme = SHA-256 der
|
||||||
|
* migration.sql. In der Entwicklung bleibt `prisma migrate dev` also benutzbar, und beide
|
||||||
|
* erkennen, was schon eingespielt ist.
|
||||||
|
*
|
||||||
|
* Jede Migration läuft in einer eigenen Transaktion; eine Sperre (`pg_advisory_lock`) verhindert,
|
||||||
|
* dass app und worker gleichzeitig migrieren. Eine eingespielte Migration, deren Datei sich
|
||||||
|
* geändert hat, ist ein Fehler – dann stimmt der Codestand nicht zur Datenbank.
|
||||||
|
*/
|
||||||
|
const SPERRE = 724_196_331; // beliebig, aber fest: „proxdeploy-migration“
|
||||||
|
|
||||||
|
export type MigrationsErgebnis = { eingespielt: string[]; vorhanden: number };
|
||||||
|
|
||||||
|
export async function migrieren(
|
||||||
|
datenbankUrl: string,
|
||||||
|
ordner: string,
|
||||||
|
log: (text: string) => void = () => {},
|
||||||
|
): Promise<MigrationsErgebnis> {
|
||||||
|
const migrationen = readdirSync(ordner, { withFileTypes: true })
|
||||||
|
.filter((e) => e.isDirectory() && /^\d{14}_/.test(e.name))
|
||||||
|
.map((e) => e.name)
|
||||||
|
.sort();
|
||||||
|
const client = new pg.Client({ connectionString: datenbankUrl });
|
||||||
|
await client.connect();
|
||||||
|
try {
|
||||||
|
await client.query("SELECT pg_advisory_lock($1)", [SPERRE]);
|
||||||
|
await client.query(`CREATE TABLE IF NOT EXISTS "_prisma_migrations" (
|
||||||
|
"id" VARCHAR(36) PRIMARY KEY NOT NULL,
|
||||||
|
"checksum" VARCHAR(64) NOT NULL,
|
||||||
|
"finished_at" TIMESTAMPTZ,
|
||||||
|
"migration_name" VARCHAR(255) NOT NULL,
|
||||||
|
"logs" TEXT,
|
||||||
|
"rolled_back_at" TIMESTAMPTZ,
|
||||||
|
"started_at" TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
"applied_steps_count" INTEGER NOT NULL DEFAULT 0
|
||||||
|
)`);
|
||||||
|
const { rows } = await client.query<{
|
||||||
|
migration_name: string;
|
||||||
|
checksum: string;
|
||||||
|
finished_at: Date | null;
|
||||||
|
}>(`SELECT migration_name, checksum, finished_at FROM "_prisma_migrations" WHERE rolled_back_at IS NULL`);
|
||||||
|
const bekannt = new Map(rows.map((r) => [r.migration_name, r]));
|
||||||
|
const unfertig = rows.filter((r) => !r.finished_at);
|
||||||
|
if (unfertig.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Migration ${unfertig.map((r) => r.migration_name).join(", ")} ist unvollständig eingespielt – bitte prüfen.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const eingespielt: string[] = [];
|
||||||
|
for (const name of migrationen) {
|
||||||
|
const sql = readFileSync(join(ordner, name, "migration.sql"));
|
||||||
|
const pruefsumme = createHash("sha256").update(sql).digest("hex");
|
||||||
|
const vorhanden = bekannt.get(name);
|
||||||
|
if (vorhanden) {
|
||||||
|
if (vorhanden.checksum !== pruefsumme) {
|
||||||
|
throw new Error(`Migration ${name} wurde nach dem Einspielen verändert (Prüfsumme weicht ab).`);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
log(`Migration ${name} …`);
|
||||||
|
await client.query("BEGIN");
|
||||||
|
try {
|
||||||
|
const id = randomUUID();
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO "_prisma_migrations" (id, checksum, migration_name, started_at) VALUES ($1, $2, $3, now())`,
|
||||||
|
[id, pruefsumme, name],
|
||||||
|
);
|
||||||
|
await client.query(sql.toString("utf8"));
|
||||||
|
await client.query(
|
||||||
|
`UPDATE "_prisma_migrations" SET finished_at = now(), applied_steps_count = 1 WHERE id = $1`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
await client.query("COMMIT");
|
||||||
|
} catch (fehler) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw new Error(`Migration ${name} fehlgeschlagen: ${(fehler as Error).message}`);
|
||||||
|
}
|
||||||
|
eingespielt.push(name);
|
||||||
|
}
|
||||||
|
return { eingespielt, vorhanden: migrationen.length - eingespielt.length };
|
||||||
|
} finally {
|
||||||
|
await client.query("SELECT pg_advisory_unlock($1)", [SPERRE]).catch(() => {});
|
||||||
|
await client.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
* Version und Git-Stand für Seitenleiste, `/api/v1/ich` und die Kopfzeile der CLI.
|
* Version und Git-Stand für Seitenleiste, `/api/v1/ich` und die Kopfzeile der CLI.
|
||||||
* Beide Werte werden beim Bauen gesetzt (Docker-Build-Arg bzw. Installer).
|
* Beide Werte werden beim Bauen gesetzt (Docker-Build-Arg bzw. Installer).
|
||||||
*/
|
*/
|
||||||
export const VERSION = process.env["NEXT_PUBLIC_APP_VERSION"] ?? "0.5.0";
|
export const VERSION = process.env["NEXT_PUBLIC_APP_VERSION"] ?? "0.6.0";
|
||||||
export const GIT_SHA = (process.env["NEXT_PUBLIC_GIT_SHA"] ?? "").slice(0, 7);
|
export const GIT_SHA = (process.env["NEXT_PUBLIC_GIT_SHA"] ?? "").slice(0, 7);
|
||||||
|
|
||||||
export function versionText(): string {
|
export function versionText(): string {
|
||||||
|
|||||||
@@ -52,4 +52,36 @@ describe.runIf(shellcheckVerfuegbar())("shellcheck", () => {
|
|||||||
})();
|
})();
|
||||||
expect(ausgabe).toBe("");
|
expect(ausgabe).toBe("");
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
|
it("findet in den Betriebsskripten nichts (Installer, Paket, CLI-Build)", () => {
|
||||||
|
const dateien = [
|
||||||
|
"deploy/install-lxc.sh",
|
||||||
|
"deploy/im-container.sh",
|
||||||
|
"scripts/paket-bauen.sh",
|
||||||
|
"scripts/cli-pruefen.sh",
|
||||||
|
"cli/bauen.sh",
|
||||||
|
];
|
||||||
|
const ausgabe = (() => {
|
||||||
|
try {
|
||||||
|
execFileSync(
|
||||||
|
"docker",
|
||||||
|
[
|
||||||
|
"run",
|
||||||
|
"--rm",
|
||||||
|
"-v",
|
||||||
|
`${process.cwd()}:/mnt:ro`,
|
||||||
|
"-w",
|
||||||
|
"/mnt",
|
||||||
|
"koalaman/shellcheck:stable",
|
||||||
|
...dateien,
|
||||||
|
],
|
||||||
|
{ stdio: "pipe" },
|
||||||
|
);
|
||||||
|
return "";
|
||||||
|
} catch (fehler) {
|
||||||
|
return String((fehler as { stdout?: Buffer }).stdout ?? fehler);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
expect(ausgabe).toBe("");
|
||||||
|
}, 60_000);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { Resvg } from "@resvg/resvg-js";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import manifest from "@/app/manifest";
|
||||||
|
import { metadata } from "@/app/layout";
|
||||||
|
|
||||||
|
const ORDNER = "public/brand";
|
||||||
|
|
||||||
|
/** Breite und Höhe aus dem IHDR-Block einer PNG-Datei. */
|
||||||
|
function pngGroesse(datei: string): [number, number] {
|
||||||
|
const daten = readFileSync(join(ORDNER, datei));
|
||||||
|
expect(daten.subarray(1, 4).toString()).toBe("PNG");
|
||||||
|
return [daten.readUInt32BE(16), daten.readUInt32BE(20)];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Marken-Dateien (pnpm marke)", () => {
|
||||||
|
it("alle SVGs der Vorgabe sind da, renderbar und ohne kaputte Koordinaten", () => {
|
||||||
|
const svgs = readdirSync(ORDNER).filter((d) => d.endsWith(".svg"));
|
||||||
|
expect(svgs.sort()).toEqual(
|
||||||
|
[
|
||||||
|
"avatar.svg",
|
||||||
|
"banner.svg",
|
||||||
|
"icon-small.svg",
|
||||||
|
"logo-light.svg",
|
||||||
|
"logo.svg",
|
||||||
|
"symbol-light.svg",
|
||||||
|
"symbol.svg",
|
||||||
|
].sort(),
|
||||||
|
);
|
||||||
|
for (const datei of svgs) {
|
||||||
|
const text = readFileSync(join(ORDNER, datei), "utf8");
|
||||||
|
expect(text, datei).not.toMatch(/NaN|Infinity/);
|
||||||
|
const bild = new Resvg(text).render();
|
||||||
|
expect(bild.width, datei).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
const banner = new Resvg(readFileSync(join(ORDNER, "banner.svg"), "utf8")).render();
|
||||||
|
expect([banner.width, banner.height]).toEqual([1280, 640]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kein Proxmox-Orange und nur Markenfarben", () => {
|
||||||
|
const erlaubt = new Set(["#11161C", "#1A2129", "#2A333D", "#8B98A5", "#E6EDF3", "#2DD4BF", "#0F766E"]);
|
||||||
|
for (const datei of readdirSync(ORDNER).filter((d) => d.endsWith(".svg"))) {
|
||||||
|
for (const [farbe] of readFileSync(join(ORDNER, datei), "utf8").matchAll(/#[0-9A-Fa-f]{6}\b/g)) {
|
||||||
|
expect(erlaubt.has(farbe.toUpperCase()), `${datei}: ${farbe}`).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Rasterbilder haben die richtige Größe; Layout und Manifest zeigen nur auf vorhandene Dateien", () => {
|
||||||
|
expect(pngGroesse("favicon-16.png")).toEqual([16, 16]);
|
||||||
|
expect(pngGroesse("favicon-32.png")).toEqual([32, 32]);
|
||||||
|
expect(pngGroesse("apple-touch-icon.png")).toEqual([180, 180]);
|
||||||
|
expect(pngGroesse("icon-192.png")).toEqual([192, 192]);
|
||||||
|
expect(pngGroesse("icon-512.png")).toEqual([512, 512]);
|
||||||
|
expect(pngGroesse("icon-maskable-512.png")).toEqual([512, 512]);
|
||||||
|
expect(pngGroesse("banner.png")).toEqual([1280, 640]);
|
||||||
|
|
||||||
|
const pfade = [
|
||||||
|
...(manifest().icons ?? []).map((i) => i.src),
|
||||||
|
...JSON.stringify(metadata.icons)
|
||||||
|
.matchAll(/"url":"([^"]+)"/g)
|
||||||
|
.map((t) => t[1]!),
|
||||||
|
];
|
||||||
|
expect(pfade.length).toBeGreaterThan(5);
|
||||||
|
for (const pfad of pfade) expect(existsSync(join("public", pfad)), pfad).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import pg from "pg";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { migrieren } from "@/betrieb/migrieren";
|
||||||
|
import { datenbankVerfuegbar } from "./helpers/db";
|
||||||
|
|
||||||
|
const NAME = "proxdeploy_migtest";
|
||||||
|
|
||||||
|
describe.runIf(datenbankVerfuegbar)("Migrationen im Betrieb (ohne Prisma-CLI)", () => {
|
||||||
|
const basis = new URL(process.env["TEST_DATABASE_URL"]!);
|
||||||
|
const ziel = new URL(basis);
|
||||||
|
ziel.pathname = `/${NAME}`;
|
||||||
|
const verwaltung = async (sql: string) => {
|
||||||
|
const c = new pg.Client({ connectionString: basis.href });
|
||||||
|
await c.connect();
|
||||||
|
try {
|
||||||
|
await c.query(sql);
|
||||||
|
} finally {
|
||||||
|
await c.end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await verwaltung(`DROP DATABASE IF EXISTS ${NAME} WITH (FORCE)`);
|
||||||
|
await verwaltung(`CREATE DATABASE ${NAME}`);
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
await verwaltung(`DROP DATABASE IF EXISTS ${NAME} WITH (FORCE)`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("spielt alle ein, beim zweiten Mal nichts – und Prisma hält den Stand für aktuell", async () => {
|
||||||
|
const erstes = await migrieren(ziel.href, "prisma/migrations");
|
||||||
|
expect(erstes.eingespielt.length).toBeGreaterThan(5);
|
||||||
|
expect(erstes.eingespielt[0]).toMatch(/_grundgeruest$/);
|
||||||
|
expect(await migrieren(ziel.href, "prisma/migrations")).toEqual({
|
||||||
|
eingespielt: [],
|
||||||
|
vorhanden: erstes.eingespielt.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = execFileSync("pnpm", ["exec", "prisma", "migrate", "status"], {
|
||||||
|
env: { ...process.env, DATABASE_URL: ziel.href },
|
||||||
|
encoding: "utf8",
|
||||||
|
});
|
||||||
|
expect(status).toMatch(/Database schema is up to date/);
|
||||||
|
|
||||||
|
// Auch die Regeln aus den Migrationen (Trigger) sind da: das Audit-Log ist nur anhängbar.
|
||||||
|
const c = new pg.Client({ connectionString: ziel.href });
|
||||||
|
await c.connect();
|
||||||
|
try {
|
||||||
|
await c.query(`INSERT INTO "Protokoll" (quelle, aktion) VALUES ('SYSTEM', 'Test')`);
|
||||||
|
await expect(c.query(`DELETE FROM "Protokoll"`)).rejects.toThrow();
|
||||||
|
} finally {
|
||||||
|
await c.end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verweigert, wenn eine eingespielte Migration verändert wurde", async () => {
|
||||||
|
const c = new pg.Client({ connectionString: ziel.href });
|
||||||
|
await c.connect();
|
||||||
|
await c.query(`UPDATE "_prisma_migrations" SET checksum = 'x' WHERE migration_name LIKE '%_server'`);
|
||||||
|
await c.end();
|
||||||
|
await expect(migrieren(ziel.href, "prisma/migrations")).rejects.toThrow(/verändert/);
|
||||||
|
});
|
||||||
|
});
|
||||||