chore: 升级至 v1.7.0,新增内存缓存与运行时统计

- 新增通用 LruCache 类,支持按条目数/字节容量两种淘汰策略
- 字体裁剪结果 LRU 内存缓存(默认 10MB,X-Cache 响应头标识命中)
- 新增 GET /api/stats 运行时统计接口
- 前端统计面板(10s 轮询,页面不可见时暂停)
- API handler 拆分到 routes/ 目录,提取 shared.ts 共享模块

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
崮生(子虚) 2026-04-30 19:34:30 +08:00
parent 6e5b2c5fb7
commit c18dc44e72
14 changed files with 460 additions and 233 deletions

View File

@ -58,6 +58,7 @@ services:
- TEMP_MAX_FILES=10 # 临时上传最大文件数(默认 10
- TEMP_MAX_TOTAL_SIZE=209715200 # 临时上传目录总体积上限,单位字节(默认 209715200即 200MB
- ADMIN_API_KEY=你的管理员密钥 # 设置后开启管理员上传,不设置则不可用
- SUBSET_CACHE_MAX_SIZE=10485760 # 字体裁剪结果内存缓存容量上限,单位字节(默认 10MB
deploy:
resources:
limits:

View File

@ -1,17 +1,14 @@
/** 解析请求 URLreq.url 只有路径,需要补全协议和主机才能用 URL API */
function parseUrl(req: Request): URL {
return new URL(req.url, "http://localhost");
}
import { fontSubset } from "./font_util/font";
import type { FontEditor } from "../vendor/fonteditor-core/lib/ttf/font.js";
import { mimeTypes } from "./server/mime_type";
import type { cMiddleware } from "./server/req_res";
import { SimpleHttpServer } from "./server/server";
import { path_join, readFile, stat, readdir, mkdir } from "./interface";
import { enableTempUpload, adminApiKey, fontDirs } from "./config";
import { parseMultipart } from "./multipart";
import { handleTempUpload, handleAdminUpload } from "./upload";
import { parseUrl, jsonResponse, stats } from "./shared";
import { enableTempUpload, adminApiKey } from "./config";
import { handleListFonts } from "./routes/fonts";
import { handleGetConfig } from "./routes/config";
import { handleStats } from "./routes/stats";
import { handleUpload } from "./routes/upload";
import { handleFontSubset } from "./routes/subset";
let release_name = globalThis?.process?.release?.name;
@ -37,65 +34,8 @@ async function ensureDirectories() {
}
}
/**
*
* > >
* @returns null
*/
async function findFontPath(filename: string): Promise<string | null> {
// 先尝试精确匹配
for (const dir of fontDirs) {
const filePath = path_join(dir, filename);
try {
const s = await stat(filePath);
if (s.isFile()) return filePath;
} catch {
// 继续搜索
}
}
// 收集所有字体文件名(不含扩展名)和完整路径
const allFonts: Array<{ basename: string; path: string }> = [];
for (const dir of fontDirs) {
try {
const entries = await readdir(dir);
for (const entry of entries) {
if (entry.isFile() && /\.(ttf|otf|woff|woff2)$/i.test(entry.name)) {
allFonts.push({
basename: entry.name.replace(/\.[^.]+$/, ""),
path: path_join(dir, entry.name),
});
}
}
} catch {
// 目录不存在,跳过
}
}
const query = filename.replace(/\.[^.]+$/, "").toLowerCase();
// 前缀匹配
for (const f of allFonts) {
if (f.basename.toLowerCase().startsWith(query)) return f.path;
}
// 包含匹配
for (const f of allFonts) {
if (f.basename.toLowerCase().includes(query)) return f.path;
}
return null;
}
/** JSON 响应工具 */
function jsonResponse(data: unknown, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json" },
});
}
const logMiddleware: cMiddleware = async (req, res, next) => {
stats.totalRequests++;
const t1 = Date.now();
const r = await next(req, res);
const t2 = Date.now();
@ -118,16 +58,16 @@ const staticFileMiddleware: cMiddleware = async function (req, res, next) {
return next(req, newRes);
}
try {
const stats = await stat(filePath);
const fileStat = await stat(filePath);
if (stats.isFile()) {
if (fileStat.isFile()) {
const fileContent = await readFile(filePath);
const extname = filePath.split(".").pop() ?? "";
newRes = new Response(fileContent, {
status: 200,
headers: {
"Content-Type": mimeTypes[extname] || "application/octet-stream",
"Content-Length": `${stats.size}`,
"Content-Length": `${fileStat.size}`,
},
});
} else {
@ -177,167 +117,6 @@ const corsMiddleware: cMiddleware = async (req, res, next) => {
}
};
/** GET /api/fonts — 列出所有可用字体 */
async function handleListFonts(req: Request, res: Response) {
const allFonts: Array<{ name: string; dir: string }> = [];
for (const dir of fontDirs) {
try {
const entries = await readdir(dir);
for (const entry of entries) {
if (entry.isFile() && /\.(ttf|otf|woff|woff2)$/i.test(entry.name)) {
allFonts.push({ name: entry.name, dir });
}
}
} catch {
// 目录不存在,跳过
}
}
return { req, res: jsonResponse(allFonts) };
}
/** GET /api/config — 返回公开配置 */
async function handleGetConfig(req: Request, res: Response) {
return {
req,
res: jsonResponse({
enableTempUpload,
adminUploadEnabled: !!adminApiKey,
supportedOutTypes: ["woff2", "ttf"],
}),
};
}
/** POST /api/upload?mode=temp|admin — 上传字体 */
async function handleUpload(req: Request, res: Response) {
const url = parseUrl(req);
const mode = url.searchParams.get("mode") ?? "temp";
const contentType = req.headers.get("Content-Type") ?? "";
console.log("[upload] mode:", mode, "contentType:", contentType);
const body = (req as Request & { _bodyBuffer?: ArrayBuffer })._bodyBuffer;
if (!body || body.byteLength === 0) {
return { req, res: jsonResponse({ success: false, error: "请求体为空" }, 400) };
}
console.log("[upload] body size:", body.byteLength);
let parsed;
try {
parsed = parseMultipart(contentType, body);
console.log("[upload] parsed files:", parsed.files.length);
} catch (err) {
console.log("[upload] parse error:", err);
return { req, res: jsonResponse({ success: false, error: "无效的 multipart 数据" }, 400) };
}
if (!parsed.files || parsed.files.length === 0) {
return { req, res: jsonResponse({ success: false, error: "未提供文件" }, 400) };
}
const file = parsed.files[0];
console.log("[upload] file:", file.name, "filename:", file.filename, "data size:", file.data.length);
let result;
if (mode === "admin") {
const authHeader = req.headers.get("Authorization") ?? "";
const apiKey = authHeader.replace("Bearer ", "");
result = await handleAdminUpload({ data: file.data, filename: file.filename }, apiKey);
console.log("[upload] admin result:", result);
return { req, res: jsonResponse(result, result.success ? 200 : 403) };
}
// 默认:临时上传
result = await handleTempUpload({ data: file.data, filename: file.filename });
console.log("[upload] temp result:", result);
return { req, res: jsonResponse(result, result.success ? 200 : 400) };
}
/** 字体文件 LRU 缓存,最多保留 3 个最近使用的字体 buffer */
const fontBufferCache = new Map<string, ArrayBuffer>();
const FONT_CACHE_MAX = 3;
/** 从缓存或磁盘读取字体 buffer */
async function readFontBuffer(fontPath: string): Promise<ArrayBuffer> {
const cached = fontBufferCache.get(fontPath);
if (cached) {
/** LRU命中时移到末尾最近使用 */
fontBufferCache.delete(fontPath);
fontBufferCache.set(fontPath, cached);
return cached;
}
const buffer = new Uint8Array(await readFile(fontPath)).buffer;
if (fontBufferCache.size >= FONT_CACHE_MAX) {
/** 淘汰最久未使用的条目 */
const oldest = fontBufferCache.keys().next().value!;
fontBufferCache.delete(oldest);
}
fontBufferCache.set(fontPath, buffer);
return buffer;
}
/** GET /api?font=...&text=... — 字体裁剪 */
async function handleFontSubset(req: Request, res: Response) {
const url = parseUrl(req);
const params = new URLSearchParams(url.search);
const font = params.get("font") || "";
const text = params.get("text") || "";
if (text.length === 0) {
return { req, res };
}
const fontPath = await findFontPath(font);
if (!fontPath) {
return {
req,
res: new Response(`Font not found: ${font}`, {
status: 404,
headers: { "Content-Type": "text/plain; charset=utf-8" },
}),
};
}
const fontType = fontPath.split(".").pop() as FontEditor.FontType;
let oldFontBuffer: ArrayBuffer;
try {
oldFontBuffer = await readFontBuffer(fontPath);
} catch {
return {
req,
res: new Response(`Font read error: ${font}`, {
status: 500,
headers: { "Content-Type": "text/plain; charset=utf-8" },
}),
};
}
/** 默认 ttf兼容性最好 */
const outTypeParam = params.get("outType") || "";
const outType = (outTypeParam === "woff2" || outTypeParam === "ttf") ? outTypeParam : "ttf";
const newFont = await fontSubset(oldFontBuffer, text, {
outType: outType,
sourceType: fontType,
});
const contentTypes: Record<string, string> = {
ttf: "font/ttf",
woff2: "font/woff2",
};
return {
req,
res: new Response(newFont, {
status: 200,
headers: {
"Content-Type": contentTypes[outType] || "font/ttf",
"Cache-Control": "public, max-age=86400",
},
}),
};
}
/** 统一的 API 路由中间件 */
const fontApiMiddleware: cMiddleware = async (req, res, next) => {
const url = parseUrl(req);
@ -349,6 +128,9 @@ const fontApiMiddleware: cMiddleware = async (req, res, next) => {
if (url.pathname === "/api/config" && req.method === "GET") {
return handleGetConfig(req, res);
}
if (url.pathname === "/api/stats" && req.method === "GET") {
return handleStats(req, res);
}
if (url.pathname === "/api/upload" && req.method === "POST") {
return handleUpload(req, res);
}

View File

@ -15,5 +15,8 @@ export const tempMaxFiles = parseInt(env.TEMP_MAX_FILES ?? "10", 10) || 10;
/** 临时上传目录总体积上限(字节),默认 200MB */
export const tempMaxTotalSize = parseInt(env.TEMP_MAX_TOTAL_SIZE ?? `${200 * 1024 * 1024}`, 10) || 200 * 1024 * 1024;
/** 字体裁剪结果内存缓存容量上限(字节),默认 10MB */
export const subsetCacheMaxSize = parseInt(env.SUBSET_CACHE_MAX_SIZE ?? `${10 * 1024 * 1024}`, 10) || 10 * 1024 * 1024;
/** 字体搜索目录按优先级排序admin > 普通 > 临时) */
export const fontDirs = ["font/admin", "font", "font/temp"] as const;

80
backend/lru_cache.ts Normal file
View File

@ -0,0 +1,80 @@
/**
* Map LRU
*
*/
export class LruCache<V> {
private cache = new Map<string, V>();
/** 计算条目字节大小(仅按容量淘汰时需要) */
private readonly sizeFn?: (value: V) => number;
/** 最大条目数(按条目淘汰时使用) */
private readonly maxSize?: number;
/** 最大字节容量(按容量淘汰时使用) */
private readonly maxBytes?: number;
/** 当前已用字节 */
private usedBytes = 0;
/** 当前缓存条目数 */
get size() {
return this.cache.size;
}
constructor(options: { maxSize: number } | { maxBytes: number; sizeFn: (value: V) => number }) {
if ("maxSize" in options) {
this.maxSize = options.maxSize;
} else {
this.maxBytes = options.maxBytes;
this.sizeFn = options.sizeFn;
}
}
/** 获取缓存值命中时移到末尾LRU */
get(key: string): V | undefined {
const value = this.cache.get(key);
if (value === undefined) return undefined;
this.cache.delete(key);
this.cache.set(key, value);
return value;
}
/** 写入缓存,超限时自动淘汰最久未使用的条目 */
set(key: string, value: V): void {
/** 如果 key 已存在,先移除旧值(更新大小) */
const existing = this.cache.get(key);
if (existing !== undefined) {
this.cache.delete(key);
if (this.sizeFn) {
this.usedBytes -= this.sizeFn(existing);
}
}
this.cache.set(key, value);
if (this.sizeFn) {
this.usedBytes += this.sizeFn(value);
this.evictByBytes();
} else if (this.maxSize !== undefined) {
this.evictByCount();
}
}
/** 按条目数淘汰 */
private evictByCount() {
while (this.cache.size > this.maxSize!) {
const oldest = this.cache.keys().next().value!;
this.cache.delete(oldest);
}
}
/** 按字节容量淘汰 */
private evictByBytes() {
while (this.usedBytes > this.maxBytes! && this.cache.size > 0) {
const oldest = this.cache.keys().next().value!;
const entry = this.cache.get(oldest)!;
this.usedBytes -= this.sizeFn!(entry);
this.cache.delete(oldest);
}
}
}

14
backend/routes/config.ts Normal file
View File

@ -0,0 +1,14 @@
import { jsonResponse } from "../shared";
import { enableTempUpload, adminApiKey } from "../config";
/** GET /api/config — 返回公开配置 */
export async function handleGetConfig(req: Request, res: Response) {
return {
req,
res: jsonResponse({
enableTempUpload,
adminUploadEnabled: !!adminApiKey,
supportedOutTypes: ["woff2", "ttf"],
}),
};
}

21
backend/routes/fonts.ts Normal file
View File

@ -0,0 +1,21 @@
import { jsonResponse, parseUrl } from "../shared";
import { readdir, stat } from "../interface";
import { fontDirs } from "../config";
/** GET /api/fonts — 列出所有可用字体 */
export async function handleListFonts(req: Request, res: Response) {
const allFonts: Array<{ name: string; dir: string }> = [];
for (const dir of fontDirs) {
try {
const entries = await readdir(dir);
for (const entry of entries) {
if (entry.isFile() && /\.(ttf|otf|woff|woff2)$/i.test(entry.name)) {
allFonts.push({ name: entry.name, dir });
}
}
} catch {}
}
return { req, res: jsonResponse(allFonts) };
}

17
backend/routes/stats.ts Normal file
View File

@ -0,0 +1,17 @@
import { jsonResponse, stats, subsetCache, fontBufferCache } from "../shared";
/** GET /api/stats — 返回运行时统计 */
export async function handleStats(req: Request, res: Response) {
return {
req,
res: jsonResponse({
uptime: Math.floor((Date.now() - stats.startTime) / 1000),
totalRequests: stats.totalRequests,
subsetRequests: stats.subsetRequests,
subsetCacheHits: stats.subsetCacheHits,
totalChars: stats.totalChars,
subsetCacheEntries: subsetCache.size,
fontBufferCacheEntries: fontBufferCache.size,
}),
};
}

86
backend/routes/subset.ts Normal file
View File

@ -0,0 +1,86 @@
import { fontSubset } from "../font_util/font";
import type { FontEditor } from "../../vendor/fonteditor-core/lib/ttf/font.js";
import { parseUrl, jsonResponse, stats, subsetCache, findFontPath, readFontBuffer } from "../shared";
/** GET /api?font=...&text=... — 字体裁剪 */
export async function handleFontSubset(req: Request, res: Response) {
const url = parseUrl(req);
const params = new URLSearchParams(url.search);
const font = params.get("font") || "";
const text = params.get("text") || "";
if (text.length === 0) {
return { req, res };
}
const fontPath = await findFontPath(font);
if (!fontPath) {
return {
req,
res: new Response(`Font not found: ${font}`, {
status: 404,
headers: { "Content-Type": "text/plain; charset=utf-8" },
}),
};
}
/** 默认 ttf兼容性最好 */
const outTypeParam = params.get("outType") || "";
const outType = (outTypeParam === "woff2" || outTypeParam === "ttf") ? outTypeParam : "ttf";
/** 查询裁剪结果缓存 */
const cacheKey = `${fontPath}:${outType}:${text}`;
stats.subsetRequests++;
stats.totalChars += text.length;
const cached = subsetCache.get(cacheKey);
if (cached) {
stats.subsetCacheHits++;
const contentTypes: Record<string, string> = { ttf: "font/ttf", woff2: "font/woff2" };
return {
req,
res: new Response(cached, {
status: 200,
headers: {
"Content-Type": contentTypes[outType] || "font/ttf",
"Cache-Control": "public, max-age=86400",
"X-Cache": "HIT",
},
}),
};
}
const fontType = fontPath.split(".").pop() as FontEditor.FontType;
let oldFontBuffer: ArrayBuffer;
try {
oldFontBuffer = await readFontBuffer(fontPath);
} catch {
return {
req,
res: new Response(`Font read error: ${font}`, {
status: 500,
headers: { "Content-Type": "text/plain; charset=utf-8" },
}),
};
}
const newFont = await fontSubset(oldFontBuffer, text, {
outType: outType,
sourceType: fontType,
});
/** 写入裁剪结果缓存 */
subsetCache.set(cacheKey, newFont as ArrayBuffer);
const contentTypes: Record<string, string> = { ttf: "font/ttf", woff2: "font/woff2" };
return {
req,
res: new Response(newFont, {
status: 200,
headers: {
"Content-Type": contentTypes[outType] || "font/ttf",
"Cache-Control": "public, max-age=86400",
"X-Cache": "MISS",
},
}),
};
}

47
backend/routes/upload.ts Normal file
View File

@ -0,0 +1,47 @@
import { jsonResponse, parseUrl } from "../shared";
import { parseMultipart } from "../multipart";
import { handleTempUpload, handleAdminUpload } from "../upload";
/** POST /api/upload?mode=temp|admin — 上传字体 */
export async function handleUpload(req: Request, res: Response) {
const url = parseUrl(req);
const mode = url.searchParams.get("mode") ?? "temp";
const contentType = req.headers.get("Content-Type") ?? "";
console.log("[upload] mode:", mode, "contentType:", contentType);
const body = (req as Request & { _bodyBuffer?: ArrayBuffer })._bodyBuffer;
if (!body || body.byteLength === 0) {
return { req, res: jsonResponse({ success: false, error: "请求体为空" }, 400) };
}
console.log("[upload] body size:", body.byteLength);
let parsed;
try {
parsed = parseMultipart(contentType, body);
console.log("[upload] parsed files:", parsed.files.length);
} catch (err) {
console.log("[upload] parse error:", err);
return { req, res: jsonResponse({ success: false, error: "无效的 multipart 数据" }, 400) };
}
if (!parsed.files || parsed.files.length === 0) {
return { req, res: jsonResponse({ success: false, error: "未提供文件" }, 400) };
}
const file = parsed.files[0];
console.log("[upload] file:", file.name, "filename:", file.filename, "data size:", file.data.length);
let result;
if (mode === "admin") {
const authHeader = req.headers.get("Authorization") ?? "";
const apiKey = authHeader.replace("Bearer ", "");
result = await handleAdminUpload({ data: file.data, filename: file.filename }, apiKey);
console.log("[upload] admin result:", result);
return { req, res: jsonResponse(result, result.success ? 200 : 403) };
}
result = await handleTempUpload({ data: file.data, filename: file.filename });
console.log("[upload] temp result:", result);
return { req, res: jsonResponse(result, result.success ? 200 : 400) };
}

86
backend/shared.ts Normal file
View File

@ -0,0 +1,86 @@
import { fontDirs, subsetCacheMaxSize } from "./config";
import { LruCache } from "./lru_cache";
import { path_join, readFile, stat, readdir } from "./interface";
/** 解析请求 URLreq.url 只有路径,需要补全协议和主机才能用 URL API */
export function parseUrl(req: Request): URL {
return new URL(req.url, "http://localhost");
}
/** JSON 响应工具 */
export function jsonResponse(data: unknown, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json" },
});
}
/** 运行时统计 */
export const stats = {
/** 服务启动时间戳 */
startTime: Date.now(),
/** 总请求数 */
totalRequests: 0,
/** 字体裁剪请求次数(含缓存命中) */
subsetRequests: 0,
/** 字体裁剪缓存命中次数 */
subsetCacheHits: 0,
/** 累计裁剪文字字符数 */
totalChars: 0,
};
/** 字体文件 LRU 缓存,最多保留 3 个最近使用的字体 buffer按条目数淘汰 */
export const fontBufferCache = new LruCache<ArrayBuffer>({ maxSize: 3 });
/** 字体裁剪结果 LRU 缓存(按字节容量淘汰) */
export const subsetCache = new LruCache<ArrayBuffer>({ maxBytes: subsetCacheMaxSize, sizeFn: (v) => v.byteLength });
/**
*
* > >
*/
export async function findFontPath(filename: string): Promise<string | null> {
for (const dir of fontDirs) {
const filePath = path_join(dir, filename);
try {
const s = await stat(filePath);
if (s.isFile()) return filePath;
} catch {}
}
const allFonts: Array<{ basename: string; path: string }> = [];
for (const dir of fontDirs) {
try {
const entries = await readdir(dir);
for (const entry of entries) {
if (entry.isFile() && /\.(ttf|otf|woff|woff2)$/i.test(entry.name)) {
allFonts.push({
basename: entry.name.replace(/\.[^.]+$/, ""),
path: path_join(dir, entry.name),
});
}
}
} catch {}
}
const query = filename.replace(/\.[^.]+$/, "").toLowerCase();
for (const f of allFonts) {
if (f.basename.toLowerCase().startsWith(query)) return f.path;
}
for (const f of allFonts) {
if (f.basename.toLowerCase().includes(query)) return f.path;
}
return null;
}
/** 从缓存或磁盘读取字体 buffer */
export async function readFontBuffer(fontPath: string): Promise<ArrayBuffer> {
const cached = fontBufferCache.get(fontPath);
if (cached) return cached;
const buffer = new Uint8Array(await readFile(fontPath)).buffer;
fontBufferCache.set(fontPath, buffer);
return buffer;
}

View File

@ -1,7 +1,7 @@
{
"name": "webfont",
"private": true,
"version": "1.6.0",
"version": "1.7.0",
"type": "module",
"scripts": {
"dev": "pnpx tsx scripts/dev-all.ts",

View File

@ -1,6 +1,7 @@
import { createMemo, createSignal, createEffect, onMount, Show } from "solid-js";
import { fetchFonts, fetchConfig, type FontInfo, type ServerConfig } from "./api";
import UploadSection from "./UploadSection";
import StatsPanel from "./StatsPanel";
import { SelectorRow } from "./FontSelector";
import FontDebugPreview from "./FontDebugPreview";
@ -283,6 +284,8 @@ function App() {
<UploadSection config={serverConfig()} onUploaded={refreshFonts} />
<StatsPanel />
<section style={{ ...s.section, "font-size": "12px", color: "#aaa", "line-height": "1.8" }}>
<p><b></b> text URL </p>
<p><b></b> CSS text </p>

72
src/StatsPanel.tsx Normal file
View File

@ -0,0 +1,72 @@
import { createSignal, onMount, onCleanup } from "solid-js";
import { fetchStats, type ServerStats } from "./api";
/** 将秒数格式化为可读时长 */
function formatUptime(seconds: number): string {
if (seconds < 60) return `${seconds}`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}${seconds % 60}`;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h < 24) return `${h}${m}${s}`;
const d = Math.floor(h / 24);
return `${d}${h % 24}${m}`;
}
export default function StatsPanel() {
const [data, setData] = createSignal<ServerStats | null>(null);
let timer: ReturnType<typeof setInterval> | null = null;
const load = async () => {
const s = await fetchStats().catch(() => null);
if (s) setData(s);
};
const startPolling = () => {
if (timer) return;
load();
timer = setInterval(load, 10_000);
};
const stopPolling = () => {
if (timer) {
clearInterval(timer);
timer = null;
}
};
const onVisibilityChange = () => {
if (document.visibilityState === "visible") {
startPolling();
} else {
stopPolling();
}
};
onMount(() => {
document.addEventListener("visibilitychange", onVisibilityChange);
startPolling();
});
onCleanup(() => {
stopPolling();
document.removeEventListener("visibilitychange", onVisibilityChange);
});
const s = data();
if (!s) return null;
const hitRate = s.subsetRequests > 0 ? ((s.subsetCacheHits / s.subsetRequests) * 100).toFixed(1) : "0.0";
return (
<section style={{ "font-size": "12px", color: "#999", "line-height": "1.8", "margin-top": "24px" }}>
<div style={{ display: "flex", gap: "16px", flex-wrap: "wrap" }}>
<span> {formatUptime(s.uptime)}</span>
<span> {s.totalRequests} </span>
<span> {s.subsetRequests} </span>
<span> {s.totalChars} </span>
<span> {hitRate}%</span>
</div>
</section>
);
}

View File

@ -14,6 +14,16 @@ export interface UploadResult {
error?: string;
}
export interface ServerStats {
uptime: number;
totalRequests: number;
subsetRequests: number;
subsetCacheHits: number;
totalChars: number;
subsetCacheEntries: number;
fontBufferCacheEntries: number;
}
export async function fetchFonts(): Promise<FontInfo[]> {
const res = await fetch("/api/fonts");
return res.json();
@ -44,3 +54,8 @@ export async function uploadFont(
});
return res.json();
}
export async function fetchStats(): Promise<ServerStats> {
const res = await fetch("/api/stats");
return res.json();
}