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:
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
/target
|
||||
web/node_modules
|
||||
web/dist
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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."
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 90,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
@@ -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 }],
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+3226
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+5
@@ -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
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"root":["./vite.config.ts"],"version":"5.9.3"}
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user