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: |
|
run: |
|
||||||
ssh "gitea_ci@${RPM_REPO_HOST}" \
|
ssh "gitea_ci@${RPM_REPO_HOST}" \
|
||||||
"cd /var/www/rpm/fedora/${{ matrix.fedora_version }}/x86_64 && createrepo_c --update ."
|
"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;
|
root /var/www/rpm;
|
||||||
|
|
||||||
autoindex on;
|
|
||||||
autoindex_exact_size off;
|
|
||||||
autoindex_localtime on;
|
|
||||||
|
|
||||||
types {
|
types {
|
||||||
application/x-rpm rpm;
|
application/x-rpm rpm;
|
||||||
application/xml xml;
|
application/xml xml;
|
||||||
}
|
}
|
||||||
default_type application/octet-stream;
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
location /assets/ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
location ~ \.rpm$ {
|
location ~ \.rpm$ {
|
||||||
expires 30d;
|
expires 30d;
|
||||||
add_header Cache-Control "public, immutable";
|
add_header Cache-Control "public, immutable";
|
||||||
@@ -30,7 +31,16 @@ server {
|
|||||||
add_header Cache-Control "no-cache, must-revalidate";
|
add_header Cache-Control "no-cache, must-revalidate";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location ~ packages\.json$ {
|
||||||
|
expires 5m;
|
||||||
|
add_header Cache-Control "public, must-revalidate";
|
||||||
|
}
|
||||||
|
|
||||||
location ~ \.gpg$ {
|
location ~ \.gpg$ {
|
||||||
default_type text/plain;
|
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.
|
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
|
## Client setup
|
||||||
|
|
||||||
```bash
|
```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