@fugood/buttress-server 2.25.0 → 2.25.1-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +238 -17
  2. package/config/function-samples/README.md +37 -0
  3. package/config/function-samples/_auth.ts +48 -0
  4. package/config/function-samples/host-info.ts +25 -0
  5. package/config/function-samples/summarize-text.ts +55 -0
  6. package/config/function-samples/text-to-speech.ts +51 -0
  7. package/config/function-samples/transcribe-media.ts +65 -0
  8. package/config/sample.toml +16 -0
  9. package/lib/cli-update.d.ts +1 -0
  10. package/lib/functions/auth.d.ts +28 -0
  11. package/lib/functions/config.d.ts +20 -0
  12. package/lib/functions/constants.d.ts +17 -0
  13. package/lib/functions/executor.d.ts +27 -0
  14. package/lib/functions/files.d.ts +29 -0
  15. package/lib/functions/index.d.ts +52 -0
  16. package/lib/functions/input.d.ts +11 -0
  17. package/lib/functions/libs.d.ts +10 -0
  18. package/lib/functions/loader.d.ts +48 -0
  19. package/lib/functions/mcp.d.ts +35 -0
  20. package/lib/functions/query.d.ts +27 -0
  21. package/lib/functions/registry.d.ts +35 -0
  22. package/lib/functions/scaffold.d.ts +18 -0
  23. package/lib/functions/status.d.ts +119 -0
  24. package/lib/functions/templates.d.ts +14 -0
  25. package/lib/functions/transpile.d.ts +23 -0
  26. package/lib/functions/types.d.ts +196 -0
  27. package/lib/functions/uploads.d.ts +42 -0
  28. package/lib/functions/watcher.d.ts +34 -0
  29. package/lib/index.d.ts +11 -3
  30. package/lib/index.mjs +347 -63
  31. package/lib/routes/functions.check.d.ts +13 -0
  32. package/lib/routes/functions.d.ts +17 -0
  33. package/lib/routes/generator-cache.d.ts +58 -0
  34. package/lib/routes/index.d.ts +1 -0
  35. package/lib/routes/llm-shared.d.ts +22 -19
  36. package/lib/routes/stt-shared.d.ts +34 -0
  37. package/lib/routes/tts-shared.d.ts +34 -0
  38. package/lib/services/create-onnx-init-context.d.ts +10 -0
  39. package/lib/services/onnx-stt.d.ts +1 -1
  40. package/lib/services/onnx-tts.d.ts +1 -1
  41. package/lib/types.d.ts +23 -0
  42. package/lib/utils/functionsAuthGuard.d.ts +12 -0
  43. package/lib/utils/update.d.ts +38 -0
  44. package/package.json +23 -3
  45. package/public/status.html +116 -0
@@ -0,0 +1,13 @@
1
+ /**
2
+ * End-to-end check for the local function endpoints.
3
+ *
4
+ * Boots a real server on an ephemeral port against a temporary functions
5
+ * directory, then exercises the HTTP list/call/SSE paths and a full MCP
6
+ * initialize → tools/list → tools/call round trip.
7
+ *
8
+ * bun src/routes/functions.check.ts
9
+ *
10
+ * Not a jest test: it needs a real Elysia listener and the native transpiler,
11
+ * neither of which fits the root jest (jsdom + React Native preset) project.
12
+ */
13
+ export {};
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Local function endpoints (EXPERIMENTAL).
3
+ *
4
+ * GET /functions list callable functions
5
+ * POST /functions/:name run one; add ?stream=1 for SSE progress.
6
+ * multipart bodies stage file fields inline and
7
+ * inject their server paths into the input
8
+ * GET /functions/:name run one with the query string as its input
9
+ * POST /functions/mcp MCP (Streamable HTTP, stateless)
10
+ * GET /functions/files/* download a file a function wrote (context.fileUrl)
11
+ * POST /functions/upload stage an input file for a function call
12
+ *
13
+ * Enable via TOML config: [functions] enabled = true, dir = "./functions"
14
+ */
15
+ import type { FunctionsService } from '../functions';
16
+ import type { Config } from '../types';
17
+ export default function factory(config: Config, functions: FunctionsService): import("../types").ButtressApp;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Warm-generator cache shared by the sessionless HTTP surfaces (compatibility
3
+ * routes and local functions).
4
+ *
5
+ * Generators are expensive to start (model load + context alloc), so entries
6
+ * are kept warm and reference-counted by in-flight requests: only idle entries
7
+ * (`activeRequests === 0`) are ever evicted, and the map doubles as an LRU
8
+ * because re-inserting on use moves a key to the end.
9
+ *
10
+ * Each caller creates its own cache instance (one per backend family) but the
11
+ * bookkeeping — in-flight de-duplication, retain/release, eviction — lives here.
12
+ */
13
+ export type GeneratorCacheEntry = {
14
+ id: string;
15
+ type: string;
16
+ config: any;
17
+ repoId: string;
18
+ initialized: boolean;
19
+ activeRequests: number;
20
+ };
21
+ export type ResolvedGeneratorRequest = {
22
+ type: string;
23
+ /** Cache key and model identity for this request. */
24
+ repoId: string;
25
+ mergedConfig: any;
26
+ };
27
+ export type GeneratorCacheOptions = {
28
+ /** Warm entries kept alive; the oldest idle entries are finalized beyond it. */
29
+ max: number;
30
+ /** Pick the configured generator that serves `requestedModel`. */
31
+ resolve: (config: any, requestedModel?: string) => ResolvedGeneratorRequest;
32
+ /** Post-start preparation (typically `initContext`). */
33
+ prepare: (backend: any, entry: GeneratorCacheEntry) => Promise<void>;
34
+ };
35
+ type ConfiguredGeneratorResolverOptions = {
36
+ family: string;
37
+ types: readonly string[];
38
+ };
39
+ /**
40
+ * Model identity shared by local generators whose repository can contain
41
+ * several weight files.
42
+ */
43
+ export declare const configuredGeneratorModelId: (generatorConfig: any) => string;
44
+ /**
45
+ * Build a resolver for generator families that must use an explicitly
46
+ * configured model. STT and TTS intentionally reject unknown models instead
47
+ * of silently loading different weights or voices.
48
+ */
49
+ export declare const createConfiguredGeneratorResolver: ({ family, types }: ConfiguredGeneratorResolverOptions) => (config: any, requestedModel?: string) => ResolvedGeneratorRequest;
50
+ export declare const createGeneratorCache: ({ max, resolve, prepare }: GeneratorCacheOptions) => {
51
+ getOrCreate: (backend: any, config: any, requestedModel: string | undefined, logPrefix: string) => Promise<GeneratorCacheEntry>;
52
+ release: (backend: any, entry: GeneratorCacheEntry | null | undefined, logPrefix: string) => Promise<void>;
53
+ };
54
+ /** Best-effort reader teardown: sources may already be closed or errored. */
55
+ export declare function cancelReaderBestEffort(reader: {
56
+ cancel: () => Promise<unknown>;
57
+ }): void;
58
+ export {};
@@ -3,3 +3,4 @@ export { default as file } from './file';
3
3
  export { default as status } from './status';
4
4
  export { default as openaiCompatFactory } from './openai-compat';
5
5
  export { default as anthropicMessagesFactory } from './anthropic-messages';
6
+ export { default as functionsFactory } from './functions';
@@ -1,33 +1,36 @@
1
1
  /**
2
- * Shared helpers for LLM-backed compatibility routes (OpenAI / Anthropic).
2
+ * Shared helpers for LLM-backed sessionless routes (OpenAI / Anthropic
3
+ * compatibility endpoints and local functions).
3
4
  *
4
- * The generator cache is module-scoped so different compatibility routes
5
- * (e.g. /oai-compat and /anthropic-messages) reuse the same warm generator
6
- * for a given model instead of duplicating contexts.
5
+ * The generator cache is module-scoped so every caller reuses the same warm
6
+ * generator for a given model instead of duplicating contexts.
7
7
  */
8
+ import type { GeneratorCacheEntry } from './generator-cache';
9
+ export type { GeneratorCacheEntry };
10
+ export { cancelReaderBestEffort } from './generator-cache';
8
11
  export declare const LLM_TYPES: string[];
9
- export type GeneratorCacheEntry = {
10
- id: string;
11
- type: string;
12
- config: any;
13
- repoId: string;
14
- initialized: boolean;
15
- activeRequests: number;
12
+ /**
13
+ * Backend options every `messages`-shaped completion needs.
14
+ *
15
+ * Without `jinja` the backend never applies the model's own chat template and
16
+ * the reply degrades into raw-continuation output (`assistant\nassistant\n…`).
17
+ * `reasoning_format` splits a thinking model's `<think>` block out of `content`
18
+ * into `reasoning_content` instead of leaving it inline, and thinking stays off
19
+ * unless the caller asks for it.
20
+ */
21
+ export declare const CHAT_COMPLETION_DEFAULTS: {
22
+ readonly jinja: true;
23
+ readonly add_generation_prompt: true;
24
+ readonly reasoning_format: 'auto';
25
+ readonly enable_thinking: false;
16
26
  };
17
- export declare function cancelReaderBestEffort(reader: {
18
- cancel: () => Promise<unknown>;
19
- }): void;
20
- export declare function releaseGenerator(backend: any, entry: GeneratorCacheEntry | null | undefined, logPrefix?: string): Promise<void>;
21
27
  /**
22
28
  * Get the LLM backend API for a generator type.
23
29
  */
24
30
  export declare function getLlmBackend(backend: any, type: string): any;
31
+ export declare function releaseGenerator(backend: any, entry: GeneratorCacheEntry | null | undefined, logPrefix?: string): Promise<void>;
25
32
  /**
26
33
  * Get or create a cached generator for the requested model.
27
- *
28
- * If `requestedModel` matches a configured generator's `model.repo_id`,
29
- * that config is used; otherwise the first LLM generator is selected and
30
- * its repo_id is overridden with the requested model.
31
34
  */
32
35
  export declare function getOrCreateGenerator(backend: any, config: any, requestedModel?: string, logPrefix?: string): Promise<GeneratorCacheEntry>;
33
36
  /**
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Shared helpers for speech-to-text on the sessionless paths.
3
+ *
4
+ * Mirrors llm-shared: a module-scoped warm-generator cache, reference-counted
5
+ * by in-flight requests. Unlike the LLM path, an unmatched model is an error
6
+ * rather than a repo_id override — STT models are identified by repo *and*
7
+ * filename (several whisper builds live in one repo), so silently substituting
8
+ * one would load the wrong weights.
9
+ */
10
+ import type { GeneratorCacheEntry } from './generator-cache';
11
+ export declare const STT_TYPES: string[];
12
+ /** Get the STT backend API for a generator type. */
13
+ export declare function getSttBackend(backend: any, type: string): any;
14
+ export declare function getOrCreateSttGenerator(backend: any, config: any, requestedModel?: string, logPrefix?: string): Promise<GeneratorCacheEntry>;
15
+ export declare function releaseSttGenerator(backend: any, entry: GeneratorCacheEntry | null | undefined, logPrefix?: string): Promise<void>;
16
+ /**
17
+ * Normalize a transcription call result.
18
+ *
19
+ * `ggmlStt.transcribe` resolves a plain object while `onnxStt.transcribe`
20
+ * returns a ReadableStream of progress events — duck-typed here (never
21
+ * `instanceof`, which fails across vm realms) so both shapes collapse to the
22
+ * final result payload.
23
+ */
24
+ export declare function resolveTranscription(value: any): Promise<any>;
25
+ export type TranscribeRequest = {
26
+ model?: string;
27
+ audioPath?: string;
28
+ audioData?: Uint8Array | Buffer;
29
+ options?: Record<string, any>;
30
+ };
31
+ /**
32
+ * Run a transcription against a warm STT generator, releasing it afterwards.
33
+ */
34
+ export declare function transcribeWith(backend: any, config: any, { model, audioPath, audioData, options }: TranscribeRequest, logPrefix?: string): Promise<any>;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Shared helpers for text-to-speech on the sessionless paths.
3
+ *
4
+ * Mirrors stt-shared: a module-scoped warm-generator cache, reference-counted
5
+ * by in-flight requests. Like the STT path, an unmatched model is an error
6
+ * rather than a substitution — synthesizing with the wrong voice is a silent
7
+ * failure the caller cannot detect.
8
+ */
9
+ import type { GeneratorCacheEntry } from './generator-cache';
10
+ export declare const TTS_TYPES: string[];
11
+ /** Get the TTS backend API for a generator type (only onnx-tts today). */
12
+ export declare function getTtsBackend(backend: any, _type: string): any;
13
+ export declare function getOrCreateTtsGenerator(backend: any, config: any, requestedModel?: string, logPrefix?: string): Promise<GeneratorCacheEntry>;
14
+ export declare function releaseTtsGenerator(backend: any, entry: GeneratorCacheEntry | null | undefined, logPrefix?: string): Promise<void>;
15
+ export type SynthesizeRequest = {
16
+ model?: string;
17
+ text: string;
18
+ /** Passed to the backend verbatim; `options.speaker` picks a registered voice. */
19
+ options?: Record<string, any>;
20
+ };
21
+ export type SynthesizeBackendResult = {
22
+ cachedId: string;
23
+ /**
24
+ * Path into the TTS output cache. The cache owns this file and may evict it
25
+ * — callers that need the audio beyond the immediate request must copy it.
26
+ */
27
+ cachedFile: string;
28
+ sampling_rate: number;
29
+ channels: number;
30
+ };
31
+ /**
32
+ * Run a synthesis against a warm TTS generator, releasing it afterwards.
33
+ */
34
+ export declare function synthesizeWith(backend: any, config: any, { model, text, options }: SynthesizeRequest, logPrefix?: string): Promise<SynthesizeBackendResult>;
@@ -0,0 +1,10 @@
1
+ import { ReadableStream } from 'node:stream/web';
2
+ import type { EventStream, ServiceContext } from '../types';
3
+ type InitContextBackend = {
4
+ initContext: (id: string, property: Record<string, unknown> & {
5
+ onProgress: (progress: number) => void;
6
+ }) => Promise<unknown>;
7
+ };
8
+ type GetBackend = (backend: ServiceContext['backend']) => InitContextBackend;
9
+ export default function createOnnxInitContext(getBackend: GetBackend): ({ backend }: ServiceContext, id: string, property: Record<string, unknown> | undefined) => ReadableStream<EventStream>;
10
+ export {};
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { ReadableStream } from 'node:stream/web';
2
+ import type { ReadableStream } from 'node:stream/web';
3
3
  import type { ServiceContext, EventStream, Expand } from '../types';
4
4
  export declare const schemas: {
5
5
  initContext: z.ZodTuple<[z.ZodString, z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>], null>;
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { ReadableStream } from 'node:stream/web';
2
+ import type { ReadableStream } from 'node:stream/web';
3
3
  import type { ServiceContext, EventStream, Expand } from '../types';
4
4
  export declare const schemas: {
5
5
  initContext: z.ZodTuple<[z.ZodString, z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>], null>;
package/lib/types.d.ts CHANGED
@@ -39,6 +39,20 @@ export type GlobalConfig = {
39
39
  enabled?: boolean;
40
40
  cors_allowed_origins?: string | string[];
41
41
  };
42
+ functions?: {
43
+ enabled?: boolean;
44
+ /** Directory holding local function files, relative to the config file. */
45
+ dir?: string;
46
+ /** Allow calls on a server with no workspace binding (off by default). */
47
+ allow_unauthenticated?: boolean;
48
+ /** Default per-call deadline; ms number or duration string like "5m". */
49
+ default_timeout?: HumanReadableUnit;
50
+ /** Watch the functions dir and reload eagerly on change (off by default). */
51
+ hot_reload?: boolean;
52
+ /** Free-form table handed to functions as `context.config`. */
53
+ config?: Record<string, any>;
54
+ cors_allowed_origins?: string | string[];
55
+ };
42
56
  } & Record<string, any>;
43
57
  export type AutodiscoverConfig = {
44
58
  udp: {
@@ -112,6 +126,15 @@ export type ServerInfo = {
112
126
  id: string;
113
127
  name?: string;
114
128
  };
129
+ /**
130
+ * Local function surface. Only a flag and a count: the whole ServerInfo is
131
+ * serialized into every UDP ANNOUNCE datagram, so the tool list itself must
132
+ * never go in here.
133
+ */
134
+ functions?: {
135
+ enabled: boolean;
136
+ count: number;
137
+ };
115
138
  };
116
139
  export type EventStream = {
117
140
  event: string;
@@ -0,0 +1,12 @@
1
+ import type { LoadedAuthFunction } from '../functions/types';
2
+ export type FunctionsAuthGuardOptions = {
3
+ allowUnauthenticated: boolean;
4
+ allowedOrigins?: string | string[];
5
+ /**
6
+ * Resolve the operator's `_auth` function; null when none exists. A
7
+ * rejection (present-but-broken auth file) fails closed: every call is
8
+ * rejected until the file loads again.
9
+ */
10
+ getCustomAuth?: () => Promise<LoadedAuthFunction | null>;
11
+ };
12
+ export declare const createFunctionsAuthGuard: ({ allowUnauthenticated, allowedOrigins, getCustomAuth, }: FunctionsAuthGuardOptions) => any;
@@ -0,0 +1,38 @@
1
+ export declare const isWindows: boolean;
2
+ export type InstallMethod = 'binary' | 'npm' | 'bun' | 'pnpm' | 'yarn' | 'unknown';
3
+ export interface DetectedInstall {
4
+ method: InstallMethod;
5
+ execPath?: string;
6
+ installDir?: string;
7
+ installRoot?: string;
8
+ scriptPath?: string;
9
+ }
10
+ export declare const compareVersions: (current: string, latest: string) => boolean;
11
+ export declare const detectChannel: ({ version }?: {
12
+ version?: string;
13
+ }) => "beta" | "release";
14
+ export declare const detectInstallMethod: ({ execPath, scriptPath, homeDir, realpath, }?: {
15
+ execPath?: string;
16
+ scriptPath?: string;
17
+ homeDir?: string;
18
+ realpath?: (p?: string) => string | undefined;
19
+ }) => DetectedInstall;
20
+ export declare const fetchVersionInfo: (channel: string) => Promise<{
21
+ version: string;
22
+ }>;
23
+ export declare const fetchNpmVersionInfo: (channel: string) => Promise<{
24
+ version: string;
25
+ }>;
26
+ export declare const fetchLatestVersionInfo: (channel: string, method: InstallMethod) => Promise<{
27
+ version: string;
28
+ }>;
29
+ export declare const updateNpm: (channel: string) => void;
30
+ export declare const updateBun: (channel: string) => void;
31
+ export interface BinaryUpdateOptions {
32
+ installDir?: string;
33
+ ggmlVariant?: string;
34
+ }
35
+ export declare const updateBinary: (channel: string, { installDir, ggmlVariant }?: BinaryUpdateOptions) => Promise<void>;
36
+ export declare const runUpdateForMethod: (method: InstallMethod, channel: string, detected?: DetectedInstall, options?: {
37
+ ggmlVariant?: string;
38
+ }) => Promise<void>;
package/package.json CHANGED
@@ -1,12 +1,16 @@
1
1
  {
2
2
  "name": "@fugood/buttress-server",
3
- "version": "2.25.0",
3
+ "version": "2.25.1-beta.1",
4
4
  "main": "lib/index.mjs",
5
5
  "types": "lib/index.d.ts",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "bricks-buttress": "./bin/bricks-buttress"
9
9
  },
10
+ "s3url": {
11
+ "release": "https://cdn.bricks.tools/bricks-buttress/release",
12
+ "beta": "https://cdn.bricks.tools/bricks-buttress/beta"
13
+ },
10
14
  "files": [
11
15
  "lib",
12
16
  "bin",
@@ -16,6 +20,11 @@
16
20
  "scripts": {
17
21
  "typecheck": "tsc --noEmit",
18
22
  "build": "tsdown -c rolldown.config.js --config-loader native && tsc --noCheck --emitDeclarationOnly",
23
+ "build:dist": "bun scripts/build-distribution.js",
24
+ "release": "bun scripts/build-distribution.js",
25
+ "release-beta": "bun scripts/build-distribution.js --beta",
26
+ "upload-release": "node scripts/upload-release.js",
27
+ "upload-beta": "node scripts/upload-release.js --beta",
19
28
  "prepublish": "bun run build",
20
29
  "dev": "bun src/index.ts",
21
30
  "start": "bun lib/index.mjs",
@@ -30,22 +39,33 @@
30
39
  "dependencies": {
31
40
  "@elysiajs/cors": "^1.1.1",
32
41
  "@elysiajs/node": "^1.4.2",
33
- "@fugood/llama.node": "1.7.11",
42
+ "@fugood/llama.node": "1.7.14",
34
43
  "@fugood/whisper.node": "^1.1.1",
35
44
  "@huggingface/gguf": "^0.3.2",
36
45
  "@iarna/toml": "^3.0.0",
46
+ "@modelcontextprotocol/sdk": "^1.15.0",
37
47
  "bytes": "^3.1.0",
48
+ "chroma-js": "^2.1.2",
38
49
  "elysia": "^1.4.19",
39
50
  "jose": "^5.9.6",
51
+ "json5": "^2.0.1",
52
+ "lodash": "^4.17.4",
53
+ "mathjs": "^12.2.0",
54
+ "md5": "^2.2.1",
55
+ "moment": "^2.22.2",
40
56
  "ms": "^2.1.1",
57
+ "nanoid": "^3.1.4",
41
58
  "node-machine-id": "^1.1.12",
42
59
  "onnxruntime-node": "1.24.3",
60
+ "oxc-transform": "^0.105.0",
61
+ "qs": "^6.15.0",
43
62
  "sharp": "^0.34.1",
63
+ "voca": "^1.4.1",
44
64
  "zod": "^3.25.76"
45
65
  },
46
66
  "devDependencies": {
47
67
  "tsdown": "^0.22.4",
48
68
  "typescript": "^7.0.2"
49
69
  },
50
- "gitHead": "a2690b315c7043b65d71f21609b65358cd50d9a4"
70
+ "gitHead": "bc61ad0f7d649c7d30674a78d5cfbe22591564e5"
51
71
  }
@@ -581,6 +581,39 @@
581
581
  </div>
582
582
  </div>
583
583
  </div>
584
+
585
+ <!-- Local Functions (hidden unless enabled) -->
586
+ <div class="card" id="functionsCard" style="display:none">
587
+ <div class="card-header">
588
+ <span class="card-title">Local Functions <span class="badge badge-warning">experimental</span></span>
589
+ <span class="badge badge-info" id="functionsCount">0 functions</span>
590
+ </div>
591
+ <div id="functionsSummary"></div>
592
+ <div class="section">
593
+ <div class="section-title collapsible" onclick="toggleSection(this)">Call History</div>
594
+ <div class="collapsible-content" id="functionsCallHistory">
595
+ <div class="empty-state">No function calls</div>
596
+ </div>
597
+ </div>
598
+ <div class="section">
599
+ <div class="section-title collapsible" onclick="toggleSection(this)">Upload History</div>
600
+ <div class="collapsible-content" id="functionsUploadHistory">
601
+ <div class="empty-state">No uploads</div>
602
+ </div>
603
+ </div>
604
+ <div class="section">
605
+ <div class="section-title collapsible" onclick="toggleSection(this)">Download History</div>
606
+ <div class="collapsible-content" id="functionsDownloadHistory">
607
+ <div class="empty-state">No downloads</div>
608
+ </div>
609
+ </div>
610
+ <div class="section">
611
+ <div class="section-title collapsible" onclick="toggleSection(this)">Auth History</div>
612
+ <div class="collapsible-content" id="functionsAuthHistory">
613
+ <div class="empty-state">No auth activity</div>
614
+ </div>
615
+ </div>
616
+ </div>
584
617
  </div>
585
618
  </div>
586
619
 
@@ -1144,6 +1177,89 @@
1144
1177
  `<span class="badge badge-error">Failed</span>`
1145
1178
  },
1146
1179
  ])
1180
+
1181
+ renderFunctionsStatus(status.functions || {})
1182
+ }
1183
+
1184
+ // Local Functions activity (calls, uploads, downloads, auth)
1185
+ function renderFunctionsStatus(fns) {
1186
+ document.getElementById('functionsCard').style.display = fns.enabled ? '' : 'none'
1187
+ if (!fns.enabled) return
1188
+
1189
+ const count = fns.count ?? 0
1190
+ document.getElementById('functionsCount').textContent =
1191
+ `${count} function${count !== 1 ? 's' : ''}`
1192
+
1193
+ const c = fns.counters || {}
1194
+ const summary = document.getElementById('functionsSummary')
1195
+ const stat = (label, value, bad) => `
1196
+ <tr>
1197
+ <td>${label}</td>
1198
+ <td>${value}</td>
1199
+ <td>${bad ? `<span class="badge badge-error">${bad}</span>` : '-'}</td>
1200
+ </tr>
1201
+ `
1202
+ withScrollPreserve(summary, () => {
1203
+ summary.innerHTML = `
1204
+ <div class="table-wrapper">
1205
+ <div class="table-inner">
1206
+ <table>
1207
+ <thead>
1208
+ <tr><th>Activity</th><th>Total</th><th>Problems</th></tr>
1209
+ </thead>
1210
+ <tbody>
1211
+ ${stat('Calls', c.calls?.total ?? 0, c.calls?.failed ? `${c.calls.failed} failed` : '')}
1212
+ ${stat('Uploads', `${c.uploads?.total ?? 0} (${formatBytes(c.uploads?.bytes ?? 0)})`, c.uploads?.failed ? `${c.uploads.failed} failed` : '')}
1213
+ ${stat('Downloads', `${c.downloads?.total ?? 0} (${formatBytes(c.downloads?.bytes ?? 0)})`, c.downloads?.missed ? `${c.downloads.missed} missed` : '')}
1214
+ ${stat('Auth checks', c.auth?.total ?? 0, c.auth?.denied ? `${c.auth.denied} denied` : '')}
1215
+ </tbody>
1216
+ </table>
1217
+ </div>
1218
+ </div>
1219
+ `
1220
+ })
1221
+
1222
+ const history = fns.history || {}
1223
+ const statusBadge = i => i.success ?
1224
+ '<span class="badge badge-success">Success</span>' :
1225
+ `<span class="badge badge-error">Failed: ${escapeHtml(i.error || 'Unknown')}</span>`
1226
+
1227
+ renderHistory('functionsCallHistory', history.calls || [], [
1228
+ { label: 'Time', render: i => `<span class="timestamp">${formatRelativeTime(i.timestamp)}</span>` },
1229
+ { label: 'Function', render: i => escapeHtml(i.name) },
1230
+ { label: 'Surface', render: i => `<span class="badge badge-info">${escapeHtml(i.surface || '-')}</span>` },
1231
+ { label: 'Duration', render: i => `${(i.durationMs / 1000).toFixed(2)}s` },
1232
+ { label: 'Status', render: statusBadge },
1233
+ ])
1234
+
1235
+ renderHistory('functionsUploadHistory', history.uploads || [], [
1236
+ { label: 'Time', render: i => `<span class="timestamp">${formatRelativeTime(i.timestamp)}</span>` },
1237
+ { label: 'Name', render: i => escapeHtml(i.name) },
1238
+ { label: 'Via', render: i => `<span class="badge badge-info">${i.source === 'call' ? 'multipart call' : 'upload'}</span>` },
1239
+ { label: 'Size', render: i => formatBytes(i.size ?? 0) },
1240
+ { label: 'Status', render: statusBadge },
1241
+ ])
1242
+
1243
+ renderHistory('functionsDownloadHistory', history.downloads || [], [
1244
+ { label: 'Time', render: i => `<span class="timestamp">${formatRelativeTime(i.timestamp)}</span>` },
1245
+ { label: 'Path', render: i => escapeHtml(i.path) },
1246
+ { label: 'Size', render: i => i.success ? formatBytes(i.size ?? 0) : '-' },
1247
+ { label: 'Status', render: i => i.success ?
1248
+ '<span class="badge badge-success">Served</span>' :
1249
+ '<span class="badge badge-error">Not found</span>'
1250
+ },
1251
+ ])
1252
+
1253
+ renderHistory('functionsAuthHistory', history.auth || [], [
1254
+ { label: 'Time', render: i => `<span class="timestamp">${formatRelativeTime(i.timestamp)}</span>` },
1255
+ { label: 'Request', render: i => escapeHtml(`${i.method || ''} ${i.path || ''}`.trim()) },
1256
+ { label: 'Mode', render: i => escapeHtml(i.mode || '-') },
1257
+ { label: 'Subject', render: i => escapeHtml(i.subject || '-') },
1258
+ { label: 'Outcome', render: i => i.outcome === 'allowed' ?
1259
+ '<span class="badge badge-success">Allowed</span>' :
1260
+ `<span class="badge badge-error">${escapeHtml(i.outcome || 'denied')}</span>`
1261
+ },
1262
+ ])
1147
1263
  }
1148
1264
 
1149
1265
  // Fallback: Fetch status via HTTP polling