fancy dash
This commit is contained in:
1 parent
3b317a4f0c
commit
ccccdc2031
16 files changed
+1529
-33
No files matched your search
@@ -10,6 +10,47 @@ Jeden kontener buildera buduje z jednego commita RustProxy:
|
||||
|
||||
**Agent Docker jest pakowany wyłącznie na Linux (DEB/RPM); brak paczki FreeBSD pkg dla agenta.** Nie ma Docker-in-Docker, SCP ani osobnego watchera repo OPNsense. FreeBSD sysroot, clang/lld i Rust target są częścią głównego obrazu buildera. OPNsense dostaje osobny publiczny adres. Gałąź Linux, gałąź OPNsense i opcjonalna gałąź natywnego FreeBSD są budowane oraz publikowane niezależnie, więc błąd testów Linux nie blokuje pakietu FreeBSD.
|
||||
|
||||
## Panel repozytorium (RustProxy Package Registry)
|
||||
|
||||
Strony startowe wszystkich trzech repozytoriów działają teraz jako **lekka aplikacja Python** (`dashboard/`) za istniejącym Nginx. Wygląd nawiązuje do panelu RustProxy 0.34.6 (ciemny, jasny i kremowy motyw). **APT, RPM i pkg są nadal obsługiwane bezpośrednio przez Nginx**, więc mechanizm aktualizacji paczek, ścieżki plików, podpisy GPG i atomowe przełączanie `current` nie zmieniają się.
|
||||
|
||||
Panel udostępnia:
|
||||
|
||||
- **Przegląd**: najnowsza wersja, czas buildu, commit, stan Linux / OPNsense / FreeBSD, statystyki i ostatnie paczki.
|
||||
- **Paczki i wersje**: pełny katalog z nazwą, wersją, architekturą, datą, rozmiarem i linkiem pobrania; wyszukiwanie, sortowanie oraz filtry formatów/kanałów.
|
||||
- **Instalacja**: aktualne polecenia dla APT, DNF, OPNsense i natywnego FreeBSD oraz przyciski kopiowania.
|
||||
- **Repozytoria**: linki do metadanych, katalogów i klucza GPG. Automatyczne odświeżanie co **60 sekund** oraz ręczne odświeżenie.
|
||||
|
||||
Adresy paneli (bez zmian domen i portów):
|
||||
|
||||
```text
|
||||
REPOSITORY_URL/ Linux DEB + RPM
|
||||
OPNSENSE_REPOSITORY_URL/ OPNsense pkg
|
||||
OPNSENSE_REPOSITORY_URL/freebsd/ Native FreeBSD pkg (opcjonalnie)
|
||||
```
|
||||
|
||||
Publiczne API, np. `https://twoje-repo.example/api/v1/packages`, zwraca JSON z `builds`, `packages` i `summary`. API jest dostępne na obu hostach. Panel nie zapisuje nic w repo; odczytuje wyłącznie opublikowany snapshot `data/repository/current`. Kontener Python nie publikuje portu hosta i ma dostęp **tylko do odczytu**.
|
||||
|
||||
Nowe buildy zapisują `built_at` w `LINUX-BUILD-INFO.txt` oraz `BUILD-INFO.txt` dla OPNsense/FreeBSD (UTC, ISO 8601). **Dla wydań sprzed tej zmiany** panel pokazuje czas modyfikacji pliku, oznaczony w podpowiedzi; nie twierdzi, że to potwierdzony czas buildu. Przełączanie wersji działa automatycznie i nie wymaga ponownego uruchamiania panelu.
|
||||
|
||||
Uruchomienie po aktualizacji projektu:
|
||||
|
||||
```bash
|
||||
./start.sh
|
||||
# lub ręcznie (przy ustawionym .env):
|
||||
docker compose --env-file .env build repository-dashboard
|
||||
docker compose --env-file .env up -d repository-dashboard repository-http
|
||||
|
||||
# API i zdrowie nginx
|
||||
curl -fsS http://127.0.0.1:18765/api/v1/packages
|
||||
curl -fsS http://127.0.0.1:18765/healthz
|
||||
|
||||
# testy komponentu Python (bez Dockera)
|
||||
python3 -m unittest discover -s tests -p 'test_dashboard.py' -v
|
||||
```
|
||||
|
||||
Panel zależy wyłącznie od biblioteki standardowej Pythona, bez dodatkowych pakietów pip. Jeżeli nie ma jeszcze buildów, pokaże komunikat **brak paczek**, a nie fikcyjne wersje.
|
||||
|
||||
## Szybki start
|
||||
|
||||
```bash
|
||||
|
||||
+17
-8
@@ -1,10 +1,17 @@
|
||||
3d666acb470e1755b030e6316e43c28116a0a283ddd3d447c89c5d49349e58a8 .env.example
|
||||
516d7009f920efa26f60acbd859fa62cd3c7e23953c805173150145b1428a033 .env.example
|
||||
17298e7669ed82b4c98abfce803e1a3f0a265b0abdc2567bf1316aa15e98aad3 Dockerfile
|
||||
1124acac6c0fa0bdd462639a0256e04c432adcf6b5c5399fe7a26d58030115bf README.md
|
||||
ef47c4a3fad51d17ad690b0d6aa440d6ef3e355c5e9bd1824368f5450b4b7957 README.md
|
||||
862263fa1f46c20f0d1e4dac5ffcc75abd55c08211b2c3864c5f8764b9d87793 dashboard/.dockerignore
|
||||
ced0339cb93478c2faf7b7b74215fe03375086e11ca957bd8892b52802fbbd3a dashboard/Dockerfile
|
||||
4bd84a56b756d6109a4fde23c6fcb2fef1b290302312a55046b8487cb9c36d74 dashboard/app.py
|
||||
345dbb2c4293405092445e159ce23053894d0c2335457f7299622ca42382dc05 dashboard/web/app.js
|
||||
f1ac0fb5c4da97db09ff328cb86f4bc763fbdd84d7310d6e8e713be1c8707bb9 dashboard/web/favicon.svg
|
||||
c7427bf31438cbbec9152c93674088bb2a16ea1d9a499b3c5c85beee2c543dda dashboard/web/index.html
|
||||
a3834770bcc9c0548bf387fb52795f3f6cb15b253f7d3819df9be147b04ebb8e dashboard/web/style.css
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 data/.gitkeep
|
||||
654795adee13eb3b86f01b32f4e87ee628e3154a7f04923f7226756136be5fd7 docker-compose.yml
|
||||
328b8b8075208e0c92793cf70c6d8999775faf94abf5d839101e6d3ba35597cf docker-compose.yml
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 gpg/.gitkeep
|
||||
21cfe2c222905dabbf2cb8f64e12d5175c425312679fd873d64098b23a3e9dcc http/nginx.conf
|
||||
7da761300aef6cf22ce28c1c7a55414b692814023e4d540a3f3ec4111c62926c http/nginx.conf
|
||||
77e898aebb54f8acf39d3eb1fb348e7f41532da1b0a9e1820a717fdcde406f71 opnsense/patches/0.20.0/0001-opnsense-build.patch
|
||||
80bb419b980793f3a57b4fda0dc132a872186b3a07e0e6db2585c6057ab4f882 opnsense/plugin/+POST_INSTALL.pre
|
||||
af4bc0ab259df3a0681fea0fcc7d84e08c8b1a1a1eb34300c4a01eccb49fa537 opnsense/plugin/+PRE_DEINSTALL.pre
|
||||
@@ -12,6 +19,7 @@ af4bc0ab259df3a0681fea0fcc7d84e08c8b1a1a1eb34300c4a01eccb49fa537 opnsense/plugi
|
||||
3acf504f1e6a4414f43f051cd1b28f595add3ed7301da091a1379813bef72059 opnsense/plugin/pkg-descr
|
||||
ca979a78c1eef6ebdba9ec89504f5dc9c11d4977b3af999298a0c1a1eb3aba15 opnsense/plugin/src/etc/newsyslog.conf.d/rustproxy.conf
|
||||
3d00d4319af64803f4620f8d5b837a33857f9682ed480b4fa8edf4988e88d014 opnsense/plugin/src/etc/rc.d/rustproxy
|
||||
69a508ddefd342f125d1235b7b590c2d233a1d50adc0372dddee36ef8d65aede opnsense/plugin/src/etc/rc.syshook.d/10-rustproxy.start
|
||||
9bfd871eebb3e499587cf19ddb604f50001b3097f3ae20ecc511f3a1067654e4 opnsense/plugin/src/etc/rc.syshook.d/start/50-rustproxy.start
|
||||
41d5c3d555d57c17d756f80ca0c30bbd256a68e2dcf51dc2d4dc2674a614a3dd opnsense/plugin/src/opnsense/mvc/app/controllers/OPNsense/RustProxy/Api/ServiceController.php
|
||||
85833df45b327bb5d403db252ff25734b9d442f54cee51a9004ee1f889e9a74d opnsense/plugin/src/opnsense/mvc/app/controllers/OPNsense/RustProxy/Api/SettingsController.php
|
||||
@@ -35,9 +43,9 @@ c8dde46217c008bfc31420c19ecbd31e3db6d71ee48f99be856ad40ca3c7f490 packaging/free
|
||||
79122afbd5964a87c0d933abe8b34745d4189e2a586d5918a82efbb9b2f50864 packaging/rustproxy-filesystem-access.md
|
||||
0f7970888117b4bc810ad3f6abc6796f189d34e4863875b2501785e689471be6 packaging/rustproxy.logrotate
|
||||
eee3cb8959cb90cd1b5b320ec4674892e5d1a987049d1028dae39c3bdc9f3dc7 scripts/apply-freebsd-patches.sh
|
||||
33246bb1645d443b0554559e0140695a3e8aafb316c84a90ae670eb2941dce23 scripts/build-freebsd-release.sh
|
||||
2655a96edff5bc07e6a689963c4398fb8c8aabd56014cf1c7edc50211fb883cf scripts/build-linux-release.sh
|
||||
08b0eafa713935ffde6096fe1f371235b07b949c171b8c1674ef747635a8b93c scripts/build-opnsense-release.sh
|
||||
5751d1e6fd0a5b0347ae6f80fb906e4ae306a34b51f0cf6837f2c062b6729f22 scripts/build-freebsd-release.sh
|
||||
d197c25fd346a2cd2a516434a8d9fab304ae6fbf3b215e4f9ea35e6efbc4f417 scripts/build-linux-release.sh
|
||||
01aaa5ecd63e15f3a5436ca394d26308f7616117233dcac8a74a6db73ab68142 scripts/build-opnsense-release.sh
|
||||
5db9f6e5c782bb9afcf455688dea7c1de1d6070c510bbedc9408af5b0fe509b1 scripts/build-opnsense.sh
|
||||
b795c04884f75cae9154af80dfa5ec418b95c8991ed2927b8953c39e3f01a4e8 scripts/build-release.sh
|
||||
1e43a0e6706c0457ab04b155fa566edd509fc52bc4023eb8c44d4271a944e62c scripts/check-and-build.sh
|
||||
@@ -54,7 +62,7 @@ de4fbbb973d9cddb170ef1e9f4f6f7896018ed5e6184594c9636c0215adf5233 scripts/normal
|
||||
b098fa629f9fcc1a54baab3d46b28721eb582c314e084b3210259bf157c124a6 scripts/package-opnsense.sh
|
||||
4ad289bc2c0e400b39a8d1bcf86395d7576c88e71b678ba8b893ccb72ac35465 scripts/prepare-agent-package.sh
|
||||
46908bfac309688340be72edded60efaecd765aa151f520a2491952619f53a43 scripts/prepare-package.sh
|
||||
56b90f4844b55cdfeba341e9d0133114bd28046542611a38c996cc0bb8c5e2a2 start.sh
|
||||
57aae30a7ace2253e80f454e542bccf932e081457482718d7a4822e796b0c882 start.sh
|
||||
f0bde843d75330bc49e3ab5547e739d8fbda8d74d0b12dfdbdb7648847e91e88 tests/check-rustproxy-source.sh
|
||||
a55cc19cba2729ea3e8d82e8e5e90d734ba77c0e0d3f56a1b432e2e7e5316dc5 tests/docker-agent-linux.sh
|
||||
e82c52ef15183a539e96909a39e44db68c8301543950e145008b563524b0b9b9 tests/freebsd-native.sh
|
||||
@@ -62,4 +70,5 @@ e82c52ef15183a539e96909a39e44db68c8301543950e145008b563524b0b9b9 tests/freebsd-
|
||||
c5781dd2335cc85e72daffe807c7ba24481b6c2e8bc95f4012be4ea1baeac9a8 tests/log-rotation.sh
|
||||
aab57cd66dc157dc2359d8d57612c174570a7ea09fe753428dce8faad231ba3c tests/opnsense-plugin.sh
|
||||
1227d503f784f7765c5950b603e4258ea155c2fdc3779886a7b0c403320be2a8 tests/static.sh
|
||||
ff0fe09dcd33124644a9babdf951193ebb358015997b302c7e3775a6c7a4d762 tests/test_dashboard.py
|
||||
c8de9ebbf51e017643d0735f27b960a6cf3c9b7122f6de32858f6b7dcc7ba9e9 tests/toolchains.sh
|
||||
@@ -0,0 +1,2 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -0,0 +1,13 @@
|
||||
FROM python:3.12-alpine
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
REPOSITORY_ROOT=/srv/repository \
|
||||
DASHBOARD_PORT=8000
|
||||
|
||||
WORKDIR /app
|
||||
COPY --chown=10001:10001 app.py /app/app.py
|
||||
COPY --chown=10001:10001 web/ /app/web/
|
||||
USER 10001:10001
|
||||
EXPOSE 8000
|
||||
CMD ["python", "/app/app.py"]
|
||||
@@ -0,0 +1,314 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only, live RustProxy package registry UI.
|
||||
|
||||
The existing Nginx continues to serve APT, DNF and pkg artifacts unchanged.
|
||||
This service only generates the dashboard and a public JSON catalog.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote, urlsplit
|
||||
|
||||
LOG = logging.getLogger("rustproxy.repository")
|
||||
REPOSITORY = Path(os.environ.get("REPOSITORY_ROOT", "/srv/repository"))
|
||||
WEB = Path(__file__).resolve().parent / "web"
|
||||
PORT = int(os.environ.get("DASHBOARD_PORT", "8000"))
|
||||
CACHE_SECONDS = max(1, int(os.environ.get("DASHBOARD_CACHE_SECONDS", "15")))
|
||||
MAX_PACKAGES = 5000
|
||||
|
||||
SITES = ("linux", "opnsense", "freebsd")
|
||||
META_PATHS = {
|
||||
"linux": Path("LINUX-BUILD-INFO.txt"),
|
||||
"opnsense": Path("opnsense/BUILD-INFO.txt"),
|
||||
"freebsd": Path("freebsd/BUILD-INFO.txt"),
|
||||
}
|
||||
REPO_URLS = {
|
||||
"linux": os.environ.get("REPOSITORY_URL", "http://localhost:18765").rstrip("/"),
|
||||
"opnsense": os.environ.get("OPNSENSE_REPOSITORY_URL", "http://localhost:18766").rstrip("/"),
|
||||
}
|
||||
REPO_URLS["freebsd"] = REPO_URLS["opnsense"] + "/freebsd"
|
||||
|
||||
DEB_FILENAME = re.compile(r"^(?P<name>.+?)_(?P<version>[^_]+)_(?P<arch>[^_]+)\.deb$")
|
||||
RPM_FILENAME = re.compile(
|
||||
r"^(?P<name>.+)-(?P<version>\d[^-]*)-(?P<release>[^/]+)\."
|
||||
r"(?P<arch>x86_64|amd64|aarch64|noarch|arm64)\.rpm$"
|
||||
)
|
||||
PKG_FILENAME = re.compile(r"^(?P<name>.+)-(?P<version>\d[^/]*)\.pkg$")
|
||||
|
||||
|
||||
def iso_time(stamp: float) -> str:
|
||||
return datetime.fromtimestamp(stamp, tz=timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
||||
|
||||
|
||||
def parse_metadata(path: Path) -> dict[str, str]:
|
||||
try:
|
||||
# Metadata is emitted by our builder, not submitted through the dashboard.
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except (FileNotFoundError, PermissionError, OSError):
|
||||
return {}
|
||||
info: dict[str, str] = {}
|
||||
for line in text.splitlines():
|
||||
key, separator, value = line.partition("=")
|
||||
if separator and re.fullmatch(r"[a-z][a-z0-9_]*", key):
|
||||
info[key] = value.strip()[:1024]
|
||||
return info
|
||||
|
||||
|
||||
def parse_filename(filename: str, kind: str) -> tuple[str, str, str] | None:
|
||||
if kind == "deb":
|
||||
m = DEB_FILENAME.fullmatch(filename)
|
||||
return (m["name"], m["version"], m["arch"]) if m else None
|
||||
if kind == "rpm":
|
||||
m = RPM_FILENAME.fullmatch(filename)
|
||||
if m:
|
||||
return (m["name"], f"{m['version']}-{m['release']}", m["arch"])
|
||||
return None
|
||||
if filename in ("packagesite.pkg", "data.pkg") or filename.endswith("-latest.pkg"):
|
||||
return None
|
||||
m = PKG_FILENAME.fullmatch(filename)
|
||||
return (m["name"], m["version"], "amd64") if m else None
|
||||
|
||||
|
||||
def publication_info(current: Path, channel: str) -> dict[str, object]:
|
||||
location = current / META_PATHS[channel]
|
||||
raw = parse_metadata(location)
|
||||
info: dict[str, object] = {
|
||||
"channel": channel,
|
||||
"published": bool(raw),
|
||||
"app_version": raw.get("app_version", ""),
|
||||
"package_version": raw.get("package_version", ""),
|
||||
"package_release": raw.get("package_release", ""),
|
||||
"source_commit": raw.get("source_commit", ""),
|
||||
"source_ref": raw.get("source_ref", ""),
|
||||
"source_url": raw.get("source_url", ""),
|
||||
"build_reason": raw.get("build_reason", ""),
|
||||
"packaging_revision": raw.get("packaging_revision", ""),
|
||||
"opnsense_series": raw.get("opnsense_series", ""),
|
||||
"freebsd_abi": raw.get("freebsd_abi", ""),
|
||||
"built_at": None,
|
||||
"date_source": "unknown",
|
||||
}
|
||||
if channel == "linux" and info["app_version"]:
|
||||
info["package_version"] = f"{info['app_version']}-{info['package_release']}" if info["package_release"] else info["app_version"]
|
||||
|
||||
stamp = raw.get("built_at", "")
|
||||
if stamp:
|
||||
try:
|
||||
candidate = datetime.fromisoformat(stamp.replace("Z", "+00:00"))
|
||||
if candidate.tzinfo is not None:
|
||||
info["built_at"] = candidate.astimezone(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
||||
info["date_source"] = "build"
|
||||
except ValueError:
|
||||
pass
|
||||
if info["built_at"] is None and raw:
|
||||
# Backward compatibility with builds made before 'built_at' was saved.
|
||||
try:
|
||||
info["built_at"] = iso_time(location.stat().st_mtime)
|
||||
info["date_source"] = "file"
|
||||
except OSError:
|
||||
pass
|
||||
return info
|
||||
|
||||
|
||||
def get_current(repository: Path) -> Path | None:
|
||||
try:
|
||||
# Never follow a manipulated symlink outside the repository tree.
|
||||
root = repository.resolve(strict=True)
|
||||
current = (root / "current").resolve(strict=True)
|
||||
if current.is_dir() and current.is_relative_to(root):
|
||||
return current
|
||||
except (OSError, RuntimeError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def package_entry(path: Path, current: Path, site: str, kind: str, channel: str,
|
||||
builds: dict[str, dict[str, object]]) -> dict[str, object] | None:
|
||||
parsed = parse_filename(path.name, kind)
|
||||
if not parsed or not path.is_file():
|
||||
return None
|
||||
name, version, arch = parsed
|
||||
try:
|
||||
stat = path.stat()
|
||||
except OSError:
|
||||
return None
|
||||
relative = path.relative_to(current)
|
||||
if site == "linux":
|
||||
public_path = relative
|
||||
else:
|
||||
public_path = relative.relative_to(site)
|
||||
# Each URL segment is encoded independently; separators remain separators.
|
||||
encoded_path = "/".join(quote(p, safe="") for p in public_path.parts)
|
||||
base = REPO_URLS[site]
|
||||
built_at = iso_time(stat.st_mtime)
|
||||
source = "file"
|
||||
build = builds[channel]
|
||||
if build.get("date_source") == "build" and version == build.get("package_version"):
|
||||
built_at = str(build["built_at"])
|
||||
source = "build"
|
||||
return {
|
||||
"name": name,
|
||||
"version": version,
|
||||
"architecture": arch,
|
||||
"format": kind.upper() if kind != "pkg" else "PKG",
|
||||
"channel": channel,
|
||||
"size_bytes": stat.st_size,
|
||||
"built_at": built_at,
|
||||
"date_source": source,
|
||||
"download_url": f"{base}/{encoded_path}",
|
||||
"relative_path": relative.as_posix(),
|
||||
}
|
||||
|
||||
|
||||
def generate_catalog(repository: Path = REPOSITORY) -> dict[str, object]:
|
||||
current = get_current(repository)
|
||||
builds: dict[str, dict[str, object]] = {}
|
||||
packages: list[dict[str, object]] = []
|
||||
for channel in SITES:
|
||||
builds[channel] = publication_info(current, channel) if current else {
|
||||
"channel": channel, "published": False, "app_version": "", "package_version": "",
|
||||
"package_release": "", "source_commit": "", "source_ref": "",
|
||||
"source_url": "", "build_reason": "", "packaging_revision": "",
|
||||
"opnsense_series": "", "freebsd_abi": "", "built_at": None, "date_source": "unknown",
|
||||
}
|
||||
|
||||
if current is not None:
|
||||
folders = (
|
||||
("linux", "deb", "linux", current / "apt" / "pool", "*.deb"),
|
||||
("linux", "rpm", "linux", current / "rpm" / "x86_64", "*.rpm"),
|
||||
("opnsense", "pkg", "opnsense", current / "opnsense", "*.pkg"),
|
||||
("freebsd", "pkg", "freebsd", current / "freebsd", "*.pkg"),
|
||||
)
|
||||
for site, kind, channel, directory, pattern in folders:
|
||||
if not directory.is_dir():
|
||||
continue
|
||||
try:
|
||||
for file in directory.rglob(pattern):
|
||||
result = package_entry(file, current, site, kind, channel, builds)
|
||||
if result:
|
||||
packages.append(result)
|
||||
except OSError as e:
|
||||
LOG.warning("Cannot scan %s: %s", directory, e)
|
||||
|
||||
packages.sort(key=lambda item: (str(item["built_at"]), str(item["version"])), reverse=True)
|
||||
total_packages = len(packages)
|
||||
packages = packages[:MAX_PACKAGES]
|
||||
channel_counts = {site: sum(p["channel"] == site for p in packages) for site in SITES}
|
||||
build_times = [str(b["built_at"]) for b in builds.values() if b["built_at"]]
|
||||
return {
|
||||
"generated_at": iso_time(time.time()),
|
||||
"ready": any(b["published"] for b in builds.values()) or bool(packages),
|
||||
"repository_urls": REPO_URLS,
|
||||
"apt_distribution": os.environ.get("REPO_DISTRIBUTION", "stable"),
|
||||
"apt_component": os.environ.get("REPO_COMPONENT", "main"),
|
||||
"builds": builds,
|
||||
"packages": packages,
|
||||
"summary": {
|
||||
"packages_count": total_packages,
|
||||
"packages_visible": len(packages),
|
||||
"channel_counts": channel_counts,
|
||||
"channels_published": sum(bool(b["published"]) for b in builds.values()),
|
||||
"latest_build_at": max(build_times, default=None),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class CatalogCache:
|
||||
def __init__(self, repository: Path = REPOSITORY, ttl: int = CACHE_SECONDS):
|
||||
self.repository = repository
|
||||
self.ttl = ttl
|
||||
self._lock = threading.Lock()
|
||||
self._expires = 0.0
|
||||
self._snapshot: dict[str, object] | None = None
|
||||
|
||||
def get(self) -> dict[str, object]:
|
||||
with self._lock:
|
||||
if self._snapshot is None or time.monotonic() >= self._expires:
|
||||
self._snapshot = generate_catalog(self.repository)
|
||||
self._expires = time.monotonic() + self.ttl
|
||||
return self._snapshot
|
||||
|
||||
|
||||
CACHE = CatalogCache()
|
||||
|
||||
|
||||
class DashboardHandler(BaseHTTPRequestHandler):
|
||||
server_version = "RustProxyRepository/1.0"
|
||||
sys_version = ""
|
||||
|
||||
def do_GET(self) -> None:
|
||||
path = urlsplit(self.path).path
|
||||
if path == "/healthz":
|
||||
self.send_bytes(b"ok\n", "text/plain; charset=utf-8", cache="no-store")
|
||||
elif path == "/api/v1/packages":
|
||||
try:
|
||||
payload = json.dumps(CACHE.get(), ensure_ascii=False, separators=(",", ":"))
|
||||
except Exception:
|
||||
LOG.exception("Failed to build package catalog")
|
||||
self.send_error(HTTPStatus.INTERNAL_SERVER_ERROR)
|
||||
return
|
||||
self.send_bytes(payload.encode("utf-8"), "application/json; charset=utf-8", cache="no-store")
|
||||
elif path in ("/", "/index.html", "/freebsd/", "/freebsd/index.html"):
|
||||
site = self.headers.get("X-Repo-Site", "linux")
|
||||
if path.startswith("/freebsd/"):
|
||||
site = "freebsd"
|
||||
if site not in SITES:
|
||||
site = "linux"
|
||||
page = (WEB / "index.html").read_text(encoding="utf-8")
|
||||
page = page.replace("__REPOSITORY_SITE__", site)
|
||||
self.send_bytes(page.encode("utf-8"), "text/html; charset=utf-8", cache="no-store", html=True)
|
||||
elif path in ("/ui/style.css", "/ui/app.js", "/ui/favicon.svg"):
|
||||
name = path.rsplit("/", 1)[-1]
|
||||
mime = {"style.css": "text/css", "app.js": "application/javascript", "favicon.svg": "image/svg+xml"}[name]
|
||||
self.send_bytes((WEB / name).read_bytes(), f"{mime}; charset=utf-8", cache="public, max-age=600")
|
||||
else:
|
||||
self.send_error(HTTPStatus.NOT_FOUND, "Unknown dashboard route")
|
||||
|
||||
def do_HEAD(self) -> None:
|
||||
# Nginx allows HEAD and GET; suppress response content for HEAD.
|
||||
self.do_GET()
|
||||
|
||||
def send_bytes(self, payload: bytes, mime: str, *, cache: str, html: bool = False) -> None:
|
||||
self.send_response(HTTPStatus.OK)
|
||||
self.send_header("Content-Type", mime)
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.send_header("Cache-Control", cache)
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.send_header("X-Frame-Options", "DENY")
|
||||
if html:
|
||||
self.send_header("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; "
|
||||
"connect-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'none'; "
|
||||
"frame-ancestors 'none'; form-action 'none'")
|
||||
self.end_headers()
|
||||
if self.command != "HEAD":
|
||||
self.wfile.write(payload)
|
||||
|
||||
def log_message(self, fmt: str, *args: object) -> None:
|
||||
LOG.info("%s - %s", self.address_string(), fmt % args)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
httpd = ThreadingHTTPServer(("0.0.0.0", PORT), DashboardHandler)
|
||||
httpd.daemon_threads = True
|
||||
LOG.info("Dashboard ready on port %s (repository: %s)", PORT, REPOSITORY)
|
||||
try:
|
||||
httpd.serve_forever(poll_interval=0.5)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,383 @@
|
||||
/* RustProxy Package Registry — dependency-free interactive client */
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const el = (id) => document.getElementById(id);
|
||||
const CHANNEL_LABELS = {linux:'Linux',opnsense:'OPNsense',freebsd:'FreeBSD'};
|
||||
const INSTALL_NAMES = {deb:'Debian / Ubuntu',rpm:'RHEL / Rocky / Alma / Fedora',opnsense:'OPNsense',freebsd:'FreeBSD native'};
|
||||
const SECTION_LABELS = {overview:'Przegląd',packages:'Paczki i wersje',install:'Instalacja',repository:'Repozytoria'};
|
||||
const esc = (value) => String(value ?? '').replace(/[&<>"']/g, (char) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[char]));
|
||||
const collator = new Intl.Collator('pl', {numeric:true,sensitivity:'base'});
|
||||
const formatNumber = new Intl.NumberFormat('pl-PL');
|
||||
const site = ['linux','opnsense','freebsd'].includes(document.body.dataset.site) ? document.body.dataset.site : 'linux';
|
||||
const state = {
|
||||
site, catalog:null, section:'overview', installing:site === 'linux' ? 'deb' : site,
|
||||
channel:site === 'linux' ? 'all' : site,
|
||||
format:'all', search:'', sort:'date', rows:25, refreshing:false,
|
||||
};
|
||||
|
||||
const formatDate = (value) => {
|
||||
if (!value) return '—';
|
||||
const time = new Date(value);
|
||||
if (Number.isNaN(time.valueOf())) return '—';
|
||||
return new Intl.DateTimeFormat('pl-PL', {year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}).format(time);
|
||||
};
|
||||
const formatShortDate = (value) => {
|
||||
if (!value) return '—';
|
||||
const time = new Date(value);
|
||||
if (Number.isNaN(time.valueOf())) return '—';
|
||||
return new Intl.DateTimeFormat('pl-PL', {day:'2-digit',month:'short',year:'numeric'}).format(time);
|
||||
};
|
||||
const formatSize = (value) => {
|
||||
const size = Number(value);
|
||||
if (!Number.isFinite(size) || size < 0) return '—';
|
||||
if (size < 1024) return `${formatNumber.format(size)} B`;
|
||||
if (size < 1024 ** 2) return `${(size / 1024).toFixed(1)} KB`;
|
||||
return `${(size / 1024 ** 2).toFixed(1)} MB`;
|
||||
};
|
||||
const safeLink = (url) => {
|
||||
try {
|
||||
const resolved = new URL(url, window.location.href);
|
||||
if (['http:','https:'].includes(resolved.protocol)) return resolved.href;
|
||||
} catch (_) { /* invalid URL */ }
|
||||
return '#';
|
||||
};
|
||||
const origin = (url) => typeof url === 'string' ? url.replace(/\/$/, '') : '';
|
||||
const dateLabel = (kind) => kind === 'build' ? 'Data zapisana przez builder' : 'Data pliku (starsze buildy bez znacznika czasu)';
|
||||
const setText = (id, value) => {const node = el(id);if (node) node.textContent = value;};
|
||||
const versionText = (b) => b?.app_version ? `v${b.app_version}` : '—';
|
||||
|
||||
let toastTimer;
|
||||
function toast(message) {
|
||||
const node = el('toast');
|
||||
node.textContent = message;
|
||||
node.hidden = false;
|
||||
window.clearTimeout(toastTimer);
|
||||
toastTimer = window.setTimeout(() => {node.hidden = true;}, 2700);
|
||||
}
|
||||
function setTheme(theme) {
|
||||
const target = ['dark','light','cream'].includes(theme) ? theme : 'dark';
|
||||
document.documentElement.dataset.theme = target;
|
||||
setText('theme-name', {dark:'Dark',light:'Light',cream:'Cream'}[target]);
|
||||
try {localStorage.setItem('rustproxy-repo-theme', target);} catch (_) { /* optional */ }
|
||||
}
|
||||
function navigate(section) {
|
||||
const next = SECTION_LABELS[section] ? section : 'overview';
|
||||
state.section = next;
|
||||
document.querySelectorAll('.view').forEach((view) => {view.hidden = view.id !== `view-${next}`;});
|
||||
document.querySelectorAll('[data-section]').forEach((btn) => {
|
||||
const active = btn.dataset.section === next;
|
||||
btn.classList.toggle('active', active);
|
||||
if (active) btn.setAttribute('aria-current','page'); else btn.removeAttribute('aria-current');
|
||||
});
|
||||
setText('crumb-section', SECTION_LABELS[next]);
|
||||
if (window.location.hash !== `#${next}`) history.replaceState(null, '', `${location.pathname}${location.search}#${next}`);
|
||||
window.scrollTo({top:0,behavior:'instant'});
|
||||
}
|
||||
|
||||
function buildCard(build, channel) {
|
||||
const live = !!build?.published;
|
||||
const commit = build?.source_commit ? build.source_commit.slice(0,9) : '—';
|
||||
const subtitle = channel === 'linux' ? 'APT / DNF · amd64 + x86_64' : channel === 'opnsense'
|
||||
? `PKG · ${build?.opnsense_series || 'FreeBSD'}` : `PKG · ${build?.freebsd_abi || 'Native'}`;
|
||||
const version = build?.package_version || (live ? build?.app_version : '') || '—';
|
||||
return `<article class="build-card${live ? '' : ' off'}">
|
||||
<div class="build-card-top"><span>${esc(CHANNEL_LABELS[channel].toUpperCase())} / ${channel === 'linux' ? 'SIGNED' : 'PKG'}</span><span class="pill ${live ? 'live' : 'missing'}">${live ? 'PUBLISHED' : 'BRAK BUILDU'}</span></div>
|
||||
<div class="build-card-version">${esc(version)}</div><div class="build-card-sub">${esc(subtitle)}</div>
|
||||
<div class="build-card-details"><div><small>BUILD DATE</small><strong title="${esc(dateLabel(build?.date_source))}">${esc(formatDate(build?.built_at))}</strong></div><div><small>COMMIT</small><strong class="commit">${esc(commit)}</strong></div></div>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function formatBadge(pkg) {
|
||||
return `<span class="format-label${pkg.format === 'PKG' ? ' pkg' : ''}">${esc(pkg.format)}</span>`;
|
||||
}
|
||||
function tableRow(pkg, compact = false) {
|
||||
const download = `<a class="download-link" href="${esc(safeLink(pkg.download_url))}" target="_blank" rel="noopener noreferrer" download title="Pobierz ${esc(pkg.name)} ${esc(pkg.version)}">${compact ? 'Pobierz' : 'Pobierz plik'} <span>↗</span></a>`;
|
||||
const first = `<span class="artifact-name" title="${esc(pkg.name)}">${esc(pkg.name)}</span><span class="artifact-arch">${esc(pkg.architecture)}</span>`;
|
||||
const format = `${formatBadge(pkg)}<span class="channel-tag ${pkg.format === 'PKG' ? 'pkg' : ''}">${esc(CHANNEL_LABELS[pkg.channel] || pkg.channel)}</span>`;
|
||||
const date = `<span class="date-cell" title="${esc(dateLabel(pkg.date_source))}">${esc(formatDate(pkg.built_at))}</span>`;
|
||||
if (compact) return `<tr><td>${first}</td><td>${format}</td><td><span class="version-code">${esc(pkg.version)}</span></td><td>${date}</td><td>${download}</td></tr>`;
|
||||
return `<tr><td>${first}</td><td>${format}</td><td><span class="version-code">${esc(pkg.version)}</span></td><td>${date}</td><td><span class="date-cell">${esc(formatSize(pkg.size_bytes))}</span></td><td>${download}</td></tr>`;
|
||||
}
|
||||
|
||||
function renderOverview(data) {
|
||||
const main = data.builds?.[state.site];
|
||||
const heroVersion = versionText(main);
|
||||
setText('hero-version', heroVersion);
|
||||
setText('hero-channel', state.site.toUpperCase());
|
||||
setText('hero-date', formatDate(main?.built_at));
|
||||
setText('hero-commit', main?.source_commit ? main.source_commit.slice(0,12) : '—');
|
||||
el('hero-date').title = dateLabel(main?.date_source);
|
||||
setText('stat-packages', formatNumber.format(data.summary?.packages_count || 0));
|
||||
setText('stat-channels', `${data.summary?.channels_published || 0} / 3`);
|
||||
setText('stat-date', formatShortDate(data.summary?.latest_build_at));
|
||||
setText('stat-version', heroVersion);
|
||||
setText('stat-release', main?.package_version ? `Pakiet: ${main.package_version}` : 'Brak opublikowanej wersji');
|
||||
setText('stat-date-sub', 'Data ostatniej publikacji');
|
||||
setText('pipeline-updated', formatDate(data.generated_at));
|
||||
const ready = !!data.ready;
|
||||
el('hero-status').innerHTML = `<span class="pulse-dot"></span> ${ready ? 'REPOZYTORIUM AKTYWNE' : 'OCZEKIWANIE NA BUILD'}`;
|
||||
el('hero-status').classList.toggle('waiting', !ready);
|
||||
el('sidebar-status').classList.toggle('waiting', !ready);
|
||||
el('sidebar-status').querySelector('span').textContent = ready ? 'REPOSITORY ONLINE' : 'OCZEKIWANIE NA DANE';
|
||||
const side = el('side-channels');
|
||||
side.innerHTML = ['linux','opnsense','freebsd'].map((channel) => `<span class="side-channel${data.builds[channel]?.published ? '' : ' disabled'}"><i class="tiny-dot"></i>${esc(CHANNEL_LABELS[channel])}<small>${channel === 'linux' ? 'APT / DNF' : 'PKG'}</small></span>`).join('');
|
||||
el('build-grid').innerHTML = ['linux','opnsense','freebsd'].map((channel) => buildCard(data.builds[channel],channel)).join('');
|
||||
const recent = data.packages.slice(0,6);
|
||||
el('recent-rows').innerHTML = recent.length ? recent.map((pkg) => tableRow(pkg,true)).join('') : '<tr><td colspan="5" class="empty-table">Nie opublikowano jeszcze żadnych paczek. Katalog zapełni się po pierwszym buildzie.</td></tr>';
|
||||
}
|
||||
|
||||
function filteredPackages() {
|
||||
const arr = state.catalog?.packages || [];
|
||||
const search = state.search.trim().toLocaleLowerCase('pl-PL');
|
||||
const filtered = arr.filter((pkg) => {
|
||||
if (state.channel !== 'all' && pkg.channel !== state.channel) return false;
|
||||
if (state.format !== 'all' && pkg.format !== state.format) return false;
|
||||
if (!search) return true;
|
||||
return [pkg.name,pkg.version,pkg.channel,pkg.format,pkg.architecture].some((field) => String(field ?? '').toLocaleLowerCase('pl-PL').includes(search));
|
||||
});
|
||||
if (state.sort === 'name') filtered.sort((a,b) => collator.compare(a.name,b.name) || collator.compare(b.version,a.version));
|
||||
if (state.sort === 'version') filtered.sort((a,b) => collator.compare(b.version,a.version) || collator.compare(a.name,b.name));
|
||||
if (state.sort === 'size') filtered.sort((a,b) => b.size_bytes - a.size_bytes);
|
||||
if (state.sort === 'date') filtered.sort((a,b) => String(b.built_at).localeCompare(String(a.built_at)) || collator.compare(b.version,a.version));
|
||||
return filtered;
|
||||
}
|
||||
function renderPackages() {
|
||||
const data = state.catalog;
|
||||
if (!data) return;
|
||||
const all = filteredPackages();
|
||||
const shown = all.slice(0,state.rows);
|
||||
setText('packages-total', `${formatNumber.format(data.summary.packages_count)} paczek`);
|
||||
setText('filter-count', `${formatNumber.format(all.length)} ${all.length === 1 ? 'wynik' : 'wyników'}`);
|
||||
setText('result-range', all.length ? `Wyświetlono ${shown.length} z ${all.length}` : 'Brak pasujących paczek');
|
||||
el('catalog-rows').innerHTML = shown.length ? shown.map((pkg) => tableRow(pkg)).join('') : '<tr><td colspan="6" class="empty-table">Brak paczek spełniających kryteria. Zmień filtry lub poczekaj na publikację.</td></tr>';
|
||||
el('show-more').hidden = shown.length >= all.length;
|
||||
}
|
||||
|
||||
function instructions(kind, data) {
|
||||
const urls = data.repository_urls || {};
|
||||
const linux = origin(urls.linux);
|
||||
const opnsense = origin(urls.opnsense);
|
||||
const freebsd = origin(urls.freebsd);
|
||||
const distribution = data.apt_distribution || 'stable';
|
||||
const component = data.apt_component || 'main';
|
||||
if (kind === 'deb') {
|
||||
return {
|
||||
description:'Dodaj podpisane repozytorium APT, odśwież listę pakietów i zainstaluj RustProxy.',
|
||||
signature:'GPG SIGNED',
|
||||
note:'Obsługiwane: Debian / Ubuntu · architektura amd64.',
|
||||
reference:`${linux}/apt/dists/${encodeURIComponent(distribution)}/InRelease`,
|
||||
agent:'sudo apt install rustproxy-docker-agent',
|
||||
code:[
|
||||
`curl -fsSL ${linux}/repo-key.gpg \\`,
|
||||
' | sudo gpg --dearmor -o /usr/share/keyrings/rustproxy.gpg',
|
||||
'',
|
||||
`echo "deb [signed-by=/usr/share/keyrings/rustproxy.gpg] ${linux}/apt ${distribution} ${component}" \\`,
|
||||
' | sudo tee /etc/apt/sources.list.d/rustproxy.list',
|
||||
'',
|
||||
'sudo apt update',
|
||||
'sudo apt install rustproxy',
|
||||
].join('\n'),
|
||||
};
|
||||
}
|
||||
if (kind === 'rpm') {
|
||||
return {
|
||||
description:'Dodaj podpisane repozytorium DNF/YUM i zainstaluj serwer RustProxy.',
|
||||
signature:'GPG SIGNED',
|
||||
note:'Obsługiwane: RHEL / Rocky / Alma / Fedora · architektura x86_64.',
|
||||
reference:`${linux}/rpm/x86_64/repodata/repomd.xml`,
|
||||
agent:'sudo dnf install rustproxy-docker-agent',
|
||||
code:[
|
||||
`sudo rpm --import ${linux}/repo-key.gpg`,
|
||||
'',
|
||||
"sudo tee /etc/yum.repos.d/rustproxy.repo > /dev/null <<'REPO'",
|
||||
'[rustproxy]',
|
||||
'name=RustProxy Repository',
|
||||
`baseurl=${linux}/rpm/$basearch`,
|
||||
'enabled=1',
|
||||
'gpgcheck=1',
|
||||
'repo_gpgcheck=1',
|
||||
`gpgkey=${linux}/repo-key.gpg`,
|
||||
'REPO',
|
||||
'',
|
||||
'sudo dnf install rustproxy',
|
||||
].join('\n'),
|
||||
};
|
||||
}
|
||||
if (kind === 'opnsense') {
|
||||
return {
|
||||
description:'Pobierz konfigurację repozytorium pkg i zainstaluj plugin os-rustproxy.',
|
||||
signature:'UNSIGNED PKG',
|
||||
note:'Repozytorium pkg nie ma podpisanego katalogu. Korzystaj tylko z zaufanego hosta HTTPS.',
|
||||
reference:`${opnsense}/RustProxy.conf`,
|
||||
agent:'sudo apt install rustproxy-docker-agent',
|
||||
code:[
|
||||
'mkdir -p /usr/local/etc/pkg/repos',
|
||||
`fetch -o /usr/local/etc/pkg/repos/RustProxy.conf ${opnsense}/RustProxy.conf`,
|
||||
'pkg update -f',
|
||||
'pkg install os-rustproxy',
|
||||
].join('\n'),
|
||||
};
|
||||
}
|
||||
return {
|
||||
description:'Zainstaluj natywny pakiet rustproxy i włącz usługę rc.d.',
|
||||
signature:'UNSIGNED PKG',
|
||||
note:'Dostępne po ustawieniu FREEBSD_NATIVE_ENABLED=1. Katalog pkg jest niesygnowany.',
|
||||
reference:`${freebsd}/RustProxy-FreeBSD.conf`,
|
||||
agent:'sudo dnf install rustproxy-docker-agent',
|
||||
code:[
|
||||
'mkdir -p /usr/local/etc/pkg/repos',
|
||||
`fetch -o /usr/local/etc/pkg/repos/RustProxy-FreeBSD.conf ${freebsd}/RustProxy-FreeBSD.conf`,
|
||||
'pkg update -f',
|
||||
'pkg install rustproxy',
|
||||
'sysrc rustproxy_enable=YES',
|
||||
'service rustproxy start',
|
||||
].join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
function renderInstall() {
|
||||
if (!state.catalog) return;
|
||||
const kind = state.installing;
|
||||
const instructionsData = instructions(kind,state.catalog);
|
||||
document.querySelectorAll('[data-install]').forEach((node) => {
|
||||
const active = node.dataset.install === kind;
|
||||
node.classList.toggle('active',active);
|
||||
node.setAttribute('aria-selected', String(active));
|
||||
});
|
||||
setText('install-tab-title', INSTALL_NAMES[kind]);
|
||||
setText('install-description', instructionsData.description);
|
||||
setText('install-signature', instructionsData.signature);
|
||||
el('install-signature').classList.toggle('live', instructionsData.signature === 'GPG SIGNED');
|
||||
setText('install-command', instructionsData.code);
|
||||
setText('install-note', instructionsData.note);
|
||||
setText('agent-command', instructionsData.agent);
|
||||
el('install-reference').href = safeLink(instructionsData.reference);
|
||||
}
|
||||
|
||||
function endpointsCard(name, label, url, links, signed) {
|
||||
return `<div class="endpoint-card">
|
||||
<div class="endpoint-card-top"><h2>${esc(name)}</h2><span class="pill${signed ? ' live' : ''}">${esc(label)}</span></div>
|
||||
<a href="${esc(safeLink(url))}" target="_blank" rel="noopener noreferrer">${esc(url)} ↗</a>
|
||||
<ul>${links.map((link) => `<li><a href="${esc(safeLink(link.url))}" target="_blank" rel="noopener noreferrer"><span>${esc(link.name)}</span><span>↗</span></a></li>`).join('')}</ul>
|
||||
</div>`;
|
||||
}
|
||||
function renderRepositories() {
|
||||
const data = state.catalog;
|
||||
if (!data) return;
|
||||
const url = data.repository_urls;
|
||||
const distribution = encodeURIComponent(data.apt_distribution || 'stable');
|
||||
el('endpoints-grid').innerHTML = [
|
||||
endpointsCard('Linux','GPG SIGNED',url.linux,[
|
||||
{name:'Klucz publiczny GPG',url:`${url.linux}/repo-key.gpg`},
|
||||
{name:'APT InRelease',url:`${url.linux}/apt/dists/${distribution}/InRelease`},
|
||||
{name:'RPM repomd.xml',url:`${url.linux}/rpm/x86_64/repodata/repomd.xml`},
|
||||
{name:'Build info',url:`${url.linux}/LINUX-BUILD-INFO.txt`},
|
||||
],true),
|
||||
endpointsCard('OPNsense','UNSIGNED PKG',url.opnsense,[
|
||||
{name:'RustProxy.conf',url:`${url.opnsense}/RustProxy.conf`},
|
||||
{name:'meta.conf',url:`${url.opnsense}/meta.conf`},
|
||||
{name:'packagesite.pkg',url:`${url.opnsense}/packagesite.pkg`},
|
||||
{name:'Build info',url:`${url.opnsense}/BUILD-INFO.txt`},
|
||||
],false),
|
||||
endpointsCard('FreeBSD','UNSIGNED PKG',url.freebsd,[
|
||||
{name:'RustProxy-FreeBSD.conf',url:`${url.freebsd}/RustProxy-FreeBSD.conf`},
|
||||
{name:'meta.conf',url:`${url.freebsd}/meta.conf`},
|
||||
{name:'packagesite.pkg',url:`${url.freebsd}/packagesite.pkg`},
|
||||
{name:'Build info',url:`${url.freebsd}/BUILD-INFO.txt`},
|
||||
],false),
|
||||
].join('');
|
||||
el('metadata-table').innerHTML = ['linux','opnsense','freebsd'].map((channel) => {
|
||||
const b = data.builds[channel] || {};
|
||||
const rows = [
|
||||
['WERSJA APLIKACJI',b.app_version || '—','latest-value'],
|
||||
['WERSJA PACZKI',b.package_version || '—',''],
|
||||
['DATA BUILDU',formatDate(b.built_at),''],
|
||||
['SOURCE COMMIT',b.source_commit || '—',''],
|
||||
['REWIZJA PAKOWANIA',b.packaging_revision || '—',''],
|
||||
];
|
||||
return `<div class="meta-block"><h3>${esc(CHANNEL_LABELS[channel])}</h3>${rows.map(([label,value,klass]) => `<div class="meta-row"><small>${esc(label)}</small><strong class="${klass}"${label === 'DATA BUILDU' ? ` title="${esc(dateLabel(b.date_source))}"` : ''}>${esc(value)}</strong></div>`).join('')}</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderAll(data) {
|
||||
state.catalog = data;
|
||||
renderOverview(data);
|
||||
renderPackages();
|
||||
renderInstall();
|
||||
renderRepositories();
|
||||
setText('last-sync', formatDate(data.generated_at));
|
||||
setText('footer-time', formatDate(data.generated_at));
|
||||
el('last-sync').innerHTML = `<i></i> ${esc(formatDate(data.generated_at))}`;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (state.refreshing) return;
|
||||
state.refreshing = true;
|
||||
el('refresh').classList.add('busy');
|
||||
el('refresh').disabled = true;
|
||||
try {
|
||||
const response = await fetch('/api/v1/packages', {cache:'no-store',headers:{'Accept':'application/json'}});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const data = await response.json();
|
||||
if (!Array.isArray(data.packages) || !data.builds || !data.summary) throw new Error('Invalid catalog');
|
||||
renderAll(data);
|
||||
el('error-banner').hidden = true;
|
||||
} catch (err) {
|
||||
el('error-banner').hidden = false;
|
||||
setText('last-sync','Błąd odczytu');
|
||||
console.error('RustProxy repository:',err);
|
||||
} finally {
|
||||
state.refreshing = false;
|
||||
el('refresh').classList.remove('busy');
|
||||
el('refresh').disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyText(text) {
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
const input = document.createElement('textarea');
|
||||
input.value = text;
|
||||
input.setAttribute('readonly','');
|
||||
input.className = 'clipboard-helper';
|
||||
document.body.appendChild(input);
|
||||
input.focus();input.select();
|
||||
const copied = document.execCommand('copy');
|
||||
input.remove();
|
||||
if (!copied) throw new Error('Clipboard unsupported');
|
||||
}
|
||||
toast('Skopiowano do schowka');
|
||||
} catch (_) { toast('Nie udało się skopiować — zaznacz polecenia ręcznie'); }
|
||||
}
|
||||
|
||||
function init() {
|
||||
setText('year',new Date().getFullYear());
|
||||
try {setTheme(localStorage.getItem('rustproxy-repo-theme') || 'dark');} catch (_) {setTheme('dark');}
|
||||
document.querySelectorAll('[data-section]').forEach((button) => button.addEventListener('click',() => navigate(button.dataset.section)));
|
||||
document.querySelectorAll('[data-go-section]').forEach((button) => button.addEventListener('click',() => navigate(button.dataset.goSection)));
|
||||
document.querySelectorAll('[data-install]').forEach((button) => button.addEventListener('click',() => {state.installing = button.dataset.install;renderInstall();}));
|
||||
document.querySelectorAll('[data-copy-target]').forEach((button) => button.addEventListener('click',() => copyText(el(button.dataset.copyTarget)?.textContent || '')));
|
||||
el('theme-toggle').addEventListener('click',() => {
|
||||
const themes = ['dark','light','cream'];
|
||||
const i = themes.indexOf(document.documentElement.dataset.theme);
|
||||
setTheme(themes[(i+1)%themes.length]);
|
||||
});
|
||||
el('refresh').addEventListener('click',() => {refresh();});
|
||||
el('retry-inline').addEventListener('click',() => {refresh();});
|
||||
el('search-input').addEventListener('input',(event) => {state.search = event.target.value;state.rows = 25;renderPackages();});
|
||||
el('filter-channel').value = state.channel;
|
||||
el('filter-channel').addEventListener('change',(event) => {state.channel = event.target.value;state.rows = 25;renderPackages();});
|
||||
el('filter-format').addEventListener('change',(event) => {state.format = event.target.value;state.rows = 25;renderPackages();});
|
||||
el('filter-sort').addEventListener('change',(event) => {state.sort = event.target.value;state.rows = 25;renderPackages();});
|
||||
el('show-more').addEventListener('click',() => {state.rows += 25;renderPackages();});
|
||||
window.addEventListener('hashchange',() => navigate(window.location.hash.slice(1)));
|
||||
navigate(window.location.hash.slice(1) || 'overview');
|
||||
refresh();
|
||||
window.setInterval(() => {if (!document.hidden) refresh();}, 60000);
|
||||
document.addEventListener('visibilitychange',() => {if (!document.hidden) refresh();});
|
||||
}
|
||||
init();
|
||||
})();
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="11" fill="#131615"/>
|
||||
<rect x="5" y="5" width="54" height="54" rx="8" fill="none" stroke="#f69b74" stroke-width="2"/>
|
||||
<text x="11" y="42" fill="#f69b74" font-size="27" font-family="monospace" font-weight="bold">r</text>
|
||||
<text x="26" y="42" fill="#bdd293" font-size="26" font-family="monospace" font-weight="bold">/</text>
|
||||
<text x="39" y="42" fill="#f69b74" font-size="27" font-family="monospace" font-weight="bold">p</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 547 B |
@@ -0,0 +1,136 @@
|
||||
<!doctype html>
|
||||
<html lang="pl" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<meta name="theme-color" content="#131615">
|
||||
<title>RustProxy · Package Registry</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/ui/favicon.svg">
|
||||
<link rel="stylesheet" href="/ui/style.css">
|
||||
<script defer src="/ui/app.js"></script>
|
||||
</head>
|
||||
<body data-site="__REPOSITORY_SITE__">
|
||||
<div class="shell">
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<a href="#overview" class="brand" aria-label="RustProxy Package Registry">
|
||||
<span class="brand-mark" aria-hidden="true">r<span>/</span>p</span>
|
||||
<span class="brand-text">RustProxy <small>PACKAGE REGISTRY</small></span>
|
||||
</a>
|
||||
<div class="edition">INDEPENDENT BY DESIGN <span>BUILD DISTRIBUTION</span></div>
|
||||
<div class="sidebar-label">WORKSPACE / 01</div>
|
||||
<nav class="main-navigation" aria-label="Nawigacja panelu">
|
||||
<button class="nav active" data-section="overview" aria-current="page"><span>01</span><b>Przegląd</b><span class="nav-arrow">↗</span></button>
|
||||
<button class="nav" data-section="packages"><span>02</span><b>Paczki i wersje</b><span class="nav-arrow">↗</span></button>
|
||||
<button class="nav" data-section="install"><span>03</span><b>Instalacja</b><span class="nav-arrow">↗</span></button>
|
||||
<button class="nav" data-section="repository"><span>04</span><b>Repozytoria</b><span class="nav-arrow">↗</span></button>
|
||||
</nav>
|
||||
<div class="sidebar-label sidebar-label--second">DOSTĘPNE KANAŁY</div>
|
||||
<div class="side-channels" id="side-channels">
|
||||
<span class="side-channel"><i class="tiny-dot"></i> Linux <small>APT / DNF</small></span>
|
||||
<span class="side-channel"><i class="tiny-dot"></i> OPNsense <small>PKG</small></span>
|
||||
<span class="side-channel"><i class="tiny-dot"></i> FreeBSD <small>PKG</small></span>
|
||||
</div>
|
||||
<div class="sidebar-bottom">
|
||||
<div class="small-label">REPOSITORY ENGINE</div>
|
||||
<p>Python / HTTP API<br>Nginx / Signed APT + RPM<br>FreeBSD pkg catalog</p>
|
||||
<div class="local-tag" id="sidebar-status"><i></i><span>OCZEKIWANIE NA DANE</span></div>
|
||||
<div class="copyright">© RustProxy <span id="year"></span></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="workspace">
|
||||
<header class="topbar">
|
||||
<div class="crumb"><span>RUSTPROXY</span><b>/</b><span>REPOSITORY</span><b>/</b><strong id="crumb-section">Przegląd</strong></div>
|
||||
<div class="top-actions">
|
||||
<span class="live-sync" id="last-sync"><i></i> Odczyt danych…</span>
|
||||
<button class="quiet icon-button" id="refresh" title="Odśwież listę paczek" aria-label="Odśwież dane">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 7v5h-5M4 17v-5h5"/><path d="M5 9a8 8 0 0 1 13.4-2.2L20 12M4 12l1.6 5.2A8 8 0 0 0 19 15"/></svg>
|
||||
</button>
|
||||
<button class="quiet theme-switch" id="theme-toggle" title="Zmień motyw" aria-label="Zmień motyw"><span class="theme-glyph">◐</span><span id="theme-name">Dark</span></button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main-content">
|
||||
<div class="error-banner" id="error-banner" hidden role="status">Nie można pobrać katalogu. Automatyczna próba ponowienia za minutę. <button id="retry-inline">Spróbuj teraz</button></div>
|
||||
|
||||
<section class="view" id="view-overview" aria-labelledby="overview-title">
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<div class="eyebrow"><span class="eyebrow-line"></span> DISTRIBUTION CONTROL ROOM <span class="eyebrow-separator">/</span> LIVE CATALOG</div>
|
||||
<h1 id="overview-title">Pakiety. Wersje.<br><em>Pełna kontrola.</em></h1>
|
||||
<p class="lead">Jedno miejsce dla wydań RustProxy na Linux, OPNsense i FreeBSD. Aktualne buildy, daty publikacji i gotowe instrukcje instalacji.</p>
|
||||
</div>
|
||||
<div class="page-head-side"><div class="subtle-label">STATUS / LIVE DATA</div><span class="active-status" id="hero-status"><span class="pulse-dot"></span> ODCZYTYWANIE</span><div class="muted-detail">Odświeżanie automatyczne co 60 sekund</div></div>
|
||||
</div>
|
||||
|
||||
<div class="hero-grid">
|
||||
<div class="hero-release">
|
||||
<div class="hero-header"><span><span class="dot-amber"></span> NAJNOWSZE WYDANIE <span id="hero-channel">LINUX</span></span><span class="hero-corner">R / P — 01</span></div>
|
||||
<div class="hero-version-label">RUSTPROXY / APPLICATION VERSION</div>
|
||||
<div class="hero-version" id="hero-version">—</div>
|
||||
<div class="hero-foot">
|
||||
<div><small>ZBUDOWANO / OPUBLIKOWANO</small><strong id="hero-date">Brak informacji</strong></div>
|
||||
<div><small>SOURCE COMMIT</small><strong class="mono" id="hero-commit">—</strong></div>
|
||||
<button class="hero-link" data-go-section="packages">Pokaż wydania <span aria-hidden="true">↗</span></button>
|
||||
</div>
|
||||
<div class="hero-background-rings" aria-hidden="true"></div>
|
||||
</div>
|
||||
<div class="pipeline-panel">
|
||||
<div class="pipeline-head"><span class="small-label">BUILD PIPELINE</span><span class="pipeline-live"><i class="tiny-dot"></i> READ ONLY</span></div>
|
||||
<p>Od źródeł do gotowych paczek.</p>
|
||||
<div class="pipeline-list">
|
||||
<div class="pipeline-step"><span class="step-index">01</span><span class="step-info"><b>Git / source</b><small>RustProxy upstream</small></span><span class="step-right">GIT <span>↗</span></span></div>
|
||||
<div class="pipeline-connector"></div>
|
||||
<div class="pipeline-step"><span class="step-index">02</span><span class="step-info"><b>Build / package</b><small>Rust + Go + FreeBSD</small></span><span class="step-right">CI <span>↗</span></span></div>
|
||||
<div class="pipeline-connector"></div>
|
||||
<div class="pipeline-step"><span class="step-index">03</span><span class="step-info"><b>Signed repository</b><small>APT / RPM / PKG</small></span><span class="step-right step-success">LIVE <span>●</span></span></div>
|
||||
</div>
|
||||
<div class="pipeline-footer">Ostatni odczyt <span id="pipeline-updated">—</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid" aria-label="Statystyki repozytorium">
|
||||
<div class="stat"><span>01 / PACZEK W KATALOGU</span><strong id="stat-packages">—</strong><small>Wszystkie opublikowane artefakty</small></div>
|
||||
<div class="stat"><span>02 / AKTYWNE KANAŁY</span><strong id="stat-channels">—</strong><small>Linux · OPNsense · FreeBSD</small></div>
|
||||
<div class="stat"><span>03 / OSTATNI BUILD</span><strong class="stat-date" id="stat-date">—</strong><small id="stat-date-sub">UTC / czas publikacji</small></div>
|
||||
<div class="stat"><span>04 / AKTUALNA WERSJA</span><strong id="stat-version">—</strong><small id="stat-release">Wersja aplikacji</small></div>
|
||||
</div>
|
||||
|
||||
<div class="section-heading"><div><span class="section-index">01 / RELEASE CHANNELS</span><h2>Ostatnie buildy</h2></div><button class="text-link" data-go-section="packages">Cały katalog <span>↗</span></button></div>
|
||||
<div class="build-grid" id="build-grid"><div class="skeleton-card">Ładowanie kanałów…</div></div>
|
||||
|
||||
<div class="section-heading section-heading--second"><div><span class="section-index">02 / RECENT ARTIFACTS</span><h2>Najnowsze paczki</h2></div><button class="text-link" data-go-section="packages">Przeglądaj paczki <span>↗</span></button></div>
|
||||
<div class="card recent-card">
|
||||
<div class="table-scroller"><table class="data-table"><thead><tr><th>PAKIET</th><th>FORMAT / KANAŁ</th><th>WERSJA PACZKI</th><th>DATA BUILDU</th><th class="table-action-col">PLIK</th></tr></thead><tbody id="recent-rows"><tr><td colspan="5" class="empty-table">Ładowanie danych…</td></tr></tbody></table></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="view" id="view-packages" aria-labelledby="packages-title" hidden>
|
||||
<div class="page-head compact-head"><div><div class="eyebrow"><span class="eyebrow-line"></span> ARTIFACT EXPLORER <span class="eyebrow-separator">/</span> VERSIONS</div><h1 id="packages-title">Katalog <em>paczek.</em></h1><p class="lead">Wyszukuj, filtruj, sortuj i pobieraj opublikowane pliki. Wszystkie dane są odczytywane bezpośrednio z aktualnego repozytorium.</p></div><div class="count-pill" id="packages-total">0 paczek</div></div>
|
||||
<div class="filter-panel"><div class="search-field"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m16.5 16.5 4 4"/></svg><input id="search-input" type="search" placeholder="Szukaj nazwy, wersji, architektury..." aria-label="Szukaj paczek" autocomplete="off"></div><label class="filter-input">KANAŁ<select id="filter-channel"><option value="all">Wszystkie</option><option value="linux">Linux</option><option value="opnsense">OPNsense</option><option value="freebsd">FreeBSD</option></select></label><label class="filter-input">FORMAT<select id="filter-format"><option value="all">Wszystkie</option><option value="DEB">DEB</option><option value="RPM">RPM</option><option value="PKG">PKG</option></select></label><label class="filter-input">SORTOWANIE<select id="filter-sort"><option value="date">Najnowsze</option><option value="version">Wersja ↓</option><option value="name">Nazwa A–Z</option><option value="size">Rozmiar ↓</option></select></label></div>
|
||||
<div class="result-topline"><span id="filter-count">Ładowanie…</span><span>Wersje i daty pochodzą z paczek i metadanych buildu</span></div>
|
||||
<div class="card catalog-card"><div class="table-scroller"><table class="data-table data-table--catalog"><thead><tr><th>PAKIET</th><th>KANAŁ</th><th>WERSJA</th><th>DATA BUILDU</th><th>ROZMIAR</th><th>POBIERZ</th></tr></thead><tbody id="catalog-rows"><tr><td colspan="6" class="empty-table">Ładowanie danych…</td></tr></tbody></table></div><div class="catalog-bottom"><span id="result-range">—</span><button class="quiet show-more" id="show-more" hidden>Załaduj kolejne 25 ↓</button></div></div>
|
||||
</section>
|
||||
|
||||
<section class="view" id="view-install" aria-labelledby="install-title" hidden>
|
||||
<div class="page-head compact-head"><div><div class="eyebrow"><span class="eyebrow-line"></span> GET STARTED <span class="eyebrow-separator">/</span> INSTALLATION</div><h1 id="install-title">Zainstaluj <em>RustProxy.</em></h1><p class="lead">Gotowe polecenia dodania repozytorium i instalacji. Wybierz platformę, skopiuj komendy do terminala.</p></div></div>
|
||||
<div class="install-tabs" role="tablist" aria-label="Wybór systemu"><button role="tab" aria-selected="true" data-install="deb" class="install-tab active">Debian / Ubuntu <small>APT · amd64</small></button><button role="tab" aria-selected="false" data-install="rpm" class="install-tab">RHEL / Rocky <small>DNF · x86_64</small></button><button role="tab" aria-selected="false" data-install="opnsense" class="install-tab">OPNsense <small>FreeBSD pkg</small></button><button role="tab" aria-selected="false" data-install="freebsd" class="install-tab">FreeBSD <small>Native pkg</small></button></div>
|
||||
<div class="install-card card"><div class="install-card-head"><div><span class="section-index">TERMINAL / INSTALL</span><h2 id="install-tab-title">Debian / Ubuntu</h2><p id="install-description">Dodanie podpisanego repo APT i instalacja serwera.</p></div><span class="pill" id="install-signature">GPG SIGNED</span></div><div class="code-window"><div class="code-topbar"><span><i class="tiny-dot"></i><i class="tiny-dot"></i><i class="tiny-dot"></i> <b>install.sh</b></span><button class="copy-code" id="copy-install" data-copy-target="install-command">Kopiuj <span>↗</span></button></div><pre><code id="install-command">Przygotowywanie poleceń…</code></pre></div><div class="install-foot"><span id="install-note">Wymagane uprawnienia sudo.</span><a id="install-reference" class="text-link" href="#">Pliki repozytorium ↗</a></div></div>
|
||||
<div class="install-extra-grid"><div class="card hint-card"><span class="hint-icon">01</span><h3>Docker discovery agent</h3><p>Linux ma również osobną paczkę <code>rustproxy-docker-agent</code>. Instalujesz ją niezależnie od głównego serwera.</p><div class="inline-command"><code id="agent-command">sudo apt install rustproxy-docker-agent</code><button data-copy-target="agent-command" class="quiet">Kopiuj</button></div></div><div class="card hint-card"><span class="hint-icon">02</span><h3>Podpisy i zaufanie</h3><p>Repozytoria APT i DNF w builderze są podpisane GPG. Repozytoria OPNsense i natywnego FreeBSD używają obecnie katalogów <code>signature_type: none</code>; korzystaj z zaufanego źródła HTTPS.</p></div></div>
|
||||
</section>
|
||||
|
||||
<section class="view" id="view-repository" aria-labelledby="repository-title" hidden>
|
||||
<div class="page-head compact-head"><div><div class="eyebrow"><span class="eyebrow-line"></span> REPOSITORY ENDPOINTS <span class="eyebrow-separator">/</span> METADATA</div><h1 id="repository-title">Źródła <em>pakietów.</em></h1><p class="lead">Adresy publicznych repozytoriów, podpisów i katalogów paczek. Aktualne metadane pochodzą z opublikowanych buildów.</p></div></div>
|
||||
<div class="endpoints-grid" id="endpoints-grid"></div>
|
||||
<div class="section-heading section-heading--second"><div><span class="section-index">SOURCE TRACE</span><h2>Informacje o wydaniach</h2></div></div>
|
||||
<div class="card metadata-card"><div class="meta-table" id="metadata-table"><span>Wczytywanie…</span></div></div>
|
||||
</section>
|
||||
|
||||
<footer class="page-footer"><span>RUSTPROXY <b>/</b> PACKAGE REGISTRY</span><span>Live catalog · Python / Nginx <b>·</b> <span id="footer-time">—</span></span></footer>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toast" id="toast" role="status" aria-live="polite" hidden></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,390 @@
|
||||
/* RustProxy Package Registry · palette adapted from RustProxy 0.34.6 */
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg:#131615; --panel:#1b1f1d; --panel2:#202622; --line:#323a34;
|
||||
--muted:#a0aaa0; --text:#edf0e9; --accent:#f69b74; --green:#bdd293;
|
||||
--red:#ffaaa2; --sidebar-bg:#101310; --brand-secondary:#cfe0ac;
|
||||
--nav-active-bg:#242b23; --nav-hover-bg:#20251f; --text-subtle:#879082;
|
||||
--separator:#596454; --border-hover:#89917c; --field-bg:#131813;
|
||||
--field-line:#47523f; --placeholder:#778070; --row-line:#2b332c;
|
||||
--outline-line:#717b67; --code-bg:#101810; --notice-text:#c9b99c;
|
||||
--notice-line:#554833; --notice-bg:#29251c; --pill-line:#6d5a45;
|
||||
--pill-live-line:#576847; --shadow:rgba(0,0,0,.22); --accent-ink:#221810;
|
||||
--ring1:rgba(246,155,116,.08); --ring2:rgba(189,210,147,.04);
|
||||
--mono:ui-monospace,SFMono-Regular,Consolas,"Liberation Mono",monospace;
|
||||
--sans:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
|
||||
}
|
||||
html[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--bg:#f4f6f2; --panel:#fff; --panel2:#edf1eb; --line:#cbd3c7;
|
||||
--muted:#5e685f; --text:#1d241f; --accent:#b85b38; --green:#5f783f;
|
||||
--red:#b64a43; --sidebar-bg:#eef2ec; --brand-secondary:#6b8249;
|
||||
--nav-active-bg:#e1e8dc; --nav-hover-bg:#e8ede6; --text-subtle:#687369;
|
||||
--separator:#9ba69b; --border-hover:#839083; --field-bg:#fff;
|
||||
--field-line:#b7c1b5; --placeholder:#7a867b; --row-line:#e0e5dd;
|
||||
--outline-line:#98a497; --code-bg:#edf3e9; --notice-text:#685b45;
|
||||
--notice-line:#d4c2a5; --notice-bg:#fff9ef; --pill-line:#cbb28f;
|
||||
--pill-live-line:#a8ba8d; --shadow:rgba(31,39,33,.1); --accent-ink:#fff;
|
||||
--ring1:rgba(184,91,56,.055); --ring2:rgba(95,120,63,.045);
|
||||
}
|
||||
html[data-theme="cream"] {
|
||||
color-scheme: light;
|
||||
--bg:#f2ede3; --panel:#fcf9f2; --panel2:#eae3d6; --line:#d5cabb;
|
||||
--muted:#6b6257; --text:#302c27; --accent:#8c5942; --green:#5e7655;
|
||||
--red:#a54645; --sidebar-bg:#eae2d5; --brand-secondary:#668367;
|
||||
--nav-active-bg:#e0d3c0; --nav-hover-bg:#e6dccc; --text-subtle:#786e61;
|
||||
--separator:#afa494; --border-hover:#968776; --field-bg:#fffcf7;
|
||||
--field-line:#baad9a; --placeholder:#807466; --row-line:#e2d9cb;
|
||||
--outline-line:#9c8d7c; --code-bg:#f0e8da; --notice-text:#765e40;
|
||||
--notice-line:#d9c3a1; --notice-bg:#f8efdf; --pill-line:#c2a78d;
|
||||
--pill-live-line:#b0bd9d; --shadow:rgba(64,49,34,.08); --accent-ink:#fff;
|
||||
--ring1:rgba(140,89,66,.075); --ring2:rgba(94,118,85,.045);
|
||||
}
|
||||
*,*::before,*::after{box-sizing:border-box}
|
||||
html{scroll-behavior:smooth}
|
||||
body{margin:0;color:var(--text);background:var(--bg);font:13px/1.6 var(--sans);min-width:320px}
|
||||
button,input,select{font:inherit}
|
||||
button{cursor:pointer}
|
||||
button:disabled{opacity:.5;cursor:wait}
|
||||
a{color:inherit;text-decoration:none}
|
||||
a:hover{text-decoration:none}
|
||||
h1,h2,h3,p{margin:0}
|
||||
h1,h2,h3{line-height:1.18}
|
||||
[hidden]{display:none!important}
|
||||
button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
|
||||
::selection{background:var(--accent);color:var(--accent-ink)}
|
||||
.shell{display:grid;grid-template-columns:250px minmax(0,1fr);min-height:100vh}
|
||||
.sidebar{position:sticky;top:0;height:100vh;min-height:600px;display:flex;flex-direction:column;padding:30px 19px 24px;background:var(--sidebar-bg);border-right:1px solid var(--line)}
|
||||
.brand{display:flex;align-items:center;gap:11px;padding:1px 3px;font-size:19px;font-weight:770;letter-spacing:-.8px;line-height:1.12}
|
||||
.brand-mark{width:49px;height:49px;flex:0 0 49px;display:flex;align-items:center;justify-content:center;border:1px solid var(--accent);border-radius:3px;color:var(--accent);font:700 29px var(--mono);letter-spacing:-3px;padding-right:4px}
|
||||
.brand-mark span{color:var(--brand-secondary);font-size:25px}
|
||||
.brand-text{display:block}
|
||||
.brand-text small{display:block;font:9px/1.4 var(--mono);letter-spacing:1.6px;color:var(--green);margin-top:5px}
|
||||
.edition{padding:19px 4px 28px;color:var(--muted);font:9px/1.6 var(--mono);letter-spacing:1.15px;border-bottom:1px solid var(--line)}
|
||||
.edition span{display:block;color:var(--accent);margin-top:5px}
|
||||
.sidebar-label{margin:29px 0 11px 11px;color:var(--text-subtle);font:10px var(--mono);letter-spacing:1px}
|
||||
.main-navigation{display:flex;flex-direction:column;gap:5px}
|
||||
.nav{display:flex;gap:12px;align-items:center;padding:12px 12px 12px 14px;min-height:43px;border:0;border-left:2px solid transparent;color:var(--muted);background:transparent;text-align:left;transition:background .14s ease,color .14s ease;border-radius:2px}
|
||||
.nav>span:first-child{font:10px var(--mono);opacity:.75}
|
||||
.nav b{font-size:12.5px;font-weight:540;flex:1}
|
||||
.nav-arrow{font-size:12px;opacity:0;transition:opacity .14s}
|
||||
.nav:hover{background:var(--nav-hover-bg);color:var(--text)}
|
||||
.nav:hover .nav-arrow{opacity:.9}
|
||||
.nav.active{background:var(--nav-active-bg);color:var(--brand-secondary);border-left-color:var(--brand-secondary)}
|
||||
.nav.active .nav-arrow{opacity:.9}
|
||||
.sidebar-label--second{margin-top:36px}
|
||||
.side-channels{display:flex;flex-direction:column;gap:5px;padding:0 7px}
|
||||
.side-channel{display:flex;align-items:center;gap:11px;color:var(--muted);font-size:12px;padding:7px 8px}
|
||||
.side-channel.disabled .tiny-dot{opacity:.25}
|
||||
.side-channel small{font:10px var(--mono);color:var(--text-subtle);margin-left:auto}
|
||||
.tiny-dot{width:6px;height:6px;display:inline-block;flex-shrink:0;background:var(--green);border-radius:50%}
|
||||
.sidebar-bottom{margin-top:auto;padding:24px 10px 0;border-top:1px solid var(--line)}
|
||||
.small-label{color:var(--muted);font:10px/1.3 var(--mono);letter-spacing:.7px}
|
||||
.sidebar-bottom p{color:var(--text-subtle);font-size:11px;line-height:1.85;margin:12px 0 23px}
|
||||
.local-tag{display:flex;gap:9px;align-items:center;color:var(--green);font:10px var(--mono);letter-spacing:.4px}
|
||||
.local-tag i,.live-sync i{width:6px;height:6px;border-radius:50%;background:var(--green);display:inline-block;flex:none}
|
||||
.local-tag.waiting{color:var(--muted)}
|
||||
.local-tag.waiting i{background:var(--muted)}
|
||||
.copyright{margin-top:30px;color:var(--text-subtle);font:10px var(--mono)}
|
||||
.workspace{min-width:0}
|
||||
.topbar{height:70px;padding:0 clamp(25px,4.3vw,72px);border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;gap:14px;background:var(--bg);position:sticky;top:0;z-index:10}
|
||||
.crumb{display:flex;gap:13px;align-items:center;color:var(--muted);font:10px var(--mono);letter-spacing:1px;white-space:nowrap}
|
||||
.crumb b{color:var(--separator);font-weight:400}
|
||||
.crumb strong{color:var(--text);font-weight:500}
|
||||
.top-actions{display:flex;gap:9px;align-items:center}
|
||||
.live-sync{margin-right:11px;color:var(--muted);display:flex;align-items:center;gap:10px;font:10px var(--mono);white-space:nowrap}
|
||||
.quiet{color:var(--muted);background:transparent;border:1px solid var(--line);border-radius:3px;padding:8px 12px;white-space:nowrap;transition:color .14s,border-color .14s}
|
||||
.quiet:hover{color:var(--text);border-color:var(--border-hover)}
|
||||
.icon-button{display:inline-flex;align-items:center;justify-content:center;width:36px;height:35px;padding:0}
|
||||
.icon-button.busy svg{animation:spin .9s linear infinite}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
.theme-switch{height:35px;display:flex;align-items:center;gap:8px;padding:0 12px}
|
||||
.theme-glyph{font-size:18px;line-height:1;color:var(--accent)}
|
||||
.main-content{padding:47px clamp(25px,4.3vw,72px) 25px;max-width:1660px;margin:0 auto}
|
||||
.view{min-height:55vh}
|
||||
.page-head{display:flex;justify-content:space-between;align-items:flex-end;gap:24px;margin-bottom:29px}
|
||||
.page-head h1{font-size:clamp(34px,3.8vw,54px);font-weight:550;letter-spacing:-2.3px;line-height:1.1}
|
||||
.page-head h1 em{font-style:normal;color:var(--accent)}
|
||||
.eyebrow{display:flex;align-items:center;gap:9px;font:10px var(--mono);color:var(--green);letter-spacing:1.9px;margin-bottom:21px}
|
||||
.eyebrow-line{height:1px;width:20px;background:var(--green)}
|
||||
.eyebrow-separator{color:var(--separator);margin:0 1px}
|
||||
.lead{max-width:680px;color:var(--muted);font-size:13px;line-height:1.82;margin-top:17px}
|
||||
.page-head-side{align-self:flex-end;text-align:right;padding-bottom:4px;flex-shrink:0}
|
||||
.subtle-label{color:var(--text-subtle);font:9px var(--mono);letter-spacing:1px;margin-bottom:8px}
|
||||
.active-status{display:inline-flex;gap:9px;align-items:center;font:10px var(--mono);letter-spacing:.8px;color:var(--green);border:1px solid var(--pill-live-line);padding:7px 10px}
|
||||
.pulse-dot{width:7px;height:7px;border-radius:50%;background:var(--green);box-shadow:0 0 0 4px color-mix(in srgb,var(--green) 12%,transparent)}
|
||||
.active-status.waiting{color:var(--muted);border-color:var(--line)}
|
||||
.active-status.waiting .pulse-dot{background:var(--muted);box-shadow:none}
|
||||
.muted-detail{font:10px var(--mono);color:var(--text-subtle);margin-top:10px}
|
||||
.hero-grid{display:grid;grid-template-columns:minmax(0,1.62fr) minmax(270px,.85fr);gap:16px;align-items:stretch}
|
||||
.hero-release{position:relative;isolation:isolate;overflow:hidden;min-height:316px;padding:26px 29px 22px;border:1px solid var(--line);border-top:2px solid var(--accent);background:radial-gradient(ellipse at 93% 30%,var(--ring1),transparent 50%),radial-gradient(ellipse at 30% 110%,var(--ring2),transparent 60%),var(--panel)}
|
||||
.hero-header{display:flex;justify-content:space-between;align-items:center;gap:10px;color:var(--green);font:10px var(--mono);letter-spacing:1px;position:relative;z-index:1}
|
||||
.hero-header>span:first-child{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||||
.dot-amber{width:6px;height:6px;border-radius:50%;background:var(--accent)}
|
||||
.hero-header #hero-channel{color:var(--accent)}
|
||||
.hero-corner{color:var(--text-subtle)}
|
||||
.hero-version-label{color:var(--text-subtle);font:10px var(--mono);letter-spacing:1.6px;margin-top:47px;position:relative;z-index:1}
|
||||
.hero-version{font:600 clamp(64px,7vw,108px)/1.1 var(--mono);letter-spacing:-6px;color:var(--text);margin-top:3px;position:relative;z-index:1;overflow-wrap:anywhere}
|
||||
.hero-foot{border-top:1px solid var(--line);padding-top:20px;margin-top:21px;display:flex;align-items:flex-end;justify-content:space-between;gap:12px;position:relative;z-index:1}
|
||||
.hero-foot>div{display:flex;flex-direction:column;gap:5px;min-width:0}
|
||||
.hero-foot small{font:9px var(--mono);letter-spacing:1px;color:var(--text-subtle)}
|
||||
.hero-foot strong{font:12px var(--sans);font-weight:550;color:var(--text);white-space:nowrap}
|
||||
.hero-foot strong.mono{font:12px var(--mono);color:var(--green)}
|
||||
.hero-link{border:1px solid var(--accent);background:var(--accent);color:var(--accent-ink);font-weight:650;padding:11px 15px;white-space:nowrap;border-radius:3px;transition:filter .15s}
|
||||
.hero-link:hover{filter:brightness(1.09)}
|
||||
.hero-link span{margin-left:8px}
|
||||
.hero-background-rings{pointer-events:none;position:absolute;right:-150px;top:22px;width:420px;height:420px;border:1px solid color-mix(in srgb,var(--green) 10%,transparent);border-radius:50%;z-index:0}
|
||||
.hero-background-rings::after{content:"";position:absolute;inset:44px;border:1px solid color-mix(in srgb,var(--accent) 10%,transparent);border-radius:50%}
|
||||
.pipeline-panel{background:var(--panel);border:1px solid var(--line);padding:24px 24px 15px;min-height:316px;display:flex;flex-direction:column}
|
||||
.pipeline-head{display:flex;justify-content:space-between;align-items:center;gap:10px}
|
||||
.pipeline-live{font:9px var(--mono);color:var(--green);display:flex;align-items:center;gap:8px}
|
||||
.pipeline-panel>p{font-size:12px;color:var(--muted);margin:17px 0 20px}
|
||||
.pipeline-list{display:flex;flex-direction:column}
|
||||
.pipeline-step{display:flex;align-items:center;gap:14px;min-width:0}
|
||||
.step-index{width:35px;height:35px;flex-shrink:0;background:var(--panel2);border:1px solid var(--line);display:grid;place-items:center;color:var(--accent);font:11px var(--mono)}
|
||||
.step-info{display:flex;flex-direction:column;gap:2px;min-width:0;flex:1}
|
||||
.step-info b{font-weight:560;font-size:12px}
|
||||
.step-info small{color:var(--text-subtle);font:10px var(--mono)}
|
||||
.step-right{font:10px var(--mono);color:var(--muted)}
|
||||
.step-right span{color:var(--separator);margin-left:3px}
|
||||
.step-right.step-success{color:var(--green)}
|
||||
.step-right.step-success span{font-size:8px;color:var(--green)}
|
||||
.pipeline-connector{width:1px;height:14px;background:var(--line);margin:3px 0 3px 17px}
|
||||
.pipeline-footer{display:flex;justify-content:space-between;gap:10px;border-top:1px solid var(--line);padding-top:13px;margin-top:auto;color:var(--text-subtle);font:9px var(--mono)}
|
||||
.pipeline-footer span{color:var(--muted)}
|
||||
.stats-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:0;margin-top:16px;border:1px solid var(--line);background:var(--panel)}
|
||||
.stat{padding:22px 22px 21px;min-width:0}
|
||||
.stat+.stat{border-left:1px solid var(--line)}
|
||||
.stat>span{font:10px var(--mono);letter-spacing:.6px;color:var(--muted)}
|
||||
.stat strong{display:block;font:500 clamp(21px,2.5vw,35px)/1.35 var(--mono);letter-spacing:-1px;margin:9px 0 3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.stat strong.stat-date{font-size:clamp(15px,2vw,24px)}
|
||||
.stat:first-child strong{color:var(--accent)}
|
||||
.stat small{color:var(--text-subtle);font-size:10px}
|
||||
.section-heading{display:flex;align-items:end;justify-content:space-between;gap:15px;margin:42px 0 16px}
|
||||
.section-heading--second{margin-top:39px}
|
||||
.section-index{font:10px var(--mono);letter-spacing:1.2px;color:var(--green)}
|
||||
h2{font-size:22px;letter-spacing:-.75px;font-weight:565;margin-top:7px}
|
||||
h3{font-size:16px;font-weight:575;letter-spacing:-.3px}
|
||||
.text-link{background:none;border:0;color:var(--accent);font:11px var(--mono);padding:4px 0;white-space:nowrap}
|
||||
.text-link:hover{text-decoration:underline}
|
||||
.text-link span{margin-left:5px}
|
||||
.build-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px}
|
||||
.build-card,.skeleton-card{background:var(--panel);border:1px solid var(--line);padding:19px 22px 20px;min-width:0;position:relative}
|
||||
.build-card:before{content:"";position:absolute;inset:0 auto 0 0;width:2px;background:var(--green);opacity:.75}
|
||||
.build-card.off:before{background:var(--separator)}
|
||||
.build-card-top{display:flex;justify-content:space-between;gap:8px;align-items:center;color:var(--muted)}
|
||||
.build-card-top>span:first-child{font:10px var(--mono);letter-spacing:.7px}
|
||||
.pill{display:inline-block;border:1px solid var(--pill-line);color:var(--accent);padding:3px 8px;font:9px var(--mono);letter-spacing:.4px;white-space:nowrap}
|
||||
.pill.live{border-color:var(--pill-live-line);color:var(--green)}
|
||||
.pill.missing{border-color:var(--line);color:var(--muted)}
|
||||
.build-card-version{font:550 clamp(22px,2.3vw,31px)/1.4 var(--mono);letter-spacing:-1.5px;margin:15px 0 2px;overflow-wrap:anywhere}
|
||||
.build-card-sub{color:var(--muted);font:11px var(--mono);margin-bottom:16px}
|
||||
.build-card-details{border-top:1px solid var(--row-line);padding-top:14px;display:flex;justify-content:space-between;align-items:center;gap:6px}
|
||||
.build-card-details>div{display:flex;flex-direction:column;gap:4px;min-width:0}
|
||||
.build-card-details small{font:9px var(--mono);color:var(--text-subtle)}
|
||||
.build-card-details strong{font:11px var(--mono);color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.build-card-details .commit{color:var(--green)}
|
||||
.skeleton-card{grid-column:1/-1;color:var(--muted)}
|
||||
.card{border:1px solid var(--line);background:var(--panel);min-width:0}
|
||||
.recent-card{overflow:hidden}
|
||||
.table-scroller{overflow-x:auto;scrollbar-color:var(--field-line) transparent;scrollbar-width:thin}
|
||||
.data-table{border-collapse:collapse;width:100%;min-width:680px;text-align:left}
|
||||
.data-table thead{background:var(--panel2)}
|
||||
.data-table th{font:10px var(--mono);letter-spacing:.7px;color:var(--muted);padding:16px 19px;white-space:nowrap;border-bottom:1px solid var(--line)}
|
||||
.data-table td{padding:15px 19px;border-bottom:1px solid var(--row-line);font-size:12px;color:var(--text);white-space:nowrap}
|
||||
.data-table tbody tr:last-child td{border-bottom:0}
|
||||
.data-table tbody tr:hover{background:color-mix(in srgb,var(--nav-hover-bg) 70%,transparent)}
|
||||
.artifact-name{font-weight:570;display:block;max-width:270px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.artifact-arch{display:block;margin-top:3px;color:var(--text-subtle);font:10px var(--mono)}
|
||||
.version-code{font:12px var(--mono);color:var(--green)}
|
||||
.date-cell{font:11px var(--mono);color:var(--text)}
|
||||
.date-cell small{display:block;color:var(--text-subtle);margin-top:2px}
|
||||
.channel-tag{display:inline-flex;align-items:center;gap:7px;font:10px var(--mono);color:var(--muted)}
|
||||
.channel-tag:before{content:"";width:5px;height:5px;background:var(--accent);border-radius:50%}
|
||||
.channel-tag.pkg:before{background:var(--green)}
|
||||
.format-label{display:inline-block;font:9px var(--mono);color:var(--accent);background:color-mix(in srgb,var(--accent) 7%,transparent);border:1px solid color-mix(in srgb,var(--accent) 28%,transparent);padding:3px 6px;letter-spacing:.5px;margin-right:7px}
|
||||
.format-label.pkg{color:var(--green);border-color:var(--pill-live-line);background:color-mix(in srgb,var(--green) 7%,transparent)}
|
||||
.download-link{font:11px var(--mono);color:var(--accent);border:1px solid var(--line);padding:7px 10px;display:inline-flex;gap:8px;align-items:center;white-space:nowrap}
|
||||
.download-link:hover{border-color:var(--accent);background:var(--nav-hover-bg)}
|
||||
.download-link span{font-size:13px}
|
||||
.empty-table{color:var(--muted)!important;text-align:center;font-size:13px!important;padding:34px!important}
|
||||
.compact-head{margin-bottom:34px}
|
||||
.compact-head .lead{max-width:710px}
|
||||
.count-pill{color:var(--green);border:1px solid var(--pill-live-line);font:10px var(--mono);padding:9px 14px;white-space:nowrap}
|
||||
.filter-panel{border:1px solid var(--line);background:var(--panel);padding:15px;display:grid;grid-template-columns:minmax(220px,2.3fr) repeat(3,minmax(120px,1fr));gap:11px;align-items:end}
|
||||
.search-field{position:relative;display:flex;align-items:center;height:41px;background:var(--field-bg);border:1px solid var(--field-line)}
|
||||
.search-field svg{margin:0 12px;color:var(--muted);flex-shrink:0}
|
||||
.search-field input{background:none;border:0;outline:none;width:100%;height:100%;color:var(--text);padding:0 11px 0 0;min-width:0;font-size:12px}
|
||||
.search-field input::placeholder{color:var(--placeholder)}
|
||||
.search-field:focus-within{border-color:var(--accent)}
|
||||
.filter-input{display:flex;flex-direction:column;gap:8px;font:9px var(--mono);color:var(--muted);letter-spacing:.7px}
|
||||
.filter-input select{appearance:none;width:100%;height:41px;padding:0 31px 0 11px;border:1px solid var(--field-line);background:var(--field-bg);color:var(--text);border-radius:0;font:12px var(--sans);background-image:linear-gradient(45deg,transparent 50%,var(--muted) 50%),linear-gradient(135deg,var(--muted) 50%,transparent 50%);background-position:calc(100% - 16px) 18px,calc(100% - 11px) 18px;background-size:5px 5px;background-repeat:no-repeat}
|
||||
.filter-input select option{background:var(--panel);color:var(--text)}
|
||||
.result-topline{display:flex;gap:12px;justify-content:space-between;margin:19px 0 13px;color:var(--muted);font:10px var(--mono)}
|
||||
.result-topline span:first-child{color:var(--green)}
|
||||
.catalog-bottom{display:flex;gap:15px;align-items:center;justify-content:space-between;padding:14px 19px;border-top:1px solid var(--line);color:var(--text-subtle);font:10px var(--mono)}
|
||||
.show-more{font-size:11px;padding:8px 13px}
|
||||
.install-tabs{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin-bottom:16px}
|
||||
.install-tab{display:flex;flex-direction:column;align-items:flex-start;gap:6px;padding:16px 18px;border:1px solid var(--line);border-bottom:2px solid var(--line);background:var(--panel);color:var(--muted);font-size:13px;font-weight:600;text-align:left;transition:border-color .14s,color .14s}
|
||||
.install-tab small{font:10px var(--mono);font-weight:400;color:var(--text-subtle)}
|
||||
.install-tab:hover{border-color:var(--border-hover);color:var(--text)}
|
||||
.install-tab.active{color:var(--text);border-bottom-color:var(--accent);background:var(--panel2)}
|
||||
.install-tab.active small{color:var(--green)}
|
||||
.install-card{padding:27px}
|
||||
.install-card-head{display:flex;justify-content:space-between;align-items:flex-start;gap:15px;margin-bottom:22px}
|
||||
.install-card-head h2{font-size:24px;margin:9px 0}
|
||||
.install-card-head p{color:var(--muted);font-size:12px}
|
||||
.code-window{border:1px solid var(--line);background:var(--code-bg);overflow:hidden}
|
||||
.code-topbar{display:flex;justify-content:space-between;align-items:center;padding:10px 17px;border-bottom:1px solid var(--line);color:var(--text-subtle);font:11px var(--mono)}
|
||||
.code-topbar>span{display:flex;align-items:center;gap:7px}
|
||||
.code-topbar .tiny-dot:nth-child(1){background:var(--accent)}
|
||||
.code-topbar .tiny-dot:nth-child(2){background:var(--notice-text)}
|
||||
.code-topbar .tiny-dot:nth-child(3){background:var(--green)}
|
||||
.code-topbar b{font-weight:400;margin-left:13px}
|
||||
.copy-code{color:var(--accent);background:transparent;border:0;font:11px var(--mono);padding:4px 2px}
|
||||
.copy-code:hover{text-decoration:underline}
|
||||
.code-window pre{color:var(--text);font:12px/1.9 var(--mono);overflow:auto;margin:0;padding:21px 24px;min-height:170px;tab-size:2;white-space:pre}
|
||||
.install-foot{display:flex;justify-content:space-between;gap:15px;margin-top:17px;color:var(--muted);font-size:11px}
|
||||
.install-extra-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px}
|
||||
.hint-card{padding:24px 26px}
|
||||
.hint-icon{display:grid;place-items:center;width:34px;height:34px;border:1px solid var(--pill-live-line);color:var(--green);font:11px var(--mono);margin-bottom:18px}
|
||||
.hint-card h3{margin-bottom:10px}
|
||||
.hint-card p{color:var(--muted);font-size:12px;line-height:1.8;min-height:67px}
|
||||
.hint-card code{font:11px var(--mono);color:var(--green)}
|
||||
.inline-command{display:flex;align-items:center;justify-content:space-between;gap:8px;border:1px solid var(--line);background:var(--code-bg);padding:9px 12px;margin-top:15px}
|
||||
.inline-command code{font-size:10px;overflow:auto;white-space:nowrap}
|
||||
.inline-command button{font-size:10px;padding:5px 8px}
|
||||
.endpoints-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px}
|
||||
.endpoint-card{border:1px solid var(--line);background:var(--panel);padding:23px 22px}
|
||||
.endpoint-card-top{display:flex;align-items:center;justify-content:space-between;gap:9px;margin-bottom:19px}
|
||||
.endpoint-card h2{font-size:20px;letter-spacing:-.4px;margin:0}
|
||||
.endpoint-card>a{color:var(--green);font:11px var(--mono);word-break:break-all;display:block;min-height:54px;line-height:1.7}
|
||||
.endpoint-card>a:hover{text-decoration:underline}
|
||||
.endpoint-card ul{list-style:none;padding:15px 0 0;border-top:1px solid var(--line);margin:12px 0 0}
|
||||
.endpoint-card li{margin:12px 0 0;font-size:11px}
|
||||
.endpoint-card li a{display:flex;justify-content:space-between;gap:7px;align-items:center}
|
||||
.endpoint-card li a span:first-child{color:var(--muted)}
|
||||
.endpoint-card li a span:last-child{color:var(--accent)}
|
||||
.endpoint-card li a:hover span:first-child{color:var(--text)}
|
||||
.metadata-card{padding:3px 25px}
|
||||
.meta-table{display:grid;grid-template-columns:repeat(3,minmax(0,1fr))}
|
||||
.meta-block{padding:19px 0}
|
||||
.meta-block+.meta-block{padding-left:23px;border-left:1px solid var(--line)}
|
||||
.meta-block h3{font-size:13px;margin-bottom:12px}
|
||||
.meta-row{display:flex;flex-direction:column;gap:4px;margin-top:11px}
|
||||
.meta-row small{font:9px var(--mono);letter-spacing:.6px;color:var(--text-subtle)}
|
||||
.meta-row strong{font:11px var(--mono);font-weight:400;color:var(--text);overflow-wrap:anywhere}
|
||||
.meta-row strong.latest-value{color:var(--green)}
|
||||
.page-footer{border-top:1px solid var(--line);color:var(--text-subtle);font:10px var(--mono);display:flex;justify-content:space-between;gap:14px;margin-top:54px;padding:19px 0}
|
||||
.page-footer b{color:var(--accent);font-weight:400}
|
||||
.error-banner{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:0 0 21px;padding:14px 16px;color:var(--notice-text);border:1px solid var(--notice-line);background:var(--notice-bg);font-size:12px}
|
||||
.error-banner button{color:var(--accent);background:none;border:0;font-weight:600;white-space:nowrap}
|
||||
.clipboard-helper{position:fixed;opacity:0;pointer-events:none}
|
||||
.toast{position:fixed;right:24px;bottom:25px;background:var(--brand-secondary);color:var(--sidebar-bg);border:1px solid var(--pill-live-line);box-shadow:0 10px 35px var(--shadow);padding:13px 20px;font-size:12px;font-weight:600;z-index:100;max-width:min(400px,calc(100vw - 32px))}
|
||||
@media(max-width:1390px){
|
||||
.shell{grid-template-columns:226px minmax(0,1fr)}
|
||||
.sidebar{padding-left:13px;padding-right:13px}
|
||||
.topbar{padding-left:30px;padding-right:30px}
|
||||
.main-content{padding-left:30px;padding-right:30px}
|
||||
.hero-grid{grid-template-columns:minmax(0,1.35fr) minmax(245px,.85fr)}
|
||||
.hero-foot{flex-wrap:wrap}
|
||||
.hero-link{margin-left:auto}
|
||||
.stat{padding:20px 15px}
|
||||
.filter-panel{grid-template-columns:minmax(200px,1.8fr) repeat(3,minmax(110px,1fr))}
|
||||
}
|
||||
@media(max-width:1070px){
|
||||
.hero-grid{grid-template-columns:minmax(0,1fr)}
|
||||
.hero-release{min-height:295px}
|
||||
.pipeline-panel{min-height:auto}
|
||||
.pipeline-list{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}
|
||||
.pipeline-step{gap:10px}
|
||||
.pipeline-connector{display:none}
|
||||
.step-right{display:none}
|
||||
.pipeline-footer{margin-top:17px}
|
||||
.build-grid{gap:10px}
|
||||
.build-card{padding:16px}
|
||||
.build-card-details{flex-wrap:wrap}
|
||||
.build-card-details>div{width:100%}
|
||||
.filter-panel{grid-template-columns:repeat(3,minmax(0,1fr))}
|
||||
.search-field{grid-column:1/-1}
|
||||
.endpoints-grid{grid-template-columns:1fr 1fr}
|
||||
}
|
||||
@media(max-width:780px){
|
||||
.shell{display:block}
|
||||
.sidebar{position:relative;width:auto;height:auto;min-height:auto;padding:13px 21px 10px;border-right:0;border-bottom:1px solid var(--line)}
|
||||
.brand{font-size:17px}
|
||||
.brand-mark{width:38px;height:38px;flex-basis:38px;font-size:23px}
|
||||
.brand-mark span{font-size:19px}
|
||||
.edition,.sidebar-label,.side-channels,.sidebar-bottom{display:none}
|
||||
.main-navigation{flex-direction:row;overflow-x:auto;gap:3px;margin-top:13px;padding-bottom:2px}
|
||||
.nav{flex:1;min-width:max-content;gap:8px;padding:10px 12px;font-size:12px;min-height:auto}
|
||||
.nav-arrow{display:none}
|
||||
.nav b{font-size:11px}
|
||||
.topbar{position:relative;padding:0 20px;height:57px}
|
||||
.main-content{padding:35px 20px 22px}
|
||||
.crumb{font-size:9px;gap:7px;letter-spacing:.5px}
|
||||
.page-head{align-items:flex-start}
|
||||
.page-head-side{display:none}
|
||||
.page-head h1{font-size:39px}
|
||||
.live-sync{margin-right:1px;font-size:9px}
|
||||
.stats-grid{grid-template-columns:1fr 1fr}
|
||||
.stat:nth-child(3){border-left:0;border-top:1px solid var(--line)}
|
||||
.stat:nth-child(4){border-top:1px solid var(--line)}
|
||||
.build-grid{grid-template-columns:1fr 1fr}
|
||||
.install-tabs{grid-template-columns:1fr 1fr}
|
||||
.install-extra-grid{grid-template-columns:1fr}
|
||||
.hint-card p{min-height:auto}
|
||||
.meta-table{grid-template-columns:1fr}
|
||||
.meta-block+.meta-block{border-left:0;border-top:1px solid var(--line);padding-left:0}
|
||||
.endpoints-grid{grid-template-columns:1fr}
|
||||
}
|
||||
@media(max-width:540px){
|
||||
.sidebar{padding:12px 13px 9px}
|
||||
.main-navigation{gap:2px}
|
||||
.nav{gap:5px;padding:9px 7px;flex:1;justify-content:center}
|
||||
.nav>span:first-child{font-size:9px}
|
||||
.nav b{font-size:10px}
|
||||
.topbar{padding:0 15px}
|
||||
.top-actions{gap:5px}
|
||||
.live-sync{display:none}
|
||||
.theme-switch{padding:0 9px}
|
||||
.main-content{padding:28px 15px 18px}
|
||||
.page-head h1{font-size:35px;letter-spacing:-1.5px}
|
||||
.eyebrow{font-size:8px;letter-spacing:1.1px;gap:6px}
|
||||
.eyebrow-line{width:13px}
|
||||
.lead{font-size:12px}
|
||||
.hero-release{padding:19px 17px 20px;min-height:270px}
|
||||
.hero-version-label{margin-top:39px;font-size:9px}
|
||||
.hero-version{font-size:clamp(46px,11vw,72px);letter-spacing:-3px}
|
||||
.hero-corner{display:none}
|
||||
.hero-foot{gap:10px}
|
||||
.hero-foot>div{min-width:calc(50% - 10px)}
|
||||
.hero-foot strong{font-size:11px}
|
||||
.hero-link{margin-top:6px;margin-left:0;width:100%}
|
||||
.pipeline-panel{padding:19px}
|
||||
.pipeline-list{display:flex;flex-direction:column;gap:13px}
|
||||
.step-right{display:block}
|
||||
.stats-grid .stat{padding:17px 14px}
|
||||
.stat>span{font-size:9px}
|
||||
.stat strong{font-size:23px}
|
||||
.stat strong.stat-date{font-size:16px}
|
||||
.stat small{font-size:9px}
|
||||
.build-grid{grid-template-columns:1fr}
|
||||
.build-card-details{flex-wrap:nowrap}
|
||||
.build-card-details>div{width:auto}
|
||||
.section-heading h2{font-size:21px}
|
||||
.filter-panel{grid-template-columns:1fr 1fr}
|
||||
.search-field{grid-column:1/-1}
|
||||
.filter-input:last-child{grid-column:1/-1}
|
||||
.result-topline{flex-direction:column}
|
||||
.install-tabs{gap:7px}
|
||||
.install-tab{padding:11px 9px;font-size:12px}
|
||||
.install-tab small{font-size:9px}
|
||||
.install-card{padding:17px}
|
||||
.install-card-head{flex-wrap:wrap}
|
||||
.code-window pre{font-size:11px;padding:15px}
|
||||
.install-foot{flex-direction:column;gap:8px}
|
||||
.hint-card{padding:19px}
|
||||
.page-footer{flex-direction:column;gap:7px}
|
||||
}
|
||||
@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}*,*::before,*::after{animation-duration:.01ms!important;transition-duration:.01ms!important}}
|
||||
@@ -32,7 +32,28 @@ services:
|
||||
- ./gpg:/run/secrets/gpg:ro
|
||||
restart: unless-stopped
|
||||
|
||||
repository-dashboard:
|
||||
build:
|
||||
context: ./dashboard
|
||||
image: rustproxy/repository-dashboard:local
|
||||
environment:
|
||||
REPOSITORY_ROOT: /srv/repository
|
||||
REPOSITORY_URL: "${REPOSITORY_URL:?REPOSITORY_URL is required}"
|
||||
OPNSENSE_REPOSITORY_URL: "${OPNSENSE_REPOSITORY_URL:?OPNSENSE_REPOSITORY_URL is required}"
|
||||
REPO_DISTRIBUTION: "${REPO_DISTRIBUTION:-stable}"
|
||||
REPO_COMPONENT: "${REPO_COMPONENT:-main}"
|
||||
volumes:
|
||||
- ./data/repository:/srv/repository:ro
|
||||
read_only: true
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
restart: unless-stopped
|
||||
|
||||
repository-http:
|
||||
depends_on:
|
||||
- repository-dashboard
|
||||
image: nginxinc/nginx-unprivileged:stable-alpine-slim
|
||||
ports:
|
||||
- "0.0.0.0:${HTTP_PORT:-18765}:8080"
|
||||
|
||||
+42
-22
@@ -23,7 +23,8 @@ http {
|
||||
uwsgi_temp_path /tmp/uwsgi_temp;
|
||||
scgi_temp_path /tmp/scgi_temp;
|
||||
|
||||
# Linux APT/RPM repository.
|
||||
# Dynamic dashboard is private to the Docker network.
|
||||
# Keep package catalogs/files served from local storage by nginx.
|
||||
server {
|
||||
listen 8080 default_server;
|
||||
server_name _;
|
||||
@@ -35,7 +36,6 @@ http {
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "no-referrer" always;
|
||||
add_header Cache-Control "max-age=600" always;
|
||||
|
||||
if ($request_method !~ ^(GET|HEAD)$) {
|
||||
return 405;
|
||||
@@ -46,13 +46,20 @@ http {
|
||||
return 200 "ok\n";
|
||||
}
|
||||
|
||||
# Actual site and its original /index.html link use the live dashboard.
|
||||
location = / {
|
||||
try_files /index.html @linux_initializing;
|
||||
proxy_set_header X-Repo-Site linux;
|
||||
proxy_pass http://repository-dashboard:8000/;
|
||||
}
|
||||
|
||||
location @linux_initializing {
|
||||
default_type text/html;
|
||||
return 200 '<!doctype html><html><head><meta charset="utf-8"><title>RustProxy repository</title></head><body><h1>RustProxy Linux repository</h1><p>Repository is being initialized.</p></body></html>';
|
||||
location = /index.html {
|
||||
proxy_set_header X-Repo-Site linux;
|
||||
proxy_pass http://repository-dashboard:8000/index.html;
|
||||
}
|
||||
location ^~ /ui/ {
|
||||
proxy_pass http://repository-dashboard:8000;
|
||||
}
|
||||
location = /api/v1/packages {
|
||||
proxy_pass http://repository-dashboard:8000;
|
||||
}
|
||||
|
||||
location ~ (^|/)\. {
|
||||
@@ -60,12 +67,12 @@ http {
|
||||
}
|
||||
|
||||
location / {
|
||||
expires 10m;
|
||||
try_files $uri =404;
|
||||
}
|
||||
}
|
||||
|
||||
# OPNsense/FreeBSD pkg repository. It has a separate public URL and local port,
|
||||
# but follows the same atomic repository/current switch as the Linux repo.
|
||||
# Separate OPNsense hostname/port, with native FreeBSD at /freebsd/.
|
||||
server {
|
||||
listen 8081;
|
||||
server_name _;
|
||||
@@ -77,7 +84,6 @@ http {
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "no-referrer" always;
|
||||
add_header Cache-Control "max-age=600" always;
|
||||
|
||||
if ($request_method !~ ^(GET|HEAD)$) {
|
||||
return 405;
|
||||
@@ -88,29 +94,43 @@ http {
|
||||
return 200 "ok\n";
|
||||
}
|
||||
|
||||
# Native FreeBSD package repo at <OPNSENSE_REPOSITORY_URL>/freebsd/.
|
||||
# Keep a distinct catalog so clients never see the OPNsense plugin.
|
||||
location = / {
|
||||
proxy_set_header X-Repo-Site opnsense;
|
||||
proxy_pass http://repository-dashboard:8000/;
|
||||
}
|
||||
location = /index.html {
|
||||
proxy_set_header X-Repo-Site opnsense;
|
||||
proxy_pass http://repository-dashboard:8000/index.html;
|
||||
}
|
||||
location = /freebsd { return 301 /freebsd/; }
|
||||
location = /freebsd/ {
|
||||
proxy_set_header X-Repo-Site freebsd;
|
||||
proxy_pass http://repository-dashboard:8000/freebsd/;
|
||||
}
|
||||
location = /freebsd/index.html {
|
||||
proxy_set_header X-Repo-Site freebsd;
|
||||
proxy_pass http://repository-dashboard:8000/freebsd/index.html;
|
||||
}
|
||||
location ^~ /ui/ {
|
||||
proxy_pass http://repository-dashboard:8000;
|
||||
}
|
||||
location = /api/v1/packages {
|
||||
proxy_pass http://repository-dashboard:8000;
|
||||
}
|
||||
|
||||
# Nginx still handles pkg catalog downloads directly, untouched.
|
||||
location /freebsd/ {
|
||||
alias /srv/repository/freebsd/;
|
||||
index index.html;
|
||||
expires 10m;
|
||||
autoindex off;
|
||||
}
|
||||
|
||||
location = / {
|
||||
try_files /index.html @opnsense_initializing;
|
||||
}
|
||||
|
||||
location @opnsense_initializing {
|
||||
default_type text/html;
|
||||
return 200 '<!doctype html><html><head><meta charset="utf-8"><title>RustProxy OPNsense repository</title></head><body><h1>RustProxy OPNsense repository</h1><p>Repository is being initialized.</p></body></html>';
|
||||
}
|
||||
|
||||
location ~ (^|/)\. {
|
||||
deny all;
|
||||
}
|
||||
|
||||
location / {
|
||||
expires 10m;
|
||||
try_files $uri =404;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ cp -f "$FREEBSD_PACKAGE_FILE" "$RELEASE_DIR/freebsd/rustproxy-latest.pkg"
|
||||
(cd "$RELEASE_DIR/freebsd" && sha256sum "$FREEBSD_ABI/$(basename "$FREEBSD_PACKAGE_FILE")") > "$RELEASE_DIR/freebsd/SHA256"
|
||||
cat > "$RELEASE_DIR/freebsd/BUILD-INFO.txt" <<INFO
|
||||
app_version=$VERSION
|
||||
built_at=$(date -u "+%Y-%m-%dT%H:%M:%SZ")
|
||||
package_version=$FREEBSD_PACKAGE_VERSION
|
||||
source_url=$SOURCE_REPO
|
||||
source_ref=$SOURCE_BRANCH
|
||||
|
||||
@@ -202,6 +202,7 @@ gpg --batch --armor --export "$GPG_KEY_ID" > "$RELEASE_DIR/repo-key.gpg"
|
||||
/opt/rust-package-builder/scripts/generate-index.sh "$RELEASE_DIR/index.html" "$PACKAGE_NAME"
|
||||
cat > "$RELEASE_DIR/LINUX-BUILD-INFO.txt" <<INFO
|
||||
app_version=$VERSION
|
||||
built_at=$(date -u "+%Y-%m-%dT%H:%M:%SZ")
|
||||
package_release=$PACKAGE_RELEASE
|
||||
packaging_revision=$PACKAGING_REVISION
|
||||
linux_packages=$PACKAGE_NAME,rustproxy-docker-agent
|
||||
|
||||
@@ -94,6 +94,7 @@ cp -f "$OPNSENSE_PACKAGE_FILE" "$RELEASE_DIR/opnsense/os-rustproxy-latest.pkg"
|
||||
(cd "$RELEASE_DIR/opnsense" && sha256sum "$OPNSENSE_ABI/amd64/$(basename "$OPNSENSE_PACKAGE_FILE")") > "$RELEASE_DIR/opnsense/SHA256"
|
||||
cat > "$RELEASE_DIR/opnsense/BUILD-INFO.txt" <<INFO
|
||||
app_version=$VERSION
|
||||
built_at=$(date -u "+%Y-%m-%dT%H:%M:%SZ")
|
||||
package_version=$OPNSENSE_PACKAGE_VERSION
|
||||
source_url=$SOURCE_REPO
|
||||
source_ref=$SOURCE_BRANCH
|
||||
|
||||
@@ -121,8 +121,8 @@ normalize_repo_url OPNSENSE_REPOSITORY_URL OPNSENSE_HTTP_PORT "$DEFAULT_OPNSENSE
|
||||
mkdir -p "$DATA_DIR/repository" "$DATA_DIR/state" "$DATA_DIR/cargo" "$DATA_DIR/work" "$GPG_DIR"
|
||||
chmod 0700 "$GPG_DIR" 2>/dev/null || true
|
||||
|
||||
log "budowanie obrazu buildera (Linux + FreeBSD cross-toolchain)"
|
||||
"${COMPOSE[@]}" --env-file "$ENV_FILE" build "$SERVICE"
|
||||
log "budowanie obrazu buildera oraz panelu repozytorium"
|
||||
"${COMPOSE[@]}" --env-file "$ENV_FILE" build "$SERVICE" repository-dashboard
|
||||
|
||||
if [[ ! -s "$GPG_DIR/private-key.asc" ]]; then
|
||||
log "brak klucza GPG - generowanie stalego klucza podpisujacego Linux APT/RPM"
|
||||
@@ -173,7 +173,7 @@ else
|
||||
log "uzywanie istniejacego klucza GPG"
|
||||
fi
|
||||
|
||||
log "uruchamianie buildera i wspolnego nginx przez Docker Compose"
|
||||
log "uruchamianie buildera, panelu i nginx przez Docker Compose"
|
||||
"${COMPOSE[@]}" --env-file "$ENV_FILE" up -d "$SERVICE" "$HTTP_SERVICE"
|
||||
|
||||
fingerprint=""
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run: python3 -m unittest discover -s tests -p 'test_dashboard.py' -v"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from http.server import ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.error import HTTPError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
spec = importlib.util.spec_from_file_location("dashboard_app", ROOT / "dashboard" / "app.py")
|
||||
app = importlib.util.module_from_spec(spec)
|
||||
assert spec and spec.loader
|
||||
spec.loader.exec_module(app)
|
||||
|
||||
|
||||
class RepoFixture:
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.repo = Path(self.temp.name) / "repository"
|
||||
self.r1 = self.repo / "releases" / "r1"
|
||||
self.r1.mkdir(parents=True)
|
||||
(self.repo / "current").symlink_to("releases/r1")
|
||||
|
||||
def make_file(self, name, *, contents="package-bytes", timestamp=1735853760):
|
||||
path = self.r1 / name
|
||||
path.parent.mkdir(parents=True,exist_ok=True)
|
||||
path.write_text(contents,encoding="utf-8")
|
||||
os.utime(path,(timestamp,timestamp))
|
||||
return path
|
||||
|
||||
def make_catalog(self):
|
||||
self.make_file("apt/pool/r/rustproxy/rustproxy_0.34.6-2_amd64.deb")
|
||||
self.make_file("apt/pool/r/rustproxy/rustproxy-docker-agent_0.34.6-2_amd64.deb")
|
||||
self.make_file("rpm/x86_64/rustproxy-0.34.6-2.x86_64.rpm")
|
||||
self.make_file("rpm/x86_64/rustproxy-docker-agent-0.34.6-2.x86_64.rpm")
|
||||
self.make_file("opnsense/26.7/amd64/os-rustproxy-0.34.6_123.pkg")
|
||||
self.make_file("opnsense/packagesite.pkg",contents="index")
|
||||
self.make_file("opnsense/os-rustproxy-latest.pkg",contents="duplicate")
|
||||
self.make_file("freebsd/FreeBSD:15:amd64/rustproxy-0.34.6_123.pkg")
|
||||
self.make_file("freebsd/rustproxy-latest.pkg",contents="duplicate")
|
||||
self.make_file("LINUX-BUILD-INFO.txt",contents="app_version=0.34.6\npackage_release=2\nsource_commit=abc123\nbuilt_at=2026-10-22T08:41:00Z\n")
|
||||
self.make_file("opnsense/BUILD-INFO.txt",contents="app_version=0.34.6\npackage_version=0.34.6_123\nsource_commit=abc123\nbuilt_at=2026-10-22T08:43:00Z\n")
|
||||
self.make_file("freebsd/BUILD-INFO.txt",contents="app_version=0.34.6\npackage_version=0.34.6_123\nsource_commit=abc123\nbuilt_at=2026-10-22T08:45:00Z\n")
|
||||
|
||||
class CatalogTests(RepoFixture, unittest.TestCase):
|
||||
def test_scans_real_file_layout_without_index_duplicates(self):
|
||||
self.make_catalog()
|
||||
catalog = app.generate_catalog(self.repo)
|
||||
self.assertEqual(6, catalog["summary"]["packages_count"])
|
||||
self.assertEqual(3, catalog["summary"]["channels_published"])
|
||||
self.assertEqual(4, catalog["summary"]["channel_counts"]["linux"])
|
||||
self.assertEqual(1, catalog["summary"]["channel_counts"]["opnsense"])
|
||||
self.assertEqual(1, catalog["summary"]["channel_counts"]["freebsd"])
|
||||
self.assertEqual("0.34.6-2",catalog["builds"]["linux"]["package_version"])
|
||||
self.assertEqual("2026-10-22T08:45:00Z",catalog["summary"]["latest_build_at"])
|
||||
linux = [p for p in catalog["packages"] if p["format"] == "RPM"]
|
||||
self.assertEqual({"rustproxy","rustproxy-docker-agent"}, {p["name"] for p in linux})
|
||||
self.assertEqual("2026-10-22T08:41:00Z", linux[0]["built_at"])
|
||||
self.assertEqual("build",linux[0]["date_source"])
|
||||
opnsense = next(p for p in catalog["packages"] if p["channel"] == "opnsense")
|
||||
self.assertTrue(opnsense["download_url"].endswith("/26.7/amd64/os-rustproxy-0.34.6_123.pkg"))
|
||||
freebsd = next(p for p in catalog["packages"] if p["channel"] == "freebsd")
|
||||
self.assertIn("/freebsd/FreeBSD%3A15%3Aamd64/",freebsd["download_url"])
|
||||
self.assertEqual("2026-10-22T08:45:00Z",freebsd["built_at"])
|
||||
|
||||
def test_mtime_backwards_compatibility(self):
|
||||
self.make_file("LINUX-BUILD-INFO.txt",contents="app_version=0.21.0\npackage_release=5\n")
|
||||
self.make_file("rpm/x86_64/rustproxy-0.21.0-5.x86_64.rpm")
|
||||
catalog = app.generate_catalog(self.repo)
|
||||
self.assertEqual("file", catalog["builds"]["linux"]["date_source"])
|
||||
self.assertEqual("file", catalog["packages"][0]["date_source"])
|
||||
self.assertEqual("0.21.0-5",catalog["packages"][0]["version"])
|
||||
self.assertTrue(catalog["packages"][0]["built_at"].endswith("Z"))
|
||||
|
||||
def test_symlink_rotation_gives_current_packages(self):
|
||||
self.make_file("rpm/x86_64/rustproxy-0.1.0-1.x86_64.rpm")
|
||||
v2 = self.repo / "releases" / "r2"
|
||||
v2.mkdir()
|
||||
(v2 / "rpm/x86_64").mkdir(parents=True)
|
||||
(v2 / "rpm/x86_64/rustproxy-0.2.0-1.x86_64.rpm").write_bytes(b"new package")
|
||||
before = app.generate_catalog(self.repo)
|
||||
self.assertEqual("0.1.0-1",before["packages"][0]["version"])
|
||||
(self.repo / "current.new").symlink_to("releases/r2")
|
||||
os.replace(self.repo / "current.new",self.repo / "current")
|
||||
after = app.generate_catalog(self.repo)
|
||||
self.assertEqual("0.2.0-1",after["packages"][0]["version"])
|
||||
|
||||
def test_empty_repo_and_untrusted_symlink(self):
|
||||
first = app.generate_catalog(self.repo)
|
||||
self.assertFalse(first["ready"])
|
||||
self.assertEqual([],first["packages"])
|
||||
(self.repo / "current").unlink()
|
||||
(self.repo / "current").symlink_to(self.temp.name) # outside repo root
|
||||
second = app.generate_catalog(self.repo)
|
||||
self.assertFalse(second["ready"])
|
||||
self.assertEqual([],second["packages"])
|
||||
|
||||
def test_regexes_and_metadata_parser(self):
|
||||
self.assertEqual(("rustproxy-docker-agent","0.34.6-2","x86_64"),app.parse_filename("rustproxy-docker-agent-0.34.6-2.x86_64.rpm","rpm"))
|
||||
self.assertEqual(("os-rustproxy","0.34.6_123","amd64"),app.parse_filename("os-rustproxy-0.34.6_123.pkg","pkg"))
|
||||
self.assertIsNone(app.parse_filename("packagesite.pkg","pkg"))
|
||||
self.assertIsNone(app.parse_filename("rustproxy-latest.pkg","pkg"))
|
||||
self.assertIsNone(app.parse_filename("repomd.xml","rpm"))
|
||||
self.assertEqual({},app.parse_metadata(self.repo/"missing-info.txt"))
|
||||
|
||||
|
||||
class HttpTests(RepoFixture, unittest.TestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.make_catalog()
|
||||
original_cache = app.CACHE
|
||||
app.CACHE = app.CatalogCache(self.repo,ttl=1)
|
||||
self.addCleanup(lambda: setattr(app,"CACHE",original_cache))
|
||||
self.server = ThreadingHTTPServer(("127.0.0.1",0),app.DashboardHandler)
|
||||
self.thread = threading.Thread(target=self.server.serve_forever,daemon=True)
|
||||
self.thread.start()
|
||||
self.addCleanup(self.server.server_close)
|
||||
self.addCleanup(self.server.shutdown)
|
||||
self.base = f"http://127.0.0.1:{self.server.server_port}"
|
||||
|
||||
def test_http_catalog_page_assets_head(self):
|
||||
with urlopen(self.base + "/api/v1/packages") as response:
|
||||
self.assertIn("no-store",response.headers["Cache-Control"])
|
||||
catalog = json.load(response)
|
||||
self.assertEqual(6,len(catalog["packages"]))
|
||||
request = Request(self.base + "/",headers={"X-Repo-Site":"opnsense"})
|
||||
with urlopen(request) as response:
|
||||
html = response.read().decode()
|
||||
self.assertIn('data-site="opnsense"',html)
|
||||
self.assertIn("Package Registry",html)
|
||||
self.assertIn("Content-Security-Policy",response.headers)
|
||||
with urlopen(self.base + "/freebsd/") as response:
|
||||
self.assertIn('data-site="freebsd"',response.read().decode())
|
||||
with urlopen(self.base + "/ui/style.css") as response:
|
||||
self.assertIn("text/css",response.headers["Content-Type"])
|
||||
self.assertIn("--accent:#f69b74",response.read().decode())
|
||||
request = Request(self.base + "/ui/app.js", method="HEAD")
|
||||
with urlopen(request) as response:
|
||||
self.assertEqual(200,response.status)
|
||||
self.assertEqual(b"",response.read())
|
||||
with self.assertRaises(HTTPError) as raised:
|
||||
urlopen(self.base + "/../something")
|
||||
self.assertEqual(404,raised.exception.code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user