Import rpm.lair.cafe SPA from lair/mistralrs-package/ui @34a28b5
All checks were successful
deploy / build-and-deploy (push) Successful in 31s

Dedicated repo for the RPM repository web UI, moved out of the
mistralrs-package repo now that multiple package repos publish to
rpm.lair.cafe. Includes an adapted deploy workflow (SPA at repo root).
This commit is contained in:
grenade
2026-07-02 11:11:47 +03:00
commit cc8c325c77
22 changed files with 2550 additions and 0 deletions

25
src/App.tsx Normal file
View 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>
);
}

View 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
src/components/Layout.tsx Normal file
View 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>
</>
);
}

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

81
src/hooks/usePackages.ts Normal file
View File

@@ -0,0 +1,81 @@
import { useEffect, useState } from "react";
import type { Channel, PackagesManifest, PackageVersion } from "../types/packages.ts";
// Fedora releasevers served at rpm.lair.cafe. Add new versions here as trees
// come online; missing trees (e.g. a releasever with no unstable repo) 404 and
// are skipped gracefully.
const RELEASEVERS = ["43", "44"];
interface ManifestSource {
url: string;
channel: Channel;
releasever: string;
}
function manifestSources(): ManifestSource[] {
return RELEASEVERS.flatMap((releasever) => [
{
url: `/fedora/${releasever}/x86_64/packages.json`,
channel: "stable" as const,
releasever,
},
{
url: `/fedora/${releasever}/x86_64/unstable/packages.json`,
channel: "unstable" as const,
releasever,
},
]);
}
function tagPackages(
manifest: PackagesManifest,
channel: Channel,
releasever: string,
): PackageVersion[] {
return manifest.packages.map((p) => ({
...p,
channel,
releasever,
baseUrl: manifest.baseUrl,
}));
}
export function usePackages() {
const [packages, setPackages] = useState<PackageVersion[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
const fetchManifest = async (
source: ManifestSource,
): Promise<PackageVersion[]> => {
const res = await fetch(source.url);
if (!res.ok) {
if (res.status === 404) return [];
throw new Error(`HTTP ${res.status} fetching ${source.url}`);
}
const data = (await res.json()) as PackagesManifest;
return tagPackages(data, source.channel, source.releasever);
};
Promise.all(manifestSources().map(fetchManifest))
.then((lists) => {
if (!cancelled) setPackages(lists.flat());
})
.catch((err: unknown) => {
if (!cancelled)
setError(err instanceof Error ? err.message : String(err));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
return { packages, loading, error };
}

10
src/main.tsx Normal file
View 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>,
);

91
src/pages/Home.tsx Normal file
View File

@@ -0,0 +1,91 @@
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_URL = "https://rpm.lair.cafe/lair-cafe.repo";
const UNSTABLE_REPO_URL = "https://rpm.lair.cafe/lair-cafe-unstable.repo";
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=${REPO_URL}`}
</CodeBlock>
<h6 className="mt-4">3. Install a package</h6>
<p className="text-body-secondary">
Choose the package matching your GPU generation:
</p>
<CodeBlock language="bash">
{`# RTX 3000 series (Ampere)\nsudo dnf install mistralrs-ampere\n\n# RTX 4000 series (Ada Lovelace)\nsudo dnf install mistralrs-ada\n\n# RTX 5000 series (Blackwell)\nsudo dnf install mistralrs-blackwell`}
</CodeBlock>
</Card.Body>
</Card>
</Col>
<Col lg={12}>
<Card>
<Card.Body>
<Card.Title>Unstable (prerelease) packages</Card.Title>
<p>
Unstable packages are built automatically from the latest
upstream <code>main</code> branch commit. They use the
next release version from <code>Cargo.toml</code> with a
prerelease suffix (e.g.{" "}
<code>0.8.1-0.1.20260511git1a2b3c4</code>). When the
upstream version is officially released, the stable package
will automatically supersede any installed prerelease.
</p>
<h6 className="mt-4">Add the unstable repository</h6>
<p className="text-body-secondary">
The unstable repo is disabled by default. Add it alongside the
stable repo:
</p>
<CodeBlock language="bash">
{`sudo dnf config-manager addrepo --from-repofile=${UNSTABLE_REPO_URL}`}
</CodeBlock>
<h6 className="mt-4">
Install or update from unstable
</h6>
<CodeBlock language="bash">
{`sudo dnf --enablerepo=lair-cafe-unstable install mistralrs-ada`}
</CodeBlock>
<h6 className="mt-4">
Pin to stable
</h6>
<p className="text-body-secondary">
If you have the unstable repo enabled and want to stay on
stable releases, exclude prerelease versions:
</p>
<CodeBlock language="bash">
{`sudo dnf --disablerepo=lair-cafe-unstable update mistralrs-ada`}
</CodeBlock>
</Card.Body>
</Card>
</Col>
</Row>
</>
);
}

119
src/pages/PackageDetail.tsx Normal file
View File

@@ -0,0 +1,119 @@
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 { packages, loading, error } = usePackages();
if (loading) return <Spinner animation="border" />;
if (error) return <Alert variant="danger">Failed to load packages: {error}</Alert>;
if (packages.length === 0) return <Alert variant="info">No package data available.</Alert>;
const versions = 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];
const hasUnstable = versions.some((v) => v.channel === "unstable");
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>
{hasUnstable && (
<div className="mt-3">
<CodeBlock language="bash">
{`# install latest unstable version\nsudo dnf --enablerepo=lair-cafe-unstable install ${name}`}
</CodeBlock>
</div>
)}
<h2 className="mt-4 mb-3">
Versions <Badge bg="secondary">{versions.length}</Badge>
</h2>
<Table striped hover responsive>
<thead>
<tr>
<th>Version</th>
<th>Fedora</th>
<th>Channel</th>
<th>Size</th>
<th>Built</th>
<th>Download</th>
</tr>
</thead>
<tbody>
{versions.map((pkg) => (
<tr key={`${pkg.version}-${pkg.release}-${pkg.channel}`}>
<td>
{pkg.version}-{pkg.release}
</td>
<td>
<Badge bg="secondary">fc{pkg.releasever}</Badge>
</td>
<td>
<Badge bg={pkg.channel === "stable" ? "success" : "warning"}>
{pkg.channel}
</Badge>
</td>
<td>{formatBytes(pkg.size)}</td>
<td>{new Date(pkg.buildTime * 1000).toLocaleDateString()}</td>
<td>
<a href={`${pkg.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>
</>
)}
</>
);
}

83
src/pages/PackageList.tsx Normal file
View File

@@ -0,0 +1,83 @@
import { Alert, Badge, Spinner, Table } from "react-bootstrap";
import { Link } from "react-router";
import { usePackages } from "../hooks/usePackages.ts";
export function PackageList() {
const { packages, loading, error } = usePackages();
if (loading) return <Spinner animation="border" />;
if (error) return <Alert variant="danger">Failed to load packages: {error}</Alert>;
if (packages.length === 0)
return <Alert variant="info">No packages published yet.</Alert>;
const byName = Map.groupBy(packages, (p) => p.name);
const summaries = [...byName.entries()].map(([name, versions]) => {
const stable = versions.filter((v) => v.channel === "stable");
const unstable = versions.filter((v) => v.channel === "unstable");
const releasevers = [...new Set(versions.map((v) => v.releasever))].sort();
const latest = versions.reduce((a, b) =>
a.buildTime >= b.buildTime ? a : b,
);
return {
name,
latest,
releasevers,
stableCount: stable.length,
unstableCount: unstable.length,
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>Fedora</th>
<th>Versions</th>
<th>Summary</th>
<th>Built</th>
</tr>
</thead>
<tbody>
{summaries.map(
({ name, latest, releasevers, stableCount, unstableCount }) => (
<tr key={name}>
<td>
<Link to={`/packages/${name}`}>{name}</Link>
</td>
<td>
{latest.version}-{latest.release}{" "}
<Badge bg={latest.channel === "stable" ? "success" : "warning"} className="ms-1">
{latest.channel}
</Badge>
</td>
<td>
{releasevers.map((rv) => (
<Badge key={rv} bg="secondary" className="me-1">
fc{rv}
</Badge>
))}
</td>
<td>
{stableCount > 0 && (
<Badge bg="success" className="me-1">{stableCount} stable</Badge>
)}
{unstableCount > 0 && (
<Badge bg="warning">{unstableCount} unstable</Badge>
)}
</td>
<td>{latest.summary}</td>
<td>{new Date(latest.buildTime * 1000).toLocaleDateString()}</td>
</tr>
),
)}
</tbody>
</Table>
</>
);
}

View 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
src/theme/ThemeToggle.tsx Normal file
View 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>
);
}

30
src/types/packages.ts Normal file
View File

@@ -0,0 +1,30 @@
export interface ChangelogEntry {
author: string;
date: number;
text: string;
}
export type Channel = "stable" | "unstable";
export interface PackageVersion {
name: string;
version: string;
release: string;
arch: string;
summary: string;
size: number;
buildTime: number;
rpmFilename: string;
changelog: ChangelogEntry[];
channel: Channel;
baseUrl: string;
// Fedora releasever ("43", "44", ...) — attached at fetch time from the
// tree each manifest was loaded from (manifests are per-releasever).
releasever: string;
}
export interface PackagesManifest {
generated: string;
baseUrl: string;
packages: PackageVersion[];
}