feat: add React UI for rpm.lair.cafe
- Vite + React + SWC + TypeScript SPA with react-router and react-bootstrap - Dark/light/system theme with Bootstrap 5.3 data-bs-theme - Home page with repo setup instructions and copyable code blocks - Package list and detail pages driven by packages.json - Python script to generate packages.json from repodata XML - Nginx config updated for SPA fallback, asset caching, removed autoindex - New deploy-ui workflow triggered on ui/ or nginx config changes, requires runners with nvm label - packages.json generation added to publish job after createrepo_c - Runner setup docs for nvm and sequoia-sq added to readme Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -190,3 +190,12 @@ jobs:
|
||||
run: |
|
||||
ssh "gitea_ci@${RPM_REPO_HOST}" \
|
||||
"cd /var/www/rpm/fedora/${{ matrix.fedora_version }}/x86_64 && createrepo_c --update ."
|
||||
|
||||
- name: Generate packages.json
|
||||
run: |
|
||||
scp script/generate-packages-json.py "gitea_ci@${RPM_REPO_HOST}:/tmp/"
|
||||
ssh "gitea_ci@${RPM_REPO_HOST}" \
|
||||
"python3 /tmp/generate-packages-json.py \
|
||||
--repodata-dir /var/www/rpm/fedora/${{ matrix.fedora_version }}/x86_64/repodata \
|
||||
--output /var/www/rpm/fedora/${{ matrix.fedora_version }}/x86_64/packages.json \
|
||||
--base-url https://rpm.lair.cafe/fedora/${{ matrix.fedora_version }}/x86_64"
|
||||
|
||||
70
.gitea/workflows/deploy-ui.yml
Normal file
70
.gitea/workflows/deploy-ui.yml
Normal file
@@ -0,0 +1,70 @@
|
||||
name: deploy-ui
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "ui/**"
|
||||
- "asset/nginx/**"
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on:
|
||||
- fedora-43
|
||||
- nvm
|
||||
env:
|
||||
RPM_REPO_HOST: oolon.kosherinata.internal
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
run: |
|
||||
source ~/.nvm/nvm.sh
|
||||
nvm install --lts
|
||||
echo "$(dirname "$(nvm which current)")" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd ui
|
||||
npm ci
|
||||
|
||||
- name: Build UI
|
||||
run: |
|
||||
cd ui
|
||||
npm run build
|
||||
|
||||
- name: Set up SSH
|
||||
run: |
|
||||
install --directory --mode 700 ~/.ssh
|
||||
echo "${RSYNC_SSH_KEY}" | install --mode 600 /dev/stdin ~/.ssh/id_ed25519
|
||||
env:
|
||||
RSYNC_SSH_KEY: ${{ secrets.RSYNC_SSH_KEY }}
|
||||
|
||||
- name: Test SSH connectivity
|
||||
run: |
|
||||
ssh -o StrictHostKeyChecking=accept-new "gitea_ci@${RPM_REPO_HOST}" exit
|
||||
|
||||
- name: Deploy UI to web root
|
||||
run: |
|
||||
rsync \
|
||||
--archive \
|
||||
--verbose \
|
||||
--delete \
|
||||
--chmod D755,F644 \
|
||||
--include="index.html" \
|
||||
--include="assets/***" \
|
||||
--exclude="*" \
|
||||
ui/dist/ \
|
||||
"gitea_ci@${RPM_REPO_HOST}:/var/www/rpm/"
|
||||
|
||||
- name: Deploy nginx config
|
||||
run: |
|
||||
rsync \
|
||||
--archive \
|
||||
--verbose \
|
||||
--rsync-path="sudo rsync" \
|
||||
--chown=root:root \
|
||||
asset/nginx/rpm.lair.cafe.conf \
|
||||
"gitea_ci@${RPM_REPO_HOST}:/etc/nginx/sites-available/rpm.lair.cafe.conf"
|
||||
ssh "gitea_ci@${RPM_REPO_HOST}" "sudo nginx -t && sudo systemctl reload nginx"
|
||||
@@ -10,16 +10,17 @@ server {
|
||||
|
||||
root /var/www/rpm;
|
||||
|
||||
autoindex on;
|
||||
autoindex_exact_size off;
|
||||
autoindex_localtime on;
|
||||
|
||||
types {
|
||||
application/x-rpm rpm;
|
||||
application/xml xml;
|
||||
}
|
||||
default_type application/octet-stream;
|
||||
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
location ~ \.rpm$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
@@ -30,7 +31,16 @@ server {
|
||||
add_header Cache-Control "no-cache, must-revalidate";
|
||||
}
|
||||
|
||||
location ~ packages\.json$ {
|
||||
expires 5m;
|
||||
add_header Cache-Control "public, must-revalidate";
|
||||
}
|
||||
|
||||
location ~ \.gpg$ {
|
||||
default_type text/plain;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
|
||||
29
readme.md
29
readme.md
@@ -93,6 +93,35 @@ gpg --homedir ~/.gnupg/lair --quick-add-key <master-fpr> ed25519 sign 1y
|
||||
|
||||
Then update the `RPM_SIGNING_KEY` secret in Gitea with the new subkey. The public key served to users doesn't change since it's anchored to the master key.
|
||||
|
||||
### 5. Runner prerequisites
|
||||
|
||||
#### nvm (for UI builds)
|
||||
|
||||
Runners that build the UI need [nvm](https://github.com/nvm-sh/nvm) installed for the `gitea_runner` user and an `nvm` label in their runner config:
|
||||
|
||||
```bash
|
||||
sudo -u gitea_runner bash -c 'curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash'
|
||||
```
|
||||
|
||||
Then add `nvm` to the labels in `/etc/act_runner/config.yml`:
|
||||
|
||||
```yaml
|
||||
runner:
|
||||
labels:
|
||||
- "fedora-43:host"
|
||||
- "nvm"
|
||||
```
|
||||
|
||||
Restart the runner after changing labels. The `deploy-ui` workflow uses `runs-on: [fedora-43, nvm]` to select runners with Node.js capability.
|
||||
|
||||
#### sequoia-sq (for RPM signing)
|
||||
|
||||
Runners that run the publish job need `sequoia-sq` installed:
|
||||
|
||||
```bash
|
||||
sudo dnf install sequoia-sq
|
||||
```
|
||||
|
||||
## Client setup
|
||||
|
||||
```bash
|
||||
|
||||
141
script/generate-packages-json.py
Executable file
141
script/generate-packages-json.py
Executable file
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Parse RPM repodata and emit a packages.json manifest for the UI."""
|
||||
|
||||
import argparse
|
||||
import gzip
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime, timezone
|
||||
|
||||
RPM_NS = "http://linux.duke.edu/metadata/common"
|
||||
OTHER_NS = "http://linux.duke.edu/metadata/other"
|
||||
REPO_NS = "http://linux.duke.edu/metadata/repo"
|
||||
|
||||
|
||||
def find_repodata_file(repodata_dir, data_type):
|
||||
"""Read repomd.xml and return the path to a specific data type's file."""
|
||||
repomd_path = os.path.join(repodata_dir, "repomd.xml")
|
||||
tree = ET.parse(repomd_path)
|
||||
root = tree.getroot()
|
||||
|
||||
for data in root.findall(f"{{{REPO_NS}}}data"):
|
||||
if data.get("type") == data_type:
|
||||
location = data.find(f"{{{REPO_NS}}}location")
|
||||
if location is not None:
|
||||
href = location.get("href", "")
|
||||
return os.path.join(os.path.dirname(repodata_dir), href)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def parse_primary(repodata_dir):
|
||||
"""Parse primary.xml.gz and return package metadata."""
|
||||
path = find_repodata_file(repodata_dir, "primary")
|
||||
if not path:
|
||||
print("error: primary metadata not found in repomd.xml", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
packages = {}
|
||||
with gzip.open(path, "rb") as f:
|
||||
tree = ET.parse(f)
|
||||
|
||||
for pkg in tree.getroot().findall(f"{{{RPM_NS}}}package"):
|
||||
if pkg.get("type") != "rpm":
|
||||
continue
|
||||
|
||||
name = pkg.findtext(f"{{{RPM_NS}}}name", "")
|
||||
version_el = pkg.find(f"{{{RPM_NS}}}version")
|
||||
ver = version_el.get("ver", "") if version_el is not None else ""
|
||||
rel = version_el.get("rel", "") if version_el is not None else ""
|
||||
arch = pkg.findtext(f"{{{RPM_NS}}}arch", "")
|
||||
|
||||
size_el = pkg.find(f"{{{RPM_NS}}}size")
|
||||
size = int(size_el.get("package", "0")) if size_el is not None else 0
|
||||
|
||||
time_el = pkg.find(f"{{{RPM_NS}}}time")
|
||||
build_time = int(time_el.get("build", "0")) if time_el is not None else 0
|
||||
|
||||
location_el = pkg.find(f"{{{RPM_NS}}}location")
|
||||
filename = os.path.basename(location_el.get("href", "")) if location_el is not None else ""
|
||||
|
||||
key = f"{name}-{ver}-{rel}"
|
||||
packages[key] = {
|
||||
"name": name,
|
||||
"version": ver,
|
||||
"release": rel,
|
||||
"arch": arch,
|
||||
"summary": pkg.findtext(f"{{{RPM_NS}}}summary", ""),
|
||||
"size": size,
|
||||
"buildTime": build_time,
|
||||
"rpmFilename": filename,
|
||||
"changelog": [],
|
||||
}
|
||||
|
||||
return packages
|
||||
|
||||
|
||||
def parse_other(repodata_dir, packages):
|
||||
"""Parse other.xml.gz and attach changelog entries to packages."""
|
||||
path = find_repodata_file(repodata_dir, "other")
|
||||
if not path:
|
||||
return
|
||||
|
||||
with gzip.open(path, "rb") as f:
|
||||
tree = ET.parse(f)
|
||||
|
||||
for pkg in tree.getroot().findall(f"{{{OTHER_NS}}}package"):
|
||||
name = pkg.get("name", "")
|
||||
version_el = pkg.find(f"{{{OTHER_NS}}}version")
|
||||
ver = version_el.get("ver", "") if version_el is not None else ""
|
||||
rel = version_el.get("rel", "") if version_el is not None else ""
|
||||
key = f"{name}-{ver}-{rel}"
|
||||
|
||||
if key not in packages:
|
||||
continue
|
||||
|
||||
for entry in pkg.findall(f"{{{OTHER_NS}}}changelog"):
|
||||
packages[key]["changelog"].append({
|
||||
"author": entry.get("author", ""),
|
||||
"date": int(entry.get("date", "0")),
|
||||
"text": (entry.text or "").strip(),
|
||||
})
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--repodata-dir",
|
||||
required=True,
|
||||
help="path to the repodata/ directory",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
required=True,
|
||||
help="path to write packages.json",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--base-url",
|
||||
required=True,
|
||||
help="public base URL for the repo (e.g. https://rpm.lair.cafe/fedora/43/x86_64)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
packages = parse_primary(args.repodata_dir)
|
||||
parse_other(args.repodata_dir, packages)
|
||||
|
||||
manifest = {
|
||||
"generated": datetime.now(timezone.utc).isoformat(),
|
||||
"baseUrl": args.base_url,
|
||||
"packages": list(packages.values()),
|
||||
}
|
||||
|
||||
with open(args.output, "w") as f:
|
||||
json.dump(manifest, f, indent=2)
|
||||
|
||||
print(f"wrote {len(packages)} packages to {args.output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
24
ui/.gitignore
vendored
Normal file
24
ui/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
12
ui/index.html
Normal file
12
ui/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>rpm.lair.cafe</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1733
ui/package-lock.json
generated
Normal file
1733
ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
ui/package.json
Normal file
25
ui/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "ui",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react-swc": "^4.3.0",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.0.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"bootstrap": "^5.3.8",
|
||||
"react": "^19.2.5",
|
||||
"react-bootstrap": "^2.10.10",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router": "^7.14.2"
|
||||
}
|
||||
}
|
||||
25
ui/src/App.tsx
Normal file
25
ui/src/App.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { createBrowserRouter, RouterProvider } from "react-router";
|
||||
import { ThemeProvider } from "./theme/ThemeContext.tsx";
|
||||
import { Layout } from "./components/Layout.tsx";
|
||||
import { Home } from "./pages/Home.tsx";
|
||||
import { PackageList } from "./pages/PackageList.tsx";
|
||||
import { PackageDetail } from "./pages/PackageDetail.tsx";
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
element: <Layout />,
|
||||
children: [
|
||||
{ index: true, element: <Home /> },
|
||||
{ path: "packages", element: <PackageList /> },
|
||||
{ path: "packages/:name", element: <PackageDetail /> },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<RouterProvider router={router} />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
35
ui/src/components/CodeBlock.tsx
Normal file
35
ui/src/components/CodeBlock.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "react-bootstrap";
|
||||
|
||||
interface CodeBlockProps {
|
||||
children: string;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export function CodeBlock({ children, language }: CodeBlockProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function copy() {
|
||||
await navigator.clipboard.writeText(children.trim());
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="position-relative">
|
||||
<pre className="bg-body-tertiary rounded p-3 overflow-auto">
|
||||
<code className={language ? `language-${language}` : undefined}>
|
||||
{children.trim()}
|
||||
</code>
|
||||
</pre>
|
||||
<Button
|
||||
variant="outline-secondary"
|
||||
size="sm"
|
||||
className="position-absolute top-0 end-0 m-2"
|
||||
onClick={copy}
|
||||
>
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
ui/src/components/Layout.tsx
Normal file
14
ui/src/components/Layout.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Container } from "react-bootstrap";
|
||||
import { Outlet } from "react-router";
|
||||
import { NavHeader } from "./NavHeader.tsx";
|
||||
|
||||
export function Layout() {
|
||||
return (
|
||||
<>
|
||||
<NavHeader />
|
||||
<Container as="main" className="pb-5">
|
||||
<Outlet />
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
37
ui/src/components/NavHeader.tsx
Normal file
37
ui/src/components/NavHeader.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Container, Nav, Navbar } from "react-bootstrap";
|
||||
import { Link, useLocation } from "react-router";
|
||||
import { ThemeToggle } from "../theme/ThemeToggle.tsx";
|
||||
|
||||
export function NavHeader() {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<Navbar expand="sm" className="border-bottom mb-4">
|
||||
<Container>
|
||||
<Navbar.Brand as={Link} to="/">
|
||||
rpm.lair.cafe
|
||||
</Navbar.Brand>
|
||||
<Navbar.Toggle />
|
||||
<Navbar.Collapse>
|
||||
<Nav className="me-auto">
|
||||
<Nav.Link
|
||||
as={Link}
|
||||
to="/"
|
||||
active={location.pathname === "/"}
|
||||
>
|
||||
Home
|
||||
</Nav.Link>
|
||||
<Nav.Link
|
||||
as={Link}
|
||||
to="/packages"
|
||||
active={location.pathname.startsWith("/packages")}
|
||||
>
|
||||
Packages
|
||||
</Nav.Link>
|
||||
</Nav>
|
||||
<ThemeToggle />
|
||||
</Navbar.Collapse>
|
||||
</Container>
|
||||
</Navbar>
|
||||
);
|
||||
}
|
||||
36
ui/src/hooks/usePackages.ts
Normal file
36
ui/src/hooks/usePackages.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { PackagesManifest } from "../types/packages.ts";
|
||||
|
||||
const MANIFEST_URL = "/fedora/43/x86_64/packages.json";
|
||||
|
||||
export function usePackages() {
|
||||
const [manifest, setManifest] = useState<PackagesManifest | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
fetch(MANIFEST_URL)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json() as Promise<PackagesManifest>;
|
||||
})
|
||||
.then((data) => {
|
||||
if (!cancelled) setManifest(data);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled)
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { manifest, loading, error };
|
||||
}
|
||||
10
ui/src/main.tsx
Normal file
10
ui/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "bootstrap/dist/css/bootstrap.min.css";
|
||||
import App from "./App.tsx";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
49
ui/src/pages/Home.tsx
Normal file
49
ui/src/pages/Home.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Card, Col, Row } from "react-bootstrap";
|
||||
import { CodeBlock } from "../components/CodeBlock.tsx";
|
||||
|
||||
const GPG_KEY_URL = "https://rpm.lair.cafe/8b2023ce.gpg";
|
||||
|
||||
const REPO_FILE = `[lair-cafe]
|
||||
name=lair.cafe RPM Repository
|
||||
baseurl=https://rpm.lair.cafe/fedora/$releasever/$basearch/
|
||||
enabled=1
|
||||
gpgcheck=1
|
||||
gpgkey=${GPG_KEY_URL}`;
|
||||
|
||||
export function Home() {
|
||||
return (
|
||||
<>
|
||||
<h1 className="mb-3">rpm.lair.cafe</h1>
|
||||
<p className="lead mb-4">
|
||||
Self-hosted RPM repository for Fedora, currently hosting CUDA-accelerated
|
||||
builds of{" "}
|
||||
<a href="https://github.com/EricLBuehler/mistral.rs">mistral.rs</a>.
|
||||
</p>
|
||||
|
||||
<Row className="g-4">
|
||||
<Col lg={12}>
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<Card.Title>Quick start</Card.Title>
|
||||
|
||||
<h6 className="mt-4">1. Import the signing key</h6>
|
||||
<CodeBlock language="bash">
|
||||
{`sudo rpm --import ${GPG_KEY_URL}`}
|
||||
</CodeBlock>
|
||||
|
||||
<h6 className="mt-4">2. Add the repository</h6>
|
||||
<CodeBlock language="bash">
|
||||
{`sudo dnf config-manager addrepo --from-repofile=/dev/stdin <<'EOF'\n${REPO_FILE}\nEOF`}
|
||||
</CodeBlock>
|
||||
|
||||
<h6 className="mt-4">3. Install a package</h6>
|
||||
<CodeBlock language="bash">
|
||||
{`sudo dnf install mistralrs-server-cuda13`}
|
||||
</CodeBlock>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
}
|
||||
100
ui/src/pages/PackageDetail.tsx
Normal file
100
ui/src/pages/PackageDetail.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Accordion, Alert, Badge, Spinner, Table } from "react-bootstrap";
|
||||
import { useParams } from "react-router";
|
||||
import { usePackages } from "../hooks/usePackages.ts";
|
||||
import { CodeBlock } from "../components/CodeBlock.tsx";
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function PackageDetail() {
|
||||
const { name } = useParams<{ name: string }>();
|
||||
const { manifest, loading, error } = usePackages();
|
||||
|
||||
if (loading) return <Spinner animation="border" />;
|
||||
if (error) return <Alert variant="danger">Failed to load packages: {error}</Alert>;
|
||||
if (!manifest) return <Alert variant="info">No package data available.</Alert>;
|
||||
|
||||
const versions = manifest.packages
|
||||
.filter((p) => p.name === name)
|
||||
.sort((a, b) => b.buildTime - a.buildTime);
|
||||
|
||||
if (versions.length === 0)
|
||||
return <Alert variant="warning">Package not found: {name}</Alert>;
|
||||
|
||||
const latest = versions[0];
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="mb-1">{name}</h1>
|
||||
<p className="text-body-secondary mb-4">{latest.summary}</p>
|
||||
|
||||
<CodeBlock language="bash">{`sudo dnf install ${name}`}</CodeBlock>
|
||||
|
||||
<h2 className="mt-4 mb-3">
|
||||
Versions <Badge bg="secondary">{versions.length}</Badge>
|
||||
</h2>
|
||||
|
||||
<Table striped hover responsive>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Version</th>
|
||||
<th>Size</th>
|
||||
<th>Built</th>
|
||||
<th>Download</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{versions.map((pkg) => (
|
||||
<tr key={`${pkg.version}-${pkg.release}`}>
|
||||
<td>
|
||||
{pkg.version}-{pkg.release}
|
||||
</td>
|
||||
<td>{formatBytes(pkg.size)}</td>
|
||||
<td>{new Date(pkg.buildTime * 1000).toLocaleDateString()}</td>
|
||||
<td>
|
||||
<a href={`${manifest.baseUrl}/${pkg.rpmFilename}`}>
|
||||
{pkg.rpmFilename}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
|
||||
{versions.some((v) => v.changelog.length > 0) && (
|
||||
<>
|
||||
<h2 className="mt-4 mb-3">Changelog</h2>
|
||||
<Accordion>
|
||||
{versions
|
||||
.filter((v) => v.changelog.length > 0)
|
||||
.map((pkg) => (
|
||||
<Accordion.Item
|
||||
key={`${pkg.version}-${pkg.release}`}
|
||||
eventKey={`${pkg.version}-${pkg.release}`}
|
||||
>
|
||||
<Accordion.Header>
|
||||
{pkg.version}-{pkg.release} —{" "}
|
||||
{new Date(pkg.buildTime * 1000).toLocaleDateString()}
|
||||
</Accordion.Header>
|
||||
<Accordion.Body>
|
||||
{pkg.changelog.map((entry, i) => (
|
||||
<div key={i} className="mb-3">
|
||||
<small className="text-body-secondary">
|
||||
{new Date(entry.date * 1000).toLocaleDateString()}{" "}
|
||||
— {entry.author}
|
||||
</small>
|
||||
<pre className="mb-0 mt-1">{entry.text}</pre>
|
||||
</div>
|
||||
))}
|
||||
</Accordion.Body>
|
||||
</Accordion.Item>
|
||||
))}
|
||||
</Accordion>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
53
ui/src/pages/PackageList.tsx
Normal file
53
ui/src/pages/PackageList.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Alert, Spinner, Table } from "react-bootstrap";
|
||||
import { Link } from "react-router";
|
||||
import { usePackages } from "../hooks/usePackages.ts";
|
||||
|
||||
export function PackageList() {
|
||||
const { manifest, loading, error } = usePackages();
|
||||
|
||||
if (loading) return <Spinner animation="border" />;
|
||||
if (error) return <Alert variant="danger">Failed to load packages: {error}</Alert>;
|
||||
if (!manifest || manifest.packages.length === 0)
|
||||
return <Alert variant="info">No packages published yet.</Alert>;
|
||||
|
||||
const byName = Map.groupBy(manifest.packages, (p) => p.name);
|
||||
const summaries = [...byName.entries()].map(([name, versions]) => {
|
||||
const latest = versions.reduce((a, b) =>
|
||||
a.buildTime >= b.buildTime ? a : b,
|
||||
);
|
||||
return { name, latest, versionCount: versions.length };
|
||||
});
|
||||
summaries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="mb-3">Packages</h1>
|
||||
<Table striped hover responsive>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Package</th>
|
||||
<th>Latest version</th>
|
||||
<th>Versions</th>
|
||||
<th>Summary</th>
|
||||
<th>Built</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{summaries.map(({ name, latest, versionCount }) => (
|
||||
<tr key={name}>
|
||||
<td>
|
||||
<Link to={`/packages/${name}`}>{name}</Link>
|
||||
</td>
|
||||
<td>
|
||||
{latest.version}-{latest.release}
|
||||
</td>
|
||||
<td>{versionCount}</td>
|
||||
<td>{latest.summary}</td>
|
||||
<td>{new Date(latest.buildTime * 1000).toLocaleDateString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</>
|
||||
);
|
||||
}
|
||||
74
ui/src/theme/ThemeContext.tsx
Normal file
74
ui/src/theme/ThemeContext.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
type ThemeChoice = "system" | "light" | "dark";
|
||||
type ResolvedTheme = "light" | "dark";
|
||||
|
||||
interface ThemeContextValue {
|
||||
choice: ThemeChoice;
|
||||
resolved: ResolvedTheme;
|
||||
setChoice: (choice: ThemeChoice) => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
function resolveSystemTheme(): ResolvedTheme {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
}
|
||||
|
||||
function resolve(choice: ThemeChoice): ResolvedTheme {
|
||||
return choice === "system" ? resolveSystemTheme() : choice;
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [choice, setChoiceState] = useState<ThemeChoice>(() => {
|
||||
const stored = localStorage.getItem("theme");
|
||||
return stored === "light" || stored === "dark" ? stored : "system";
|
||||
});
|
||||
const [resolved, setResolved] = useState<ResolvedTheme>(() =>
|
||||
resolve(choice),
|
||||
);
|
||||
|
||||
function setChoice(next: ThemeChoice) {
|
||||
setChoiceState(next);
|
||||
if (next === "system") {
|
||||
localStorage.removeItem("theme");
|
||||
} else {
|
||||
localStorage.setItem("theme", next);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setResolved(resolve(choice));
|
||||
|
||||
if (choice !== "system") return;
|
||||
|
||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handler = () => setResolved(resolveSystemTheme());
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, [choice]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute("data-bs-theme", resolved);
|
||||
}, [resolved]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ choice, resolved, setChoice }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
|
||||
return ctx;
|
||||
}
|
||||
32
ui/src/theme/ThemeToggle.tsx
Normal file
32
ui/src/theme/ThemeToggle.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Dropdown } from "react-bootstrap";
|
||||
import { useTheme } from "./ThemeContext.tsx";
|
||||
|
||||
const options = [
|
||||
{ key: "light" as const, label: "Light", icon: "☀️" },
|
||||
{ key: "dark" as const, label: "Dark", icon: "🌙" },
|
||||
{ key: "system" as const, label: "System", icon: "💻" },
|
||||
];
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { choice, setChoice } = useTheme();
|
||||
const current = options.find((o) => o.key === choice) ?? options[2];
|
||||
|
||||
return (
|
||||
<Dropdown align="end">
|
||||
<Dropdown.Toggle variant="outline-secondary" size="sm">
|
||||
{current.icon}
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu>
|
||||
{options.map((o) => (
|
||||
<Dropdown.Item
|
||||
key={o.key}
|
||||
active={o.key === choice}
|
||||
onClick={() => setChoice(o.key)}
|
||||
>
|
||||
{o.icon} {o.label}
|
||||
</Dropdown.Item>
|
||||
))}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
23
ui/src/types/packages.ts
Normal file
23
ui/src/types/packages.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export interface ChangelogEntry {
|
||||
author: string;
|
||||
date: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface PackageVersion {
|
||||
name: string;
|
||||
version: string;
|
||||
release: string;
|
||||
arch: string;
|
||||
summary: string;
|
||||
size: number;
|
||||
buildTime: number;
|
||||
rpmFilename: string;
|
||||
changelog: ChangelogEntry[];
|
||||
}
|
||||
|
||||
export interface PackagesManifest {
|
||||
generated: string;
|
||||
baseUrl: string;
|
||||
packages: PackageVersion[];
|
||||
}
|
||||
25
ui/tsconfig.json
Normal file
25
ui/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2023",
|
||||
"module": "esnext",
|
||||
"lib": ["ES2024", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"],
|
||||
"jsx": "react-jsx",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
ui/vite.config.ts
Normal file
7
ui/vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react-swc";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: "/",
|
||||
});
|
||||
Reference in New Issue
Block a user