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

+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}}