web: correct the asset cache policy, serve the flow index, and drop a stray artifact

This commit is contained in:
dev 2026-10-01 07:35:56 +08:00
parent ea1b2e6244
commit 994f871ff2
6 changed files with 108 additions and 33 deletions

4
.gitignore vendored
View File

@ -14,3 +14,7 @@
# Vite and TypeScript caches. # Vite and TypeScript caches.
/frontend/.vite /frontend/.vite
/frontend/*.tsbuildinfo /frontend/*.tsbuildinfo
# Session exports dropped into the project directory by tooling that runs
# alongside it. They are large and unrelated to the service.
apsis-web.html

View File

@ -101,7 +101,7 @@ frontend:
- `GET /health` - `GET /health`
- `GET /api/docs`, `GET /api/docs/<id>`, `GET /api/docs/search?q=` - `GET /api/docs`, `GET /api/docs/<id>`, `GET /api/docs/search?q=`
- `GET /api/graph`, `GET /api/graph/crates`, `GET /api/graph/<flow>` - `GET /api/graph`, `GET /api/graph/crates`, `GET /api/graph/flows`, `GET /api/graph/<flow>`
- `GET /api/api`, `GET /api/api/crates/<name>`, `GET /api/api/items/<name>` - `GET /api/api`, `GET /api/api/crates/<name>`, `GET /api/api/items/<name>`
- `GET /api/tools`, `GET /api/tools/<id>`, `POST /api/tools/<id>/run` - `GET /api/tools`, `GET /api/tools/<id>`, `POST /api/tools/<id>/run`

View File

@ -96,6 +96,20 @@ export interface GraphEdge {
label: string | null; label: string | null;
} }
/** An entry of the flow index. */
export interface FlowEntry {
id: string;
title: Bilingual;
summary: Bilingual;
nodes: number;
edges: number;
}
/** The flow index. */
export interface FlowIndex {
flows: FlowEntry[];
}
/** A graph. */ /** A graph. */
export interface Graph { export interface Graph {
id: string; id: string;

View File

@ -3,28 +3,16 @@
* The algorithm flow graphs. * The algorithm flow graphs.
* *
* Each flow describes the steps of one algorithm. Selecting a step shows * Each flow describes the steps of one algorithm. Selecting a step shows
* the interface item that implements it. * the interface item that implements it. The list of flows comes from the
* backend, so adding a flow to the content is enough for it to appear
* here.
*/ */
import type { Graph, GraphNode } from '../lib/types'; import type { FlowIndex, Graph, GraphNode } from '../lib/types';
import { getJson } from '../lib/api'; import { getJson } from '../lib/api';
import { groupColor } from '../lib/format'; import { groupColor } from '../lib/format';
import { language, pick, tr } from '../lib/i18n'; import { language, pick, tr } from '../lib/i18n';
import GraphView from '../lib/GraphView.svelte'; import GraphView from '../lib/GraphView.svelte';
/** The flow identifiers, in display order. */
const FLOWS: { id: string; label: { zh: string; en: string } }[] = [
{ id: 'sgp4-propagation', label: { zh: 'SGP4 传播', en: 'SGP4 propagation' } },
{ id: 'tle-parsing', label: { zh: '两行元素解析', en: 'Two-line element parsing' } },
{
id: 'time-scale-conversion',
label: { zh: '时间尺度转换', en: 'Time scale conversion' },
},
{
id: 'frame-rotation',
label: { zh: '参考系旋转与大地坐标转换', en: 'Frame rotation and geodetic conversion' },
},
];
interface Props { interface Props {
id: string | null; id: string | null;
} }
@ -35,11 +23,12 @@
let lang = $derived($language); let lang = $derived($language);
let flows = $state<Record<string, Graph>>({}); let flows = $state<Record<string, Graph>>({});
let index = $state<FlowIndex['flows']>([]);
let loading = $state(false); let loading = $state(false);
let selected = $state<string | null>(null); let selected = $state<string | null>(null);
/** The identifier currently shown. */ /** The identifier currently shown. */
const active = $derived(id ?? FLOWS[0].id); const active = $derived(id ?? index[0]?.id ?? '');
/** The graph currently shown. */ /** The graph currently shown. */
const graph = $derived(flows[active] ?? null); const graph = $derived(flows[active] ?? null);
@ -48,11 +37,14 @@
graph && selected ? (graph.nodes.find((node) => node.id === selected) ?? null) : null, graph && selected ? (graph.nodes.find((node) => node.id === selected) ?? null) : null,
); );
/** Loads the flow graphs that have not been fetched yet. */ /** Loads the flow index and any flow graph that has not been fetched. */
async function load() { async function load() {
loading = true; loading = true;
try { try {
for (const flow of FLOWS) { if (index.length === 0) {
index = (await getJson<FlowIndex>('/api/graph/flows')).flows;
}
for (const flow of index) {
if (flows[flow.id]) continue; if (flows[flow.id]) continue;
flows[flow.id] = await getJson<Graph>(`/api/graph/${flow.id}`); flows[flow.id] = await getJson<Graph>(`/api/graph/${flow.id}`);
} }
@ -84,12 +76,12 @@
</div> </div>
<div class="graph-controls"> <div class="graph-controls">
{#each FLOWS as flow} {#each index as flow}
<a <a
href={`#/flows/${flow.id}`} href={`#/flows/${flow.id}`}
class="badge" class="badge"
style={active === flow.id ? 'border-color: var(--accent); color: var(--accent);' : ''} style={active === flow.id ? 'border-color: var(--accent); color: var(--accent);' : ''}
>{pick(flow.label, lang)}</a> >{pick(flow.title, lang)}</a>
{/each} {/each}
{#if loading && !graph} {#if loading && !graph}
<span class="badge"><span class="spinner"></span> {tr('common.loading', lang)}</span> <span class="badge"><span class="spinner"></span> {tr('common.loading', lang)}</span>

View File

@ -36,6 +36,8 @@ pub struct Router {
search: crate::docs::SearchIndex, search: crate::docs::SearchIndex,
/// Pre-encoded concept graph. /// Pre-encoded concept graph.
concept_graph: String, concept_graph: String,
/// Pre-encoded list of the flow graphs.
flow_index: String,
/// Pre-encoded crate structure graph. /// Pre-encoded crate structure graph.
crate_graph: String, crate_graph: String,
/// Pre-encoded tool registry. /// Pre-encoded tool registry.
@ -48,6 +50,7 @@ impl Router {
let docs_index = json::to_string(&document_index(&state)); let docs_index = json::to_string(&document_index(&state));
let search = state.docs.search_index(); let search = state.docs.search_index();
let concept_graph = json::to_string(&graph_json(&state.graph.concepts)); let concept_graph = json::to_string(&graph_json(&state.graph.concepts));
let flow_index = json::to_string(&flow_list_json(&state.graph.flows));
let crate_graph = json::to_string(&graph_json(&crate::graph::crate_structure(&state.api))); let crate_graph = json::to_string(&graph_json(&crate::graph::crate_structure(&state.api)));
let tool_registry = json::to_string(&tools::registry_json(&state.tools)); let tool_registry = json::to_string(&tools::registry_json(&state.tools));
Self { Self {
@ -55,6 +58,7 @@ impl Router {
docs_index, docs_index,
search, search,
concept_graph, concept_graph,
flow_index,
crate_graph, crate_graph,
tool_registry, tool_registry,
} }
@ -75,6 +79,9 @@ impl Handler for Router {
(Method::Get | Method::Head, "/api/graph") => { (Method::Get | Method::Head, "/api/graph") => {
Response::json(self.concept_graph.clone()).with_cache_control("no-cache") Response::json(self.concept_graph.clone()).with_cache_control("no-cache")
} }
(Method::Get | Method::Head, "/api/graph/flows") => {
Response::json(self.flow_index.clone()).with_cache_control("no-cache")
}
(Method::Get | Method::Head, "/api/graph/crates") => { (Method::Get | Method::Head, "/api/graph/crates") => {
Response::json(self.crate_graph.clone()).with_cache_control("no-cache") Response::json(self.crate_graph.clone()).with_cache_control("no-cache")
} }
@ -204,6 +211,9 @@ impl Router {
/// Returns one flow graph by identifier. /// Returns one flow graph by identifier.
fn flow(&self, path: &str) -> Response { fn flow(&self, path: &str) -> Response {
let id = path.trim_start_matches("/api/graph/"); let id = path.trim_start_matches("/api/graph/");
if id == "flows" {
return Response::json(self.flow_index.clone()).with_cache_control("no-cache");
}
if id == "crates" { if id == "crates" {
return Response::json(self.crate_graph.clone()).with_cache_control("no-cache"); return Response::json(self.crate_graph.clone()).with_cache_control("no-cache");
} }
@ -376,7 +386,7 @@ impl Router {
if !relative.is_empty() && !relative.ends_with('/') { if !relative.is_empty() && !relative.ends_with('/') {
if let Some(bytes) = embedded::get(relative) { if let Some(bytes) = embedded::get(relative) {
return Response::new(Status::Ok, embedded::content_type(relative), bytes.to_vec()) return Response::new(Status::Ok, embedded::content_type(relative), bytes.to_vec())
.with_cache_control(cache_policy(relative)); .with_cache_control(CACHE_POLICY);
} }
} }
@ -390,17 +400,13 @@ impl Router {
} }
} }
/// Returns the cache policy for an asset. /// The cache policy for every asset.
/// ///
/// The build output uses content hashed file names, so those files are /// The build writes stable file names rather than content hashed ones, so a
/// immutable. The entry point is not, because it names the hashed files. /// browser must revalidate after a deployment. A year long immutable policy
fn cache_policy(path: &str) -> &'static str { /// would be correct only for hashed names, and would pin a reader to the
if path.starts_with("assets/") || path.contains('.') && path.starts_with("static/") { /// bundle that was current when they first loaded the page.
"public, max-age=31536000, immutable" const CACHE_POLICY: &str = "no-cache";
} else {
"no-cache"
}
}
/// Builds the document listing payload. /// Builds the document listing payload.
fn document_index(state: &AppState) -> Value { fn document_index(state: &AppState) -> Value {
@ -515,6 +521,27 @@ fn optional_bilingual(translation: &Option<String>, fallback: &str) -> Value {
} }
} }
/// Encodes the list of flow graphs.
///
/// The list is part of the payload so that the frontend does not carry its
/// own copy of the flow identifiers; adding a flow to the content is enough
/// for it to appear in the navigation.
fn flow_list_json(flows: &[Graph]) -> Value {
let items: Vec<Value> = flows
.iter()
.map(|graph| {
json_object! {
"id" => graph.id.clone(),
"title" => optional_bilingual(&graph.title_zh, &graph.title),
"summary" => optional_bilingual(&graph.summary_zh, &graph.summary),
"nodes" => graph.nodes.len(),
"edges" => graph.edges.len(),
}
})
.collect();
json_object! { "flows" => items }
}
/// Encodes a graph as JSON. /// Encodes a graph as JSON.
/// ///
/// A display label and a summary are carried in both languages when the /// A display label and a summary are carried in both languages when the

View File

@ -357,6 +357,44 @@ fn tool_result_labels_are_bilingual() {
assert!(body.contains("\"en\":\"Semi-major axis\""), "{body}"); assert!(body.contains("\"en\":\"Semi-major axis\""), "{body}");
} }
#[test]
fn the_flow_index_lists_every_flow() {
let service = Service::start();
wait_ready(&service);
let index = service.get("/api/graph/flows");
let body = body(&index);
assert!(body.contains("\"flows\""), "{body}");
assert!(body.contains("sgp4-propagation"), "{body}");
assert!(body.contains("tle-parsing"), "{body}");
// The index carries a bilingual title, so the frontend needs no copy of
// the flow names.
assert!(body.contains("\"title\""), "{body}");
assert!(body.contains("\"zh\""), "{body}");
}
#[test]
fn static_assets_are_revalidated() {
let service = Service::start();
wait_ready(&service);
// The build writes stable file names, so an immutable policy would pin a
// reader to the bundle current at first load.
let asset = service.get("/assets/app.js");
assert!(asset.contains("no-cache"), "{asset}");
assert!(!asset.contains("immutable"), "{asset}");
}
#[test]
fn a_traversal_attempt_does_not_reach_the_filesystem() {
let service = Service::start();
wait_ready(&service);
let response = service.get("/../../etc/passwd");
let body = body(&response);
assert!(
!body.contains("root:x:"),
"a traversal must not read a file"
);
}
#[test] #[test]
fn graph_labels_are_bilingual() { fn graph_labels_are_bilingual() {
let service = Service::start(); let service = Service::start();