feat: add React UI for rpm.lair.cafe
Some checks failed
poll-upstream / check (push) Successful in 1s
deploy-ui / build-and-deploy (push) Failing after 19s

- 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:
2026-04-27 12:55:38 +03:00
parent a6cebc76ba
commit 7f9e857695
23 changed files with 2577 additions and 4 deletions

49
ui/src/pages/Home.tsx Normal file
View 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>
</>
);
}

View 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} &mdash;{" "}
{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()}{" "}
&mdash; {entry.author}
</small>
<pre className="mb-0 mt-1">{entry.text}</pre>
</div>
))}
</Accordion.Body>
</Accordion.Item>
))}
</Accordion>
</>
)}
</>
);
}

View 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>
</>
);
}