feat(web): add the operator UI and the /v1 inventory API it reads

Three routes, per architecture/generic.md §4 (Vite + React + SWC + TS,
static, served by nginx): / renders the repository readme, /models is a
paged and filterable listing of what the bucket holds, and
/{namespace}/{name} shows one repository -- its pinned refs, the files
actually stored, and its model card.

The API it reads is /v1/, deliberately not /api/. That surface is the
Hub's, recorded and replayed verbatim, and adding routes of our own to
it risks a client mistaking one for the real thing. /v1/ answers a
question the Hub has no equivalent for -- what is in this bucket --
which is inventory, not the model search the spec rules out.

The listing shows only what has actually been fetched, never upstream's
siblings. A repository pulled one file at a time shows one file, which
is the honest answer to "what can I get from here offline".

Model cards are third-party content, so their HTML is parsed and then
sanitised against GitHub's allowlist. The plugin order is load-bearing
and commented as such. Verified against a card crafted with <script>,
onerror, a javascript: href, an <iframe>, an SVG-embedded script and an
inline handler: none execute and ordinary markdown still renders. Card
images are not loaded at all -- fetching them would leak the viewer's
address to a third party and make an offline registry's pages depend on
the internet.

Routing: rustingface's URL space is the Hub's, so /Qwen/Qwen3-0.6B is
both a page and the prefix of a file. The vhost splits them the way the
Hub does -- /resolve/ anywhere in the path, plus /api/ and /v1/, go to
the service; everything else is the app. A repo legitimately named
v1/repos is handed back to the resolve path by the router, and there is
a test for it. The /resolve/ test is on the repo type rather than the
path substring, because a repo may contain a directory called resolve
and /api/models/a/b/tree/main/resolve/f must stay a tree request.

Deployment: the frontend ships to hanzalova:/var/www/rustingface, so
that host now gets its own scoped gitea_ci drop-in -- narrower than the
service host's: a webroot rsync, a relabel, nginx -t and a reload. The
health check probes rf.internal from the proxy rather than from the
runner, because a runner is a plain Fedora container with no internal
root CA (verified: fedora:43 gets 000, the proxy gets 200).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XZG2i4AmfSqE97EJGBVb64
This commit is contained in:
2026-08-31 12:37:26 +03:00
co-authored by Claude Opus 5
parent 010a8bbc0e
commit acd47a31c8
37 changed files with 5329 additions and 33 deletions
+17
View File
@@ -27,3 +27,20 @@ jobs:
run: |
rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl --bin rustingface
web:
runs-on: fedora-43
steps:
- uses: actions/checkout@v4
- name: install
working-directory: web
run: pnpm install --frozen-lockfile
- name: lint
working-directory: web
run: pnpm lint
- name: build
working-directory: web
run: pnpm build
+106
View File
@@ -19,6 +19,8 @@ env:
S3_ENDPOINT: http://caveman.kosherinata.internal:9000
S3_BUCKET: rustingface
UPSTREAM_ENABLED: "true"
PROXY_HOST: hanzalova.internal
WEBROOT: /var/www/rustingface
jobs:
build:
@@ -49,6 +51,31 @@ jobs:
name: rustingface
path: target/x86_64-unknown-linux-musl/release/rustingface
build-web:
runs-on: fedora-43
steps:
- uses: actions/checkout@v4
# The gate for the frontend, matching the Rust one: a type error or a
# lint failure must not reach a host either.
- name: install
working-directory: web
run: pnpm install --frozen-lockfile
- name: lint
working-directory: web
run: pnpm lint
- name: build
working-directory: web
# `pnpm build` runs `tsc -b` first, so type errors fail here.
run: pnpm build
- uses: actions/upload-artifact@v3
with:
name: rustingface-web
path: web/dist
deploy:
needs: build
runs-on: fedora-43
@@ -211,3 +238,82 @@ jobs:
- name: journal
if: always() && steps.auth.outcome == 'success'
run: ssh "$SERVICE_HOST" 'journalctl -u rustingface.service -n 200 --no-pager'
deploy-web:
# The frontend is only useful once the API it reads is live, and the vhost
# it is served from proxies to that API.
needs: [build-web, deploy]
runs-on: fedora-43
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v3
with:
name: rustingface-web
path: dist
- name: check the required secrets are set
env:
RSYNC_SSH_KEY: ${{ secrets.RSYNC_SSH_KEY }}
run: |
[ -n "${RSYNC_SSH_KEY:-}" ] || { echo "missing repo secret: RSYNC_SSH_KEY" >&2; exit 1; }
echo "all required secrets are present"
- name: authenticate to the proxy
id: auth
run: |
install -d -m 0700 ~/.ssh
printf '%s\n' "${{ secrets.RSYNC_SSH_KEY }}" > ~/.ssh/id_gitea_ci
chmod 600 ~/.ssh/id_gitea_ci
cat > ~/.ssh/config <<CFG
Host $PROXY_HOST
User gitea_ci
IdentityFile ~/.ssh/id_gitea_ci
IdentitiesOnly yes
StrictHostKeyChecking accept-new
CFG
ssh "$PROXY_HOST" hostname -f
- name: ship the built frontend
run: |
set -euo pipefail
[ -f dist/index.html ] || { echo "the build produced no index.html" >&2; exit 1; }
# --delete so a rename leaves no orphaned asset behind; the webroot
# holds nothing but this build's output.
rsync -az --delete --rsync-path='sudo rsync' \
dist/ "$PROXY_HOST:$WEBROOT/"
# Webroots must be httpd_sys_content_t or nginx answers 403.
ssh "$PROXY_HOST" "sudo restorecon -R $WEBROOT"
- name: reload nginx
run: |
set -euo pipefail
# `nginx -t` cannot catch a bind failure, but it does catch a config
# that would break every other vhost on this shared proxy.
ssh "$PROXY_HOST" 'sudo nginx -t'
ssh "$PROXY_HOST" 'sudo systemctl reload nginx'
- name: health check
run: |
set -euo pipefail
# Probed from the proxy, not from this runner. The runner is a plain
# Fedora container and does not carry the internal root CA, so a TLS
# probe from here would fail on trust rather than on anything real
# (verified: a fedora:43 container gets 000, the proxy gets 200).
# The proxy resolves rf.internal to itself, and a mesh name does not
# hit the public-name hairpin.
ssh "$PROXY_HOST" 'set -euo pipefail
# The app is served, and the API it reads is reachable through the
# same vhost. Both, because a working page over a dead API looks
# fine until somebody clicks Models.
curl -fsS https://rf.internal/ | grep -q "id=.root."
curl -fsS https://rf.internal/v1/status | grep -q bucket
curl -fsS https://rf.internal/healthz | grep -q ok
# A client route must fall back to the app rather than 404.
curl -fsS https://rf.internal/models | grep -q "id=.root."
# And an API path must still reach the service, not the app.
curl -fsS https://rf.internal/api/whoami-v2 | grep -q rustingface'
echo "frontend and API both answering through rf.internal"
- name: nginx log
if: always() && steps.auth.outcome == 'success'
run: ssh "$PROXY_HOST" 'sudo tail -n 50 /var/log/nginx/rf.internal.error.log'
+2
View File
@@ -1 +1,3 @@
/target
web/node_modules
web/dist
+17
View File
@@ -43,6 +43,23 @@ anything touching `rustingface-data`, because the local filesystem store hides
whole classes of problem (it has no multipart, no conditional update, and no
presigning).
## The web UI
`web/` is a Vite + React + SWC + TS app (`architecture/generic.md` §4), served
statically from the nginx host and reading the `/v1/` API.
- **Keep UI routes out of `/api/`.** That surface is the Hub's, replayed
verbatim. Anything rustingface invents goes under `/v1/`.
- **Model cards are untrusted third-party content.** `components/Markdown.tsx`
parses their HTML and then sanitises it; the plugin order (`rehype-raw`, then
`rehype-sanitize`) is not interchangeable, and reversing it would reintroduce
the raw HTML unchecked. Card images are deliberately not loaded.
- **Any new route must not shadow a repository id.** rustingface owns the whole
URL root, which is the Hub's namespace, so every prefix collides with some
repo. Where it is ambiguous, disambiguate in the handler the way
`v1_repo_detail` does — and test it against `/resolve/` in a *directory*
name, not just in a repo name.
## Where things live
Types in `entities`, logic in `core`, I/O in `data`, HTTP in `api`, wiring in
+81 -28
View File
@@ -1,9 +1,17 @@
# rf.internal — the mesh vhost for rustingface.
#
# Installed on hanzalova (the office edge proxy); the service itself runs on
# bob. TCP :443 belongs to the stream SNI router, which hands connections to
# the https tier on 127.0.0.1:14443, so this vhost listens there and reads the
# real client address from PROXY protocol.
# bob and the built frontend is served from this host. TCP :443 belongs to the
# stream SNI router, which hands connections to the https tier on
# 127.0.0.1:14443, so this vhost listens there and reads the real client
# address from PROXY protocol.
#
# The routing split is the interesting part. rustingface's URL space *is* the
# Hub's, so `/Qwen/Qwen3-0.6B` is both a page a person reads and the prefix of
# `/Qwen/Qwen3-0.6B/resolve/main/config.json`, which a client downloads. The
# Hub itself makes the same split, and so does this: anything containing
# `/resolve/`, plus the API prefixes, goes to the service; everything else is
# the single-page app.
upstream rustingface {
server bob.hanzalova.internal:20482;
@@ -19,39 +27,84 @@ server {
ssl_trusted_certificate /etc/pki/ca-trust/source/anchors/root-internal.pem;
ssl_protocols TLSv1.3;
root /var/www/rustingface;
index index.html;
# The vhost believes it is serving 127.0.0.1:14443, so any redirect it
# generates itself would carry that unreachable port.
# generates itself would carry that unreachable port. try_files on a real
# directory is exactly what generates one.
absolute_redirect off;
access_log /var/log/nginx/rf.internal.access.log;
error_log /var/log/nginx/rf.internal.error.log;
location / {
proxy_pass http://rustingface;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Connection "";
proxy_set_header X-Real-IP $proxy_protocol_addr;
proxy_set_header X-Forwarded-For $proxy_protocol_addr;
proxy_set_header X-Forwarded-Proto https;
# Proxy settings apply to every proxied location below; only `proxy_pass`
# differs, so they are set once here rather than repeated four times.
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Connection "";
proxy_set_header X-Real-IP $proxy_protocol_addr;
proxy_set_header X-Forwarded-For $proxy_protocol_addr;
proxy_set_header X-Forwarded-Proto https;
# Model weights are measured in tens of gigabytes. Buffering them to
# disk on the proxy would double the I/O and stall the client until the
# whole file had landed, so stream straight through.
proxy_buffering off;
proxy_request_buffering off;
client_max_body_size 0;
# Model weights are measured in tens of gigabytes. Buffering them to disk
# on the proxy would double the I/O and stall the client until the whole
# file had landed, so stream straight through.
proxy_buffering off;
proxy_request_buffering off;
client_max_body_size 0;
# A cold 40GB pull through this vhost is one long-lived response. The
# read timeout bounds silence between chunks, not total duration, so it
# stays modest; the send timeout must tolerate a slow client.
proxy_connect_timeout 15s;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
# A cold 40GB pull is one long-lived response. The read timeout bounds
# silence between chunks, not total duration, so it stays modest; the send
# timeout must tolerate a slow client.
proxy_connect_timeout 15s;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
# Never re-compress. The client sends `Accept-Encoding: identity`
# precisely so the sizes it learns are real.
gzip off;
proxy_set_header Accept-Encoding $http_accept_encoding;
# Never re-compress a blob. The client sends `Accept-Encoding: identity`
# precisely so the sizes it learns are real.
proxy_set_header Accept-Encoding $http_accept_encoding;
# --- the service -------------------------------------------------------
# `^~` so these win outright and the `/resolve/` regex below is never even
# evaluated for them: a repository file may legitimately live in a
# directory called `resolve`, and `/api/models/a/b/tree/main/resolve/f`
# must stay a tree request.
location ^~ /api/ { proxy_pass http://rustingface; }
location ^~ /v1/ { proxy_pass http://rustingface; }
location = /healthz { proxy_pass http://rustingface; }
# Mesh-only and unauthenticated, like every other exporter on this fleet.
location = /metrics { proxy_pass http://rustingface; }
# `/{repo_id}/resolve/…` and `/datasets/{repo_id}/resolve/…`. A regex
# location outranks the `/` prefix, so blob paths reach the service while
# `/{namespace}/{name}` alone falls through to the app.
location ~ /resolve/ { proxy_pass http://rustingface; }
# --- the app -----------------------------------------------------------
# Filenames are content-hashed by the build, so a given URL's bytes never
# change and a year is safe. index.html is the opposite: it names the
# current hashes, so it must never be held.
location /assets/ {
gzip_static on;
add_header Cache-Control "public, max-age=31536000, immutable";
}
location = /index.html {
add_header Cache-Control "no-cache";
}
# Client-side routing: /models and /{namespace}/{name} are app routes with
# no file behind them.
location / {
try_files $uri $uri/ /index.html;
}
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
}
@@ -0,0 +1,312 @@
//! The `/v1/` surface: what this registry actually holds.
//!
//! Deliberately separate from `/api/`. That surface is the Hub's, replayed
//! verbatim, and its shapes are dictated by what `huggingface_hub` expects;
//! adding routes of our own to it would risk a client mistaking one for the
//! real thing. This surface answers a different question — *what is in this
//! bucket* — which the Hub has no equivalent for, and which model search
//! (a stated non-goal) is not.
use axum::extract::{Query, State};
use axum::response::{IntoResponse, Response};
use rustingface_core::admin::RepoSummary;
use rustingface_entities::error::Error;
use rustingface_entities::repo::{RepoId, RepoType};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::error::ApiError;
use crate::state::AppState;
use crate::telemetry;
/// Default and maximum page sizes.
///
/// Each listed repo costs a manifest read, so the ceiling bounds how much work
/// one request can ask for.
const DEFAULT_PER_PAGE: usize = 24;
const MAX_PER_PAGE: usize = 100;
/// Query parameters for the inventory listing.
#[derive(Debug, Deserialize)]
pub struct ListQuery {
#[serde(default)]
pub page: Option<usize>,
#[serde(default)]
pub per_page: Option<usize>,
/// Restrict to `models` or `datasets`.
#[serde(default)]
pub r#type: Option<String>,
/// Case-insensitive substring match on the repo id.
#[serde(default)]
pub q: Option<String>,
}
/// `GET /v1/repos`
pub async fn list(
State(state): State<AppState>,
Query(query): Query<ListQuery>,
) -> Result<Response, ApiError> {
let types: Vec<RepoType> = match query.r#type.as_deref() {
None | Some("") | Some("all") => RepoType::ALL.to_vec(),
Some(other) => vec![other.parse()?],
};
// Listing repo ids is a prefix scan of small objects; summarising one
// means reading its manifests. So the filter runs over the cheap list and
// only the requested page is summarised.
let needle = query.q.as_deref().map(str::to_lowercase);
let mut ids: Vec<(RepoType, RepoId)> = Vec::new();
for repo_type in types {
for repo_id in state.registry.list_repos(repo_type).await? {
let matches = needle
.as_deref()
.is_none_or(|n| repo_id.to_string().to_lowercase().contains(n));
if matches {
ids.push((repo_type, repo_id));
}
}
}
ids.sort_by(|a, b| a.1.cmp(&b.1).then(a.0.cmp(&b.0)));
let total = ids.len();
let per_page = query
.per_page
.unwrap_or(DEFAULT_PER_PAGE)
.clamp(1, MAX_PER_PAGE);
let pages = total.div_ceil(per_page).max(1);
let page = query.page.unwrap_or(1).clamp(1, pages);
let start = (page - 1) * per_page;
let mut repos = Vec::new();
for (repo_type, repo_id) in ids.iter().skip(start).take(per_page) {
let summary = state.registry.summarise(*repo_type, repo_id).await?;
repos.push(brief(&summary));
}
telemetry::request("v1_repos", 200);
Ok(axum::Json(json!({
"page": page,
"per_page": per_page,
"pages": pages,
"total": total,
"repos": repos,
}))
.into_response())
}
/// `GET /v1/repos/{type}/{namespace}/{name}` and its bare-name form.
pub async fn detail(State(state): State<AppState>, rest: String) -> Result<Response, ApiError> {
let segments: Vec<&str> = rest.trim_matches('/').split('/').collect();
if segments.len() < 2 {
return Err(ApiError(Error::BadRequest(
"expected /v1/repos/{type}/{repo_id}".into(),
)));
}
let repo_type: RepoType = segments[0].parse()?;
let repo_id: RepoId = segments[1..].join("/").parse()?;
if !state.registry.repo_is_known(repo_type, &repo_id).await? {
return Err(ApiError(Error::RepoNotFound(format!(
"{repo_id} is not stored in this registry"
))));
}
let summary = state.registry.summarise(repo_type, &repo_id).await?;
// The revision a viewer means is whatever the default ref points at; fall
// back to the newest recorded commit for a repo pinned only by SHA.
let default_ref = summary
.refs
.iter()
.find(|(name, _)| name == "main")
.or_else(|| summary.refs.first())
.cloned();
let commit = default_ref
.as_ref()
.map(|(_, commit)| commit.clone())
.or_else(|| summary.commits.last().cloned());
let mut files = Vec::new();
if let Some(commit) = &commit
&& let Some(manifest) = state.registry.load_manifest(repo_type, &repo_id, commit).await?
{
for entry in &manifest.entries {
files.push(json!({
"path": entry.path,
"size": entry.size,
"lfs": entry.lfs,
"digest": entry.digest,
}));
}
}
telemetry::request("v1_repo", 200);
Ok(axum::Json(json!({
"repo_type": repo_type.as_str(),
"repo_id": repo_id.to_string(),
"refs": summary.refs.iter().map(|(name, commit)| json!({
"name": name,
"commit": commit,
})).collect::<Vec<_>>(),
"commits": summary.commits,
"default_ref": default_ref.as_ref().map(|(name, _)| name),
"commit": commit,
"files": files,
"file_count": summary.files,
"bytes": summary.bytes,
"recorded_at": summary.recorded_at.map(rfc3339),
"meta": summary.meta.as_ref().map(card),
}))
.into_response())
}
/// `GET /v1/status`
///
/// What this instance is, for a viewer deciding whether it can be trusted to
/// serve them offline.
pub async fn status(State(state): State<AppState>) -> Result<Response, ApiError> {
let config = state.registry.config();
let mut counts = Vec::new();
let mut total = 0usize;
for repo_type in RepoType::ALL {
let n = state.registry.list_repos(repo_type).await?.len();
total += n;
counts.push(json!({ "type": repo_type.as_str(), "count": n }));
}
telemetry::request("v1_status", 200);
Ok(axum::Json(json!({
"version": env!("CARGO_PKG_VERSION"),
"sealed": config.sealed(),
"upstream": config.upstream.enabled.then(|| config.upstream.endpoint.clone()),
"bucket": config.storage.bucket,
"ref_resolution": match config.policy.ref_resolution {
rustingface_entities::config::RefResolution::Freeze => "freeze",
rustingface_entities::config::RefResolution::Follow => "follow",
},
"allowlist": config.policy.allowlist,
"repos": counts,
"total": total,
}))
.into_response())
}
/// The subset of a summary worth putting in a list of many.
///
/// Repo metadata is stored whole and can be tens of kilobytes — mostly the
/// `siblings` array — so a page of it would be megabytes for no benefit.
fn brief(summary: &RepoSummary) -> Value {
let meta = summary.meta.as_ref();
let string = |key: &str| meta.and_then(|m| m.get(key)).and_then(Value::as_str);
json!({
"repo_type": summary.repo_type.as_str(),
"repo_id": summary.repo_id.to_string(),
"revisions": summary.commits.len(),
"files": summary.files,
"bytes": summary.bytes,
"refs": summary.refs.iter().map(|(name, commit)| json!({
"name": name,
"commit": commit,
})).collect::<Vec<_>>(),
"pipeline_tag": string("pipeline_tag"),
"library_name": string("library_name"),
"last_modified": string("lastModified"),
"gated": meta.and_then(|m| m.get("gated")).cloned(),
"tags": meta
.and_then(|m| m.get("tags"))
.and_then(Value::as_array)
.map(|tags| tags.iter().take(6).cloned().collect::<Vec<_>>()),
"recorded_at": summary.recorded_at.map(rfc3339),
})
}
/// The subset of repo metadata a card page renders.
///
/// `siblings` is dropped: the file list comes from `entries`, which is what is
/// actually stored, and advertising the rest would be the same lie sealed mode
/// exists to avoid.
fn card(meta: &Value) -> Value {
let take = |key: &str| meta.get(key).cloned().unwrap_or(Value::Null);
json!({
"id": take("id"),
"author": take("author"),
"sha": take("sha"),
"pipeline_tag": take("pipeline_tag"),
"library_name": take("library_name"),
"lastModified": take("lastModified"),
"createdAt": take("createdAt"),
"downloads": take("downloads"),
"likes": take("likes"),
"gated": take("gated"),
"private": take("private"),
"tags": take("tags"),
"cardData": take("cardData"),
"config": take("config"),
})
}
/// Render a timestamp the way every other document in this project does.
fn rfc3339(at: time::OffsetDateTime) -> String {
at.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use rustingface_core::admin::RepoSummary;
fn summary() -> RepoSummary {
RepoSummary {
repo_type: RepoType::Models,
repo_id: "Qwen/Qwen3-0.6B".parse().unwrap(),
refs: vec![("main".into(), "c1899de".into())],
commits: vec!["c1899de".into()],
files: 10,
bytes: 1_503_300_328,
recorded_at: None,
meta: Some(json!({
"id": "Qwen/Qwen3-0.6B",
"pipeline_tag": "text-generation",
"library_name": "transformers",
"tags": ["a", "b", "c", "d", "e", "f", "g", "h"],
"siblings": (0..500).map(|i| json!({"rfilename": format!("f{i}")})).collect::<Vec<_>>(),
})),
}
}
#[test]
fn a_list_entry_drops_the_bulk_of_the_metadata() {
let brief = brief(&summary());
assert_eq!(brief["pipeline_tag"], "text-generation");
assert_eq!(brief["bytes"], 1_503_300_328u64);
assert!(brief.get("siblings").is_none(), "siblings must not reach a listing");
assert_eq!(
brief["tags"].as_array().unwrap().len(),
6,
"tags are capped so one repo cannot dominate a page"
);
}
#[test]
fn a_card_keeps_the_useful_fields_and_still_drops_siblings() {
let summary = summary();
let card = card(summary.meta.as_ref().unwrap());
assert_eq!(card["pipeline_tag"], "text-generation");
assert_eq!(card["library_name"], "transformers");
assert!(
card.get("siblings").is_none(),
"the file list comes from entries, which is what is actually stored"
);
assert_eq!(card["tags"].as_array().unwrap().len(), 8, "a card shows them all");
}
#[test]
fn a_summary_without_metadata_still_renders() {
let mut summary = summary();
summary.meta = None;
let brief = brief(&summary);
assert_eq!(brief["repo_id"], "Qwen/Qwen3-0.6B");
assert!(brief["pipeline_tag"].is_null());
}
}
@@ -1,5 +1,6 @@
//! HTTP handlers.
pub mod blob;
pub mod inventory;
pub mod meta;
pub mod ops;
+29
View File
@@ -35,6 +35,11 @@ pub fn router(state: AppState) -> Router {
.route("/healthz", get(handlers::ops::healthz))
.route("/metrics", get(handlers::ops::metrics))
.route("/api/whoami-v2", get(handlers::meta::whoami))
// The registry's own surface: what this bucket holds. Kept off /api/,
// which belongs to the Hub's shapes.
.route("/v1/status", get(handlers::inventory::status))
.route("/v1/repos", get(handlers::inventory::list))
.route("/v1/repos/{*rest}", get(v1_repo_detail).head(v1_repo_detail))
.route("/api/models/{*rest}", get(api_models))
.route("/api/datasets/{*rest}", get(api_datasets))
.route(
@@ -67,6 +72,30 @@ async fn authenticate(State(state): State<AppState>, request: Request, next: Nex
next.run(request).await
}
/// `GET /v1/repos/{type}/{repo_id}`
///
/// rustingface owns the whole URL root, which is the Hub's namespace, so every
/// route it adds of its own shadows some repository id. Here the shapes are
/// distinguishable: this route's first segment is always a repo type, so
/// anything else under `/v1/repos/` belongs to a repository actually named
/// `v1/repos` and is handed back to the resolve path.
///
/// The test is the repo type specifically, not the presence of `/resolve/` in
/// the path — a repo may contain a *directory* called `resolve`, and
/// `/api/models/x/y/tree/main/resolve/f` must stay a tree request.
async fn v1_repo_detail(
state: State<AppState>,
method: Method,
headers: HeaderMap,
axum::extract::Path(rest): axum::extract::Path<String>,
) -> Result<Response, ApiError> {
let first = rest.trim_matches('/').split('/').next().unwrap_or_default();
if first.parse::<RepoType>().is_err() {
return dispatch(state, RepoType::Models, method, headers, format!("v1/repos/{rest}")).await;
}
handlers::inventory::detail(state, rest).await
}
/// `GET /api/models/…`
async fn api_models(
state: State<AppState>,
+108
View File
@@ -965,6 +965,114 @@ async fn bearer_auth_refuses_an_unknown_token_but_never_the_health_probe() {
);
}
#[tokio::test]
async fn the_v1_inventory_lists_what_is_actually_stored() {
let hub = FakeHub::start().await;
publish_model(&hub).await;
hub.publish(
"meta-llama/Llama-3-8B",
"main",
COMMIT_B,
&[("config.json", HubFile::plain(r#"{"model_type":"llama"}"#))],
)
.await;
let bucket = bucket_dir("v1");
let rf = instance(&bucket, Some(&hub), |_| {});
// Nothing is stored until something is fetched, so an empty registry
// reports an empty inventory rather than the Hub's catalogue.
let (status, _, body) = call(&rf.app, "GET", "/v1/repos", &[]).await;
assert_eq!(status, StatusCode::OK);
let page: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(page["total"], 0);
for uri in [
"/Qwen/Qwen3-32B/resolve/main/config.json",
"/Qwen/Qwen3-32B/resolve/main/model.safetensors",
"/meta-llama/Llama-3-8B/resolve/main/config.json",
] {
assert_eq!(call(&rf.app, "GET", uri, &[]).await.0, StatusCode::OK, "{uri}");
}
let (_, _, body) = call(&rf.app, "GET", "/v1/repos", &[]).await;
let page: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(page["total"], 2);
let repos = page["repos"].as_array().unwrap();
assert_eq!(repos[0]["repo_id"], "Qwen/Qwen3-32B", "sorted by id");
assert_eq!(repos[0]["files"], 2);
assert!(repos[0]["bytes"].as_u64().unwrap() > 0);
// Paging.
let (_, _, body) = call(&rf.app, "GET", "/v1/repos?per_page=1&page=2", &[]).await;
let page: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(page["pages"], 2);
assert_eq!(page["repos"].as_array().unwrap().len(), 1);
assert_eq!(page["repos"][0]["repo_id"], "meta-llama/Llama-3-8B");
// Search.
let (_, _, body) = call(&rf.app, "GET", "/v1/repos?q=llama", &[]).await;
let page: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(page["total"], 1);
assert_eq!(page["repos"][0]["repo_id"], "meta-llama/Llama-3-8B");
// Detail, including the file list actually stored.
let (status, _, body) = call(&rf.app, "GET", "/v1/repos/models/Qwen/Qwen3-32B", &[]).await;
assert_eq!(status, StatusCode::OK);
let detail: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(detail["commit"], COMMIT_A);
assert_eq!(detail["default_ref"], "main");
let files = detail["files"].as_array().unwrap();
assert_eq!(files.len(), 2);
assert!(files.iter().any(|f| f["path"] == "model.safetensors" && f["lfs"] == true));
// A repo this registry has never seen is a 404, not an empty page.
let (status, headers, _) = call(&rf.app, "GET", "/v1/repos/models/nobody/nothing", &[]).await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(headers["x-error-code"], "RepoNotFound");
}
#[tokio::test]
async fn v1_status_reports_whether_the_instance_is_sealed() {
let hub = FakeHub::start().await;
publish_model(&hub).await;
let bucket = bucket_dir("v1-status");
let rf = instance(&bucket, Some(&hub), |_| {});
let (status, _, body) = call(&rf.app, "GET", "/v1/status", &[]).await;
assert_eq!(status, StatusCode::OK);
let doc: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(doc["sealed"], false);
assert_eq!(doc["ref_resolution"], "freeze");
assert!(doc["upstream"].is_string());
let sealed = instance(&bucket, None, |_| {});
let (_, _, body) = call(&sealed.app, "GET", "/v1/status", &[]).await;
let doc: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(doc["sealed"], true);
assert!(doc["upstream"].is_null(), "a sealed instance names no upstream");
}
#[tokio::test]
async fn the_v1_surface_does_not_shadow_a_repo_named_like_it() {
let hub = FakeHub::start().await;
hub.publish(
"v1/repos",
"main",
COMMIT_A,
&[("config.json", HubFile::plain("{}"))],
)
.await;
let bucket = bucket_dir("v1-shadow");
let rf = instance(&bucket, Some(&hub), |_| {});
// A resolve path is unambiguous even when the repo id collides with the
// registry's own routes, because it carries /resolve/.
let (status, _, body) = call(&rf.app, "GET", "/v1/repos/resolve/main/config.json", &[]).await;
assert_eq!(status, StatusCode::OK, "a repo may legitimately be named v1/repos");
assert_eq!(body, b"{}");
}
#[tokio::test]
async fn whoami_answers_so_hf_auth_login_can_complete() {
let bucket = bucket_dir("whoami");
+12
View File
@@ -30,6 +30,11 @@ pub struct RepoSummary {
pub files: usize,
/// Bytes referenced, counting each distinct blob once.
pub bytes: u64,
/// The most recently recorded commit's upstream metadata document, if one
/// was captured. Callers pick the fields they need; it is stored whole.
pub meta: Option<serde_json::Value>,
/// When that commit was recorded.
pub recorded_at: Option<time::OffsetDateTime>,
}
/// What `rustingface rm` detached.
@@ -93,10 +98,17 @@ impl Registry {
}
}
// "Most recent" is by record time, not commit order: commits are
// opaque SHAs with no inherent ordering, and what a viewer wants is
// the metadata this registry saw last.
let newest = manifests.iter().max_by_key(|m| m.recorded_at);
Ok(RepoSummary {
repo_type,
repo_id: repo_id.clone(),
refs,
meta: newest.and_then(|m| m.repo_meta.clone()),
recorded_at: newest.map(|m| m.recorded_at),
commits: manifests.iter().map(|m| m.commit.clone()).collect(),
files: files.len(),
bytes,
+63 -4
View File
@@ -51,6 +51,7 @@ crates/
rustingface-data/ adapters: object_store (S3 and local) and reqwest.
rustingface-api/ the axum surface and the header contract.
rustingface/ the binary: serve, plus the admin subcommands.
web/ Vite + React + SWC + TS; the operator-facing UI
asset/ systemd, firewalld, nginx, config template
script/infra-setup.sh one-time host provisioning
test/conformance/ the suite that drives a real huggingface_hub
@@ -66,6 +67,57 @@ Two departures from `architecture/generic.md`, both deliberate:
---
## The web UI
`web/` is a static single-page app served from the nginx host. Three routes:
`/` renders this readme, `/models` is a paged, filterable listing of what the
bucket holds, and `/{namespace}/{name}` shows one repository — its pinned refs,
the files actually stored, and its model card.
It reads a **`/v1/` API** that is deliberately separate from `/api/`. That
surface belongs to the Hub: it is recorded and replayed verbatim, and adding
routes of our own to it risks a client mistaking one for the real thing.
`/v1/` answers a question the Hub has no equivalent for — *what is in this
bucket* — which is inventory, not the model search the spec rules out.
```
GET /v1/status what this instance is; whether it is sealed
GET /v1/repos?page=&per_page=&type=&q= paged inventory
GET /v1/repos/{type}/{repo_id} one repository, with its stored files
```
Two things the UI is careful about, because model cards are third-party
content:
- Card HTML is parsed and then **sanitised** against GitHub's allowlist.
Verified against a card crafted with `<script>`, `onerror`, a `javascript:`
href, an `<iframe>`, an SVG-embedded script and an inline handler: none
execute, and ordinary markdown still renders.
- Images referenced by a card are **not loaded**; the alt text stands in.
Fetching them would leak the viewer's address to a third party and make an
offline registry's pages depend on the internet.
```sh
cd web && pnpm install
pnpm dev # proxies /v1, /api and the resolve paths to a local service
VITE_DEV_API=http://bob.hanzalova.internal:20482 pnpm dev
```
### Routing
rustingface's URL space *is* the Hub's, so `/Qwen/Qwen3-0.6B` is both a page a
person reads and the prefix of a file a client downloads. The vhost splits them
the same way the Hub does — anything containing `/resolve/`, plus `/api/` and
`/v1/`, goes to the service; everything else is the app, with client-side
routes falling back to `index.html`.
The one consequence worth knowing: a repository named `v1/repos` or `api/…`
collides with those prefixes. `/v1/repos/…` is disambiguated in the router (its
first segment is always a repo type, so anything else is handed back to the
resolve path), and the Hub itself cannot host a repo at `api/models` either.
---
## Build and run
```sh
@@ -134,13 +186,20 @@ operator typing `gc`.
| Service | `bob.hanzalova.internal:20482` |
| Object storage | MinIO on `caveman.kosherinata.internal:9000`, bucket `rustingface` |
| Ingress | `rf.internal`, via the hanzalova edge proxy (mesh-only, no public record) |
| Frontend | `/var/www/rustingface` on `hanzalova`, served by the same vhost |
| Port | `20482`, derived from the service name per `architecture/port-allocations.md` §3 |
Deployment is CI-driven (`.gitea/workflows/deploy.yml`): push to `main`, the
workflow runs the lint/test gate, builds a static musl binary, ships it as
`gitea_ci` over scoped sudo, and health-checks with `/healthz` followed by
`rustingface doctor` — which proves the bucket is actually writable rather than
merely that a socket opened.
workflow runs the lint/test gate, builds a static musl binary and the frontend,
ships both as `gitea_ci` over scoped sudo, and health-checks. The service is
probed with `/healthz` and then `rustingface doctor`, which proves the bucket is
actually writable rather than merely that a socket opened; the frontend is
probed through `rf.internal` **from the proxy**, since a CI runner is a plain
container that does not carry the internal root CA.
The frontend deploys to `hanzalova` (`/var/www/rustingface`) and the service to
`bob`, so both hosts carry a scoped `gitea_ci` sudoers drop-in. The proxy's is
the narrower of the two: a webroot rsync, a relabel, `nginx -t` and a reload.
One-time host provisioning is `script/infra-setup.sh`, run by an operator from
a workstation with full sudo. It is idempotent and skips unreachable hosts.
+50 -1
View File
@@ -13,7 +13,8 @@
# What it does:
# 1. bob — gitea_ci user, runner key, scoped sudoers, SELinux port label
# 2. caveman — the rustingface bucket and a scoped MinIO service account
# 3. hanzalova— rf.internal cert, nginx vhost, step@ renewal timer
# 3. hanzalova— rf.internal cert, nginx vhost, webroot, step@ renewal timer,
# and the gitea_ci account CI uses to ship the built frontend
#
# Application config is NOT shipped here. The deploy workflow renders it from
# Gitea secrets on every deploy.
@@ -28,6 +29,7 @@ readonly MINIO_HOST=caveman.kosherinata.internal
readonly MINIO_URL=http://caveman.kosherinata.internal:9000
readonly BUCKET=rustingface
readonly VHOST=rf
readonly WEBROOT=/var/www/rustingface
readonly RUNNER_PUBKEY="${RUNNER_PUBKEY:-$HOME/.ssh/id_gitea_ci.pub}"
readonly PROVISIONER_PASSWORD="${PROVISIONER_PASSWORD:-$HOME/.step/secrets/provisioner}"
readonly ROOT_CA=/etc/pki/ca-trust/source/anchors/root-internal.pem
@@ -280,6 +282,51 @@ With 24h certs this puts an expired certificate on the wire within a day.
Investigate the bind failure in the nginx error log, then restart nginx."
fi
# CI ships the built frontend here, so this host needs its own scoped
# deploy account. Its whitelist is narrower than the service host's: a
# webroot rsync, a relabel, and an nginx config test plus reload.
ssh "$PROXY_HOST" 'sudo bash -s' <<'REMOTE'
set -euo pipefail
if id gitea_ci >/dev/null 2>&1; then
echo " gitea_ci exists"
else
useradd --system --create-home --home-dir /var/lib/gitea_ci --shell /bin/bash gitea_ci
echo " created gitea_ci"
fi
install -d -o gitea_ci -g gitea_ci -m 0700 /var/lib/gitea_ci/.ssh
usermod -aG systemd-journal gitea_ci
REMOTE
# shellcheck disable=SC2029 # the key must expand locally
ssh "$PROXY_HOST" "sudo bash -c '
umask 077
touch /var/lib/gitea_ci/.ssh/authorized_keys
grep -qxF \"$(cat "$RUNNER_PUBKEY")\" /var/lib/gitea_ci/.ssh/authorized_keys \
|| echo \"$(cat "$RUNNER_PUBKEY")\" >> /var/lib/gitea_ci/.ssh/authorized_keys
chown -R gitea_ci:gitea_ci /var/lib/gitea_ci/.ssh
chmod 600 /var/lib/gitea_ci/.ssh/authorized_keys'"
ok "gitea_ci account and runner key on the proxy"
ssh "$PROXY_HOST" 'sudo bash -s' <<REMOTE
set -euo pipefail
install -d -o root -g root -m 0755 ${WEBROOT}
# /var/www is httpd_sys_content_t by default; relabel so a freshly created
# directory inherits it rather than picking up the parent's runtime context.
restorecon -R ${WEBROOT}
cat > /etc/sudoers.d/.${APP}_gitea_ci.new <<'SUDOERS'
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * ${WEBROOT}/
gitea_ci ALL=(root) NOPASSWD: /usr/bin/install -d -o root -g root -m 0755 ${WEBROOT}
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/restorecon -R ${WEBROOT}
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/nginx -t
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl reload nginx
SUDOERS
chmod 0440 /etc/sudoers.d/.${APP}_gitea_ci.new
visudo -cf /etc/sudoers.d/.${APP}_gitea_ci.new
mv /etc/sudoers.d/.${APP}_gitea_ci.new /etc/sudoers.d/${APP}_gitea_ci
REMOTE
ok "webroot ${WEBROOT} and scoped sudoers on the proxy"
info "DNS"
if getent hosts "${VHOST}.internal" >/dev/null; then
ok "${VHOST}.internal resolves"
@@ -292,6 +339,8 @@ Investigate the bind failure in the nginx error log, then restart nginx."
echo " opn-cli --config ~/.opn-cli/kosherinata.yml unbound host create \\"
echo " --hostname ${VHOST} --domain internal --server 10.6.0.46 \\"
echo " --description 'rustingface via hanzalova reverse proxy (bob backend)'"
echo " opn-cli 'create' only stages the record; apply it on each router"
echo " (reverse-proxies.md §2). Verify with: getent hosts ${VHOST}.internal"
echo " Never give ${VHOST}.internal a public / Cloudflare record."
}
+6
View File
@@ -0,0 +1,6 @@
{
"semi": false,
"singleQuote": true,
"printWidth": 90,
"trailingComma": "all"
}
+19
View File
@@ -0,0 +1,19 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: { ecmaVersion: 2022, globals: globals.browser },
plugins: { 'react-hooks': reactHooks, 'react-refresh': reactRefresh },
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
},
},
)
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex, nofollow" />
<title>rustingface</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+36
View File
@@ -0,0 +1,36 @@
{
"name": "rustingface-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint .",
"typecheck": "tsc -b"
},
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.9.5",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@eslint/js": "^9.39.0",
"@types/node": "^24.10.1",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react-swc": "^4.2.0",
"eslint": "^9.39.0",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.2",
"vite": "^7.2.2"
}
}
+3226
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
import { Routes, Route } from 'react-router-dom'
import Layout from './components/Layout'
import Home from './routes/Home'
import Models from './routes/Models'
import Repo from './routes/Repo'
import NotFound from './routes/NotFound'
export default function App() {
return (
<Layout>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/models" element={<Models />} />
{/* A namespaced repo, and the bare-named form the Hub also allows
(`gpt2`). Static segments outrank dynamic ones in React Router, so
`/models` is never captured by `/:name`. */}
<Route path="/:namespace/:name" element={<Repo />} />
<Route path="/:name" element={<Repo />} />
<Route path="*" element={<NotFound />} />
</Routes>
</Layout>
)
}
+135
View File
@@ -0,0 +1,135 @@
/**
* The rustingface `/v1` surface.
*
* Same origin as the app: nginx serves the built assets and proxies `/v1`,
* `/api` and the resolve paths to the service, so there is no base URL to
* configure and no CORS to arrange.
*/
export interface RepoRef {
name: string
commit: string
}
export interface RepoBrief {
repo_type: 'models' | 'datasets'
repo_id: string
revisions: number
files: number
bytes: number
refs: RepoRef[]
pipeline_tag: string | null
library_name: string | null
last_modified: string | null
gated: unknown
tags: string[] | null
recorded_at: string | null
}
export interface RepoPage {
page: number
per_page: number
pages: number
total: number
repos: RepoBrief[]
}
export interface RepoFile {
path: string
size: number
lfs: boolean
digest: string
}
export interface RepoDetail {
repo_type: 'models' | 'datasets'
repo_id: string
refs: RepoRef[]
commits: string[]
default_ref: string | null
commit: string | null
files: RepoFile[]
file_count: number
bytes: number
recorded_at: string | null
meta: Record<string, unknown> | null
}
export interface Status {
version: string
sealed: boolean
upstream: string | null
bucket: string
ref_resolution: 'freeze' | 'follow'
allowlist: string[]
repos: { type: string; count: number }[]
total: number
}
/** Raised for any non-2xx response, carrying the status so callers can branch. */
export class ApiError extends Error {
constructor(
readonly status: number,
message: string,
) {
super(message)
this.name = 'ApiError'
}
}
async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
const response = await fetch(path, { signal, headers: { accept: 'application/json' } })
if (!response.ok) {
// The service puts a typed reason in X-Error-Code and a sentence in
// X-Error-Message; prefer those over a bare status.
const code = response.headers.get('x-error-code')
const detail = response.headers.get('x-error-message')
throw new ApiError(response.status, detail ?? code ?? `request failed (${response.status})`)
}
return (await response.json()) as T
}
export function fetchStatus(signal?: AbortSignal): Promise<Status> {
return getJson<Status>('/v1/status', signal)
}
export function fetchRepos(
params: { page?: number; perPage?: number; type?: string; q?: string },
signal?: AbortSignal,
): Promise<RepoPage> {
const query = new URLSearchParams()
if (params.page) query.set('page', String(params.page))
if (params.perPage) query.set('per_page', String(params.perPage))
if (params.type && params.type !== 'all') query.set('type', params.type)
if (params.q) query.set('q', params.q)
return getJson<RepoPage>(`/v1/repos?${query}`, signal)
}
export function fetchRepo(
repoType: string,
repoId: string,
signal?: AbortSignal,
): Promise<RepoDetail> {
return getJson<RepoDetail>(`/v1/repos/${repoType}/${repoId}`, signal)
}
/**
* The model card, read through the ordinary resolve path.
*
* That is the same URL a client uses, so a card rendering here is direct
* evidence the blob path works. Returns `null` when the card was never
* fetched: a registry records what it has actually seen, and a repo pulled
* without its README simply has no card to show.
*/
export async function fetchCard(
repoType: string,
repoId: string,
revision: string,
signal?: AbortSignal,
): Promise<string | null> {
const prefix = repoType === 'datasets' ? 'datasets/' : ''
const response = await fetch(`/${prefix}${repoId}/resolve/${revision}/README.md`, { signal })
if (response.status === 404) return null
if (!response.ok) throw new ApiError(response.status, `could not read the model card`)
return await response.text()
}
+31
View File
@@ -0,0 +1,31 @@
import type { ReactNode } from 'react'
import { NavLink } from 'react-router-dom'
import StatusBadge from './StatusBadge'
export default function Layout({ children }: { children: ReactNode }) {
return (
<>
<header className="masthead">
<div className="bar">
<NavLink to="/" className="wordmark">
rustingface
</NavLink>
<nav>
<NavLink to="/" end>
About
</NavLink>
<NavLink to="/models">Models</NavLink>
</nav>
<StatusBadge />
</div>
</header>
<main>{children}</main>
<footer>
<p>
A sovereign, HuggingFace-compatible model registry. Mesh-only; not for
redistribution.
</p>
</footer>
</>
)
}
+42
View File
@@ -0,0 +1,42 @@
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeRaw from 'rehype-raw'
import rehypeSanitize from 'rehype-sanitize'
/**
* Render untrusted markdown.
*
* Model cards are third-party content written by whoever published the model,
* and they routinely contain HTML — banner images, badges, centred headers.
* Refusing to parse it leaves the tags visible as literal text, so the HTML is
* parsed (`rehype-raw`) and then sanitised against GitHub's allowlist
* (`rehype-sanitize`).
*
* The order matters and is not interchangeable: raw parses the HTML into the
* tree, sanitize then strips anything not on the allowlist — scripts, event
* handlers, `javascript:` URLs, iframes. Running them the other way round
* would sanitise the markdown and then reintroduce the raw HTML unchecked.
*/
export default function Markdown({ children }: { children: string }) {
return (
<div className="prose">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]}
components={{
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noreferrer noopener">
{children}
</a>
),
// Cards embed banners hosted upstream. Loading them would leak the
// viewer's address to a third party and make an offline registry's
// pages depend on the internet; the alt text carries the meaning.
img: ({ alt }) => <span className="img-placeholder">{alt || 'image'}</span>,
}}
>
{children}
</ReactMarkdown>
</div>
)
}
+37
View File
@@ -0,0 +1,37 @@
import { useEffect, useState } from 'react'
import { fetchStatus, type Status } from '../api/client'
/**
* Whether this instance still has an upstream.
*
* Prominent on purpose: "sealed" is the operationally interesting state, and
* the difference between a registry that can fill a gap and one that cannot is
* exactly what a viewer needs to know before trusting it.
*/
export default function StatusBadge() {
const [status, setStatus] = useState<Status | null>(null)
useEffect(() => {
const abort = new AbortController()
fetchStatus(abort.signal)
.then(setStatus)
.catch(() => setStatus(null))
return () => abort.abort()
}, [])
if (!status) return null
return (
<span
className={status.sealed ? 'badge badge-sealed' : 'badge badge-open'}
title={
status.sealed
? 'upstream.enabled = false: this registry serves only what is already in its bucket'
: `misses are fetched from ${status.upstream}`
}
>
{status.sealed ? 'sealed' : 'upstream'}
<span className="badge-count">{status.total}</span>
</span>
)
}
+41
View File
@@ -0,0 +1,41 @@
/** Presentation helpers. Pure, so they are trivially checkable. */
/** Render a byte count the way an operator reads it. */
export function bytes(value: number): string {
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
let n = value
let unit = 0
while (n >= 1024 && unit < units.length - 1) {
n /= 1024
unit += 1
}
return unit === 0 ? `${value} B` : `${n.toFixed(1)} ${units[unit]}`
}
/** Abbreviate a commit for display, keeping enough to be recognisable. */
export function shortCommit(commit: string): string {
return commit.slice(0, 12)
}
/** A date, or an em dash when there isn't one. */
export function date(value: string | null | undefined): string {
if (!value) return '—'
const parsed = new Date(value)
return Number.isNaN(parsed.getTime())
? '—'
: parsed.toISOString().slice(0, 10)
}
/**
* Strip YAML front matter from a model card.
*
* Hub cards open with a `---` delimited metadata block that is data, not
* prose; rendering it verbatim puts a wall of YAML above the actual card.
*/
export function stripFrontMatter(markdown: string): string {
if (!markdown.startsWith('---')) return markdown
const end = markdown.indexOf('\n---', 3)
if (end === -1) return markdown
const after = markdown.indexOf('\n', end + 1)
return after === -1 ? '' : markdown.slice(after + 1).trimStart()
}
+45
View File
@@ -0,0 +1,45 @@
import { useEffect, useState } from 'react'
/** The state of one asynchronous load. */
export type Async<T> =
| { state: 'loading' }
| { state: 'ready'; data: T }
| { state: 'error'; error: unknown }
/**
* Run `load` whenever `key` changes, keeping the previous result visible until
* the new one arrives.
*
* State is only ever set from the async callbacks, never synchronously inside
* the effect. Resetting to "loading" on the way in would be a synchronous
* `setState` during render's commit — the thing React 19 warns about — and it
* would also blank the page on every keystroke of a filter. Instead the result
* carries the key it was loaded for, so a caller can tell current from stale
* and show a quiet indicator rather than a flash of nothing.
*/
export function useAsync<T>(
key: string,
load: (signal: AbortSignal) => Promise<T>,
): { result: Async<T>; stale: boolean } {
const [held, setHeld] = useState<{ key: string; result: Async<T> } | null>(null)
useEffect(() => {
const abort = new AbortController()
load(abort.signal)
.then((data) => {
if (!abort.signal.aborted) setHeld({ key, result: { state: 'ready', data } })
})
.catch((error: unknown) => {
if (!abort.signal.aborted) setHeld({ key, result: { state: 'error', error } })
})
return () => abort.abort()
// `load` is recreated every render by callers; `key` is what identifies the
// request, and re-running on the closure's identity would loop forever.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key])
return {
result: held?.result ?? { state: 'loading' },
stale: held !== null && held.key !== key,
}
}
+16
View File
@@ -0,0 +1,16 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import './styles.css'
const root = document.getElementById('root')
if (!root) throw new Error('#root is missing from index.html')
createRoot(root).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
)
+28
View File
@@ -0,0 +1,28 @@
import readme from 'virtual:repo-readme'
import Markdown from '../components/Markdown'
import { stripFrontMatter } from '../lib/format'
/**
* The landing page is the repository readme.
*
* One description of what this service is, not two: a hand-written page here
* would drift from the readme, and the drift would be invisible until someone
* noticed the site disagreeing with the repository.
*/
export default function Home() {
return (
<article className="page">
<div className="quickstart">
<h2>Point a client at it</h2>
<pre>
<code>{`export HF_ENDPOINT=https://rf.internal\nhf download Qwen/Qwen3-0.6B`}</code>
</pre>
<p>
Nothing else changes. Anything fetched through this endpoint stays in
the bucket until an operator removes it.
</p>
</div>
<Markdown>{stripFrontMatter(readme)}</Markdown>
</article>
)
}
+163
View File
@@ -0,0 +1,163 @@
import { Link, useSearchParams } from 'react-router-dom'
import { fetchRepos, type RepoPage } from '../api/client'
import { bytes, date, shortCommit } from '../lib/format'
import { useAsync } from '../lib/useAsync'
/**
* What this registry holds.
*
* Paging, filtering and search all live in the query string so a view is a
* link somebody can send: "the six datasets we cached last week" should be a
* URL, not a sequence of clicks to reproduce.
*/
export default function Models() {
const [params, setParams] = useSearchParams()
const current = Number(params.get('page') ?? '1')
const type = params.get('type') ?? 'all'
const query = params.get('q') ?? ''
const { result, stale } = useAsync<RepoPage>(`${current}|${type}|${query}`, (signal) =>
fetchRepos({ page: current, type, q: query }, signal),
)
const page = result.state === 'ready' ? result.data : null
const error =
result.state === 'error'
? result.error instanceof Error
? result.error.message
: String(result.error)
: null
const loading = result.state === 'loading' || stale
function update(next: Record<string, string>) {
const merged = new URLSearchParams(params)
for (const [key, value] of Object.entries(next)) {
if (value) merged.set(key, value)
else merged.delete(key)
}
// Any change to the filters invalidates the page number.
if (!('page' in next)) merged.delete('page')
setParams(merged)
}
return (
<article className="page">
<div className="page-head">
<h1>Stored repositories</h1>
{page && (
<p className="muted">
{page.total} {page.total === 1 ? 'repository' : 'repositories'}
</p>
)}
</div>
<div className="controls">
<input
type="search"
placeholder="Filter by name…"
defaultValue={query}
onChange={(event) => update({ q: event.target.value })}
aria-label="Filter repositories by name"
/>
<div className="segmented" role="group" aria-label="Repository type">
{['all', 'models', 'datasets'].map((option) => (
<button
key={option}
type="button"
className={type === option ? 'active' : ''}
onClick={() => update({ type: option === 'all' ? '' : option })}
>
{option}
</button>
))}
</div>
</div>
{error && <p className="error">{error}</p>}
{loading && !page && <p className="muted">Loading…</p>}
{page && page.repos.length === 0 && !loading && (
<div className="empty">
<p>Nothing stored yet{query && ' matching that filter'}.</p>
<p className="muted">
A registry records what it has actually served. Pull something
through <code>HF_ENDPOINT</code>, or warm it ahead of need with{' '}
<code>rustingface fetch</code>.
</p>
</div>
)}
<ul className="repos">
{page?.repos.map((repo) => (
<li key={`${repo.repo_type}/${repo.repo_id}`}>
<Link to={`/${repo.repo_id}`} className="repo">
<div className="repo-head">
<h2>{repo.repo_id}</h2>
<span className="type">{repo.repo_type}</span>
</div>
<dl className="facts">
<div>
<dt>Size</dt>
<dd>{bytes(repo.bytes)}</dd>
</div>
<div>
<dt>Files</dt>
<dd>{repo.files}</dd>
</div>
<div>
<dt>Revisions</dt>
<dd>{repo.revisions}</dd>
</div>
<div>
<dt>Cached</dt>
<dd>{date(repo.recorded_at)}</dd>
</div>
</dl>
{repo.refs.length > 0 && (
<p className="pins">
{repo.refs.map((ref) => (
<span key={ref.name} className="pin">
{ref.name}
<code>{shortCommit(ref.commit)}</code>
</span>
))}
</p>
)}
{repo.tags && repo.tags.length > 0 && (
<p className="tags">
{repo.tags.map((tag) => (
<span key={tag} className="tag">
{tag}
</span>
))}
</p>
)}
</Link>
</li>
))}
</ul>
{page && page.pages > 1 && (
<nav className="pager" aria-label="Pagination">
<button
type="button"
disabled={page.page <= 1}
onClick={() => update({ page: String(page.page - 1) })}
>
Previous
</button>
<span>
Page {page.page} of {page.pages}
</span>
<button
type="button"
disabled={page.page >= page.pages}
onClick={() => update({ page: String(page.page + 1) })}
>
Next
</button>
</nav>
)}
</article>
)
}
+13
View File
@@ -0,0 +1,13 @@
import { Link } from 'react-router-dom'
export default function NotFound() {
return (
<article className="page">
<h1>Not found</h1>
<p className="muted">
No such page. <Link to="/models">Stored repositories</Link> lists what
this registry holds.
</p>
</article>
)
}
+227
View File
@@ -0,0 +1,227 @@
import { Link, useParams } from 'react-router-dom'
import { ApiError, fetchCard, fetchRepo, type RepoDetail } from '../api/client'
import Markdown from '../components/Markdown'
import { bytes, date, shortCommit, stripFrontMatter } from '../lib/format'
import { useAsync } from '../lib/useAsync'
/** A repository together with its card, loaded as one unit. */
interface Loaded {
detail: RepoDetail
card: string | null
}
/**
* One repository, as this registry holds it.
*
* The file list comes from the manifest — what has actually been fetched —
* rather than from upstream's `siblings`. A repository pulled one file at a
* time shows one file, which is the honest answer to "what can I get from here
* offline".
*/
export default function Repo() {
const params = useParams()
const repoId = params.namespace ? `${params.namespace}/${params.name}` : (params.name ?? '')
const { result } = useAsync<Loaded>(repoId, async (signal) => {
// A repo id alone does not say which namespace it is in; try models, then
// datasets, which is the order a viewer following a link from /models
// expects.
let detail: RepoDetail | null = null
for (const type of ['models', 'datasets']) {
try {
detail = await fetchRepo(type, repoId, signal)
break
} catch (err) {
if (err instanceof ApiError && err.status === 404) continue
throw err
}
}
if (!detail) throw new ApiError(404, `${repoId} is not stored in this registry`)
// A missing card is not a failure: a repo pulled without its README simply
// has none to show.
const card = detail.commit
? await fetchCard(
detail.repo_type,
detail.repo_id,
detail.default_ref ?? detail.commit,
signal,
).catch(() => null)
: null
return { detail, card }
})
if (result.state === 'loading') return <p className="muted page">Loading…</p>
if (result.state === 'error') {
const error =
result.error instanceof ApiError
? { status: result.error.status, message: result.error.message }
: {
status: 0,
message: result.error instanceof Error ? result.error.message : String(result.error),
}
return (
<article className="page">
<h1>{repoId}</h1>
<div className="empty">
<p>
{error.status === 404
? 'This registry has never served that repository.'
: error.message}
</p>
{error.status === 404 && (
<p className="muted">
rustingface records what it has actually fetched, so a repository
appears here only once something has pulled it. Try{' '}
<code>rustingface fetch {repoId}</code>, or just request a file
through <code>HF_ENDPOINT</code>.
</p>
)}
<p>
<Link to="/models">Back to stored repositories</Link>
</p>
</div>
</article>
)
}
const { detail, card } = result.data
const meta = (detail.meta ?? {}) as Record<string, unknown>
const tags = Array.isArray(meta.tags) ? (meta.tags as string[]) : []
const pipeline = typeof meta.pipeline_tag === 'string' ? meta.pipeline_tag : null
const library = typeof meta.library_name === 'string' ? meta.library_name : null
return (
<article className="page">
<div className="page-head">
<h1>{detail.repo_id}</h1>
<span className="type">{detail.repo_type}</span>
</div>
<dl className="facts wide">
<div>
<dt>Size</dt>
<dd>{bytes(detail.bytes)}</dd>
</div>
<div>
<dt>Files stored</dt>
<dd>{detail.file_count}</dd>
</div>
<div>
<dt>Revisions</dt>
<dd>{detail.commits.length}</dd>
</div>
<div>
<dt>Cached</dt>
<dd>{date(detail.recorded_at)}</dd>
</div>
{pipeline && (
<div>
<dt>Task</dt>
<dd>{pipeline}</dd>
</div>
)}
{library && (
<div>
<dt>Library</dt>
<dd>{library}</dd>
</div>
)}
</dl>
{detail.refs.length > 0 && (
<section>
<h2>Pinned refs</h2>
<p className="muted">
Frozen on first resolution. Upstream cannot move these; only{' '}
<code>rustingface refresh</code> can.
</p>
<table className="grid">
<thead>
<tr>
<th>Ref</th>
<th>Commit</th>
</tr>
</thead>
<tbody>
{detail.refs.map((ref) => (
<tr key={ref.name}>
<td>{ref.name}</td>
<td>
<code>{ref.commit}</code>
</td>
</tr>
))}
</tbody>
</table>
</section>
)}
<section>
<h2>Stored files</h2>
<p className="muted">
{detail.commit && (
<>
Revision <code>{shortCommit(detail.commit)}</code>.{' '}
</>
)}
Only files this registry has actually fetched are listed — those are
the ones it can serve with no upstream.
</p>
{detail.files.length === 0 ? (
<p className="muted">No file has been fetched from this repository yet.</p>
) : (
<table className="grid">
<thead>
<tr>
<th>Path</th>
<th>Size</th>
<th>Storage</th>
</tr>
</thead>
<tbody>
{detail.files.map((file) => (
<tr key={file.path}>
<td>
<a
href={`/${detail.repo_type === 'datasets' ? 'datasets/' : ''}${detail.repo_id}/resolve/${detail.default_ref ?? detail.commit}/${file.path}`}
>
{file.path}
</a>
</td>
<td>{bytes(file.size)}</td>
<td>{file.lfs ? 'lfs' : 'git'}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
{tags.length > 0 && (
<p className="tags">
{tags.map((tag) => (
<span key={tag} className="tag">
{tag}
</span>
))}
</p>
)}
<section>
<h2>Model card</h2>
{card ? (
<Markdown>{stripFrontMatter(card)}</Markdown>
) : (
<p className="muted">
No <code>README.md</code> has been fetched for this revision, so
there is no card to show. Pulling the file would store it.
</p>
)}
</section>
</article>
)
}
+321
View File
@@ -0,0 +1,321 @@
/* One stylesheet, no framework. The site is four pages of mostly text; a
utility framework would be more bytes than the app. */
:root {
--bg: #fbfaf8;
--surface: #ffffff;
--border: #e3ded6;
--ink: #23201c;
--muted: #6c655c;
--accent: #9a4a1e;
--accent-soft: #f3e6dd;
--sealed: #1f5c3d;
--sealed-soft: #dff0e6;
--radius: 10px;
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #16140f;
--surface: #1e1b16;
--border: #332e26;
--ink: #eae5dc;
--muted: #9c9488;
--accent: #e08a52;
--accent-soft: #33241a;
--sealed: #6fce9f;
--sealed-soft: #16301f;
color-scheme: dark;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font: 15px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
code, pre { font-family: var(--mono); font-size: 0.9em; }
a { color: var(--accent); }
/* ---------- chrome ---------- */
.masthead {
border-bottom: 1px solid var(--border);
background: var(--surface);
position: sticky;
top: 0;
z-index: 10;
}
.bar {
max-width: 60rem;
margin: 0 auto;
padding: 0.75rem 1.25rem;
display: flex;
align-items: center;
gap: 1.5rem;
}
.wordmark {
font-weight: 650;
letter-spacing: -0.02em;
text-decoration: none;
color: var(--ink);
}
.bar nav { display: flex; gap: 1rem; margin-right: auto; }
.bar nav a {
text-decoration: none;
color: var(--muted);
padding: 0.2rem 0;
border-bottom: 2px solid transparent;
}
.bar nav a:hover { color: var(--ink); }
.bar nav a.active { color: var(--ink); border-bottom-color: var(--accent); }
.badge {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.15rem 0.5rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
background: var(--accent-soft);
color: var(--accent);
}
.badge-sealed { background: var(--sealed-soft); color: var(--sealed); }
.badge-count {
font-family: var(--mono);
font-weight: 500;
opacity: 0.75;
}
main { max-width: 60rem; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
footer {
max-width: 60rem;
margin: 0 auto;
padding: 0 1.25rem 3rem;
color: var(--muted);
font-size: 0.85rem;
}
/* ---------- pages ---------- */
.page > h1 { font-size: 1.6rem; letter-spacing: -0.02em; margin: 0; }
.page-head {
display: flex;
align-items: baseline;
gap: 0.75rem;
margin-bottom: 1.25rem;
flex-wrap: wrap;
}
.page-head p { margin: 0; }
.muted { color: var(--muted); }
.error { color: #b3261e; }
.type {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
border: 1px solid var(--border);
border-radius: 999px;
padding: 0.1rem 0.5rem;
}
.quickstart {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem 1.5rem;
margin-bottom: 2rem;
}
.quickstart h2 { margin: 0 0 0.75rem; font-size: 1rem; }
.quickstart p { margin: 0.75rem 0 0; color: var(--muted); font-size: 0.9rem; }
pre {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.85rem 1rem;
overflow-x: auto;
margin: 0;
}
/* ---------- controls ---------- */
.controls {
display: flex;
gap: 0.75rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.controls input[type='search'] {
flex: 1 1 16rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
color: var(--ink);
font: inherit;
}
.segmented { display: flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
.segmented button {
border: 0;
background: var(--surface);
color: var(--muted);
padding: 0.5rem 0.9rem;
font: inherit;
cursor: pointer;
}
.segmented button + button { border-left: 1px solid var(--border); }
.segmented button.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
/* ---------- listing ---------- */
.repos { list-style: none; padding: 0; margin: 0; display: grid; gap: 0.75rem; }
.repo {
display: block;
text-decoration: none;
color: inherit;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem 1.25rem;
}
.repo:hover { border-color: var(--accent); }
.repo-head { display: flex; align-items: baseline; gap: 0.75rem; }
.repo-head h2 { margin: 0; font-size: 1rem; font-weight: 600; }
.facts {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
margin: 0.75rem 0 0;
}
.facts.wide { margin: 1.5rem 0; }
.facts div { display: flex; flex-direction: column; gap: 0.15rem; }
.facts dt { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); }
.facts dd { margin: 0; font-variant-numeric: tabular-nums; }
.pins, .tags { display: flex; flex-wrap: wrap; gap: 0.4rem; margin: 0.75rem 0 0; }
.pin {
display: inline-flex;
gap: 0.4rem;
align-items: center;
font-size: 0.75rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.1rem 0.45rem;
}
.tag {
font-size: 0.72rem;
color: var(--muted);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 999px;
padding: 0.1rem 0.5rem;
}
.empty {
background: var(--surface);
border: 1px dashed var(--border);
border-radius: var(--radius);
padding: 2rem 1.5rem;
text-align: center;
}
.empty p { margin: 0.35rem 0; }
.pager { display: flex; align-items: center; gap: 1rem; justify-content: center; margin-top: 1.5rem; }
.pager button {
border: 1px solid var(--border);
background: var(--surface);
color: var(--ink);
border-radius: 8px;
padding: 0.4rem 0.9rem;
font: inherit;
cursor: pointer;
}
.pager button:disabled { opacity: 0.45; cursor: default; }
/* ---------- tables ---------- */
section { margin-top: 2.5rem; }
section h2 { font-size: 1.05rem; margin: 0 0 0.35rem; }
section > .muted { margin-top: 0; font-size: 0.88rem; }
.grid {
width: 100%;
border-collapse: collapse;
margin-top: 0.85rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.grid th, .grid td { text-align: left; padding: 0.5rem 0.9rem; border-bottom: 1px solid var(--border); }
.grid tr:last-child td { border-bottom: 0; }
.grid th { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); font-weight: 600; }
.grid td { font-variant-numeric: tabular-nums; }
/* ---------- markdown ---------- */
.prose { max-width: 46rem; }
.prose h1 { font-size: 1.6rem; letter-spacing: -0.02em; }
.prose h2 { font-size: 1.2rem; margin-top: 2rem; }
.prose h3 { font-size: 1rem; }
.prose img { max-width: 100%; height: auto; }
.prose table { border-collapse: collapse; display: block; overflow-x: auto; }
.prose th, .prose td { border: 1px solid var(--border); padding: 0.35rem 0.7rem; }
.prose blockquote {
margin: 1rem 0;
padding: 0.1rem 1rem;
border-left: 3px solid var(--border);
color: var(--muted);
}
.prose hr { border: 0; border-top: 1px solid var(--border); margin: 2rem 0; }
.prose code { background: var(--accent-soft); padding: 0.1em 0.35em; border-radius: 4px; }
.prose pre code { background: none; padding: 0; }
/* Cards reference images hosted upstream; those are not loaded (see
components/Markdown.tsx), so the alt text stands in for them. */
.img-placeholder {
display: inline-block;
font-size: 0.75rem;
color: var(--muted);
border: 1px dashed var(--border);
border-radius: 6px;
padding: 0.05rem 0.4rem;
}
+5
View File
@@ -0,0 +1,5 @@
/** The repository readme, inlined at build time by the `repo-readme` plugin. */
declare module 'virtual:repo-readme' {
const content: string
export default content
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"types": ["vite/client"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"skipLibCheck": true,
"noEmit": true
},
"include": ["src", "src/virtual.d.ts"]
}
+1
View File
@@ -0,0 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/virtual.d.ts","./src/api/client.ts","./src/components/Layout.tsx","./src/components/Markdown.tsx","./src/components/StatusBadge.tsx","./src/lib/format.ts","./src/lib/useAsync.ts","./src/routes/Home.tsx","./src/routes/Models.tsx","./src/routes/NotFound.tsx","./src/routes/Repo.tsx"],"version":"5.9.3"}
+4
View File
@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"moduleResolution": "bundler",
"types": ["node"],
"strict": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["vite.config.ts"]
}
+1
View File
@@ -0,0 +1 @@
{"root":["./vite.config.ts"],"version":"5.9.3"}
+62
View File
@@ -0,0 +1,62 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
const repoRoot = fileURLToPath(new URL('..', import.meta.url))
const devApi = process.env.VITE_DEV_API ?? 'http://127.0.0.1:20482'
/**
* Expose the repository readme to the app as a virtual module.
*
* The landing page is the readme, so that there is exactly one description of
* what this service is: a copy under `web/` would drift from the real one, and
* the drift would be invisible until somebody noticed the site disagreeing
* with the repository.
*/
function repoReadme() {
const id = 'virtual:repo-readme'
const resolved = '\0' + id
return {
name: 'repo-readme',
resolveId: (source: string) => (source === id ? resolved : null),
load(this: { addWatchFile?: (f: string) => void }, requested: string) {
if (requested !== resolved) return null
const path = repoRoot + 'readme.md'
this.addWatchFile?.(path)
return `export default ${JSON.stringify(readFileSync(path, 'utf8'))}`
},
}
}
export default defineConfig({
plugins: [react(), repoReadme()],
build: {
outDir: 'dist',
sourcemap: false,
rollupOptions: {
output: {
// Split the dependencies that never change from the app that does, so
// a redeploy does not invalidate a viewer's cached copy of React and
// the markdown stack.
manualChunks: {
react: ['react', 'react-dom', 'react-router-dom'],
markdown: ['react-markdown', 'remark-gfm', 'rehype-raw', 'rehype-sanitize'],
},
},
},
},
server: {
// Dev server only: proxy to a running rustingface so the app is exercised
// against the real thing rather than a fixture. Mirrors what the nginx
// vhost does in production — including the resolve paths, which is how
// model cards are read.
proxy: {
'/v1': { target: devApi, changeOrigin: true },
'/api': { target: devApi, changeOrigin: true },
'/healthz': { target: devApi, changeOrigin: true },
// `/{namespace}/{name}/resolve/...` and its bare-name form.
'^/[^/]+/(?:[^/]+/)?resolve/': { target: devApi, changeOrigin: true },
},
},
})