fancy dash

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-22 11:23:32 +02:00
1 parent 3b317a4f0c
commit ccccdc2031
16 files changed
+1530 -34

No files matched your search

+41
View File
@@ -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
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
__pycache__/
*.pyc
+13
View File
@@ -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"]
+314
View File
@@ -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()
+383
View File
@@ -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) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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();
})();
+7
View File
@@ -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

+136
View File
@@ -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>
+390
View File
@@ -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}}
+21
View File
@@ -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"
+43 -23
View File
@@ -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,8 +36,7 @@ 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;
}
}
+1
View File
@@ -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
+1
View File
@@ -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
+1
View File
@@ -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
+3 -3
View File
@@ -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=""
+157
View File
@@ -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()