diff --git a/internal/cluster/ring_engine.go b/internal/cluster/ring_engine.go
index 7340d64..d953e77 100644
--- a/internal/cluster/ring_engine.go
+++ b/internal/cluster/ring_engine.go
@@ -238,6 +238,16 @@ func (e *Engine) OnToken(ctx context.Context, tk *Token) (*Token, error) {
// (b) apply incremental log delta; trim consumed entries off the token.
if e.Log != nil && len(tk.Log) > 0 {
+ // Raise the local seq allocator above the ring's high-water mark so a
+ // restarted node (whose log was rebuilt from scratch at Seq=0) never
+ // re-issues sequence numbers that already exist in the shared history —
+ // duplicate seqs would make ApplyDelta silently drop the new entries as
+ // "already have" and pollute full-log attachments with ambiguous ids.
+ for _, en := range tk.Log {
+ if en.Seq > e.Log.Seq {
+ e.Log.Seq = en.Seq
+ }
+ }
wm, err := e.Log.ApplyDelta(tk.Log)
if err != nil {
log.Printf("ring[%s] log delta gap: %v (request full sync later)", e.ID, err)
@@ -275,9 +285,15 @@ func (e *Engine) OnToken(ctx context.Context, tk *Token) (*Token, error) {
// flows to the newcomer (its successor) so it can participate.
e.injectPendingJoin()
- // re-attach own fresh log entries so peers converge.
+ // re-attach OWN full log so peers converge even after gaps. A node that
+ // was offline when seq=N circulated never gets seq=N from the delta (it
+ // was trimmed off by peers whose watermark advanced past N). Attaching the
+ // FULL local log every cycle lets any peer missing entries backfill them
+ // next round — the cluster log is small (tens of entries) so the cost is
+ // negligible. ApplyDelta dedupes by seq so re-sent entries are a no-op
+ // for peers that already have them.
if !e.selfRemoved && e.Log != nil {
- mine := e.Log.EntriesAfter(e.lastLogSent)
+ mine := e.Log.Snapshot()
if len(mine) > 0 {
tk.Log = append(tk.Log, mine...)
if last := mine[len(mine)-1]; last.Seq > e.lastLogSent {
diff --git a/internal/cluster/ring_log_test.go b/internal/cluster/ring_log_test.go
index 0450249..6f57dfa 100644
--- a/internal/cluster/ring_log_test.go
+++ b/internal/cluster/ring_log_test.go
@@ -77,3 +77,60 @@ func TestClaimLogsToEngine(t *testing.T) {
t.Fatalf("engine log = %+v", snap)
}
}
+
+// TestFullLogBackfillAfterGap: a node that missed entries while offline
+// (Synced stuck below the ring's max) must backfill from a peer's FULL log
+// attachment. This is the regression test for the "log delta gap: want 1 got
+// N" livelock where offline nodes could never rejoin the log history.
+func TestFullLogBackfillAfterGap(t *testing.T) {
+ // Peer with complete history [1..4].
+ var peer ClusterLog
+ for i := 1; i <= 4; i++ {
+ if _, err := peer.Append("n1", LogNodeJoin, map[string]int{"i": i}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ // Straggler that only has [1]; it missed [2..3] while offline and now
+ // receives the peer's FULL attachment [1..4].
+ straggler := NewClusterLog()
+ if _, err := straggler.Append("n2", LogNodeJoin, nil); err != nil {
+ t.Fatal(err)
+ }
+ // Force straggler to look like it has seq1 only (Synced=1).
+ straggler.Synced = 1
+
+ wm, err := straggler.ApplyDelta(peer.Snapshot())
+ if err != nil {
+ t.Fatalf("full backfill failed: %v", err)
+ }
+ if wm != 4 {
+ t.Fatalf("watermark = %d, want 4", wm)
+ }
+ if len(straggler.Snapshot()) != 4 {
+ t.Fatalf("log length = %d, want 4 (no dupes)", len(straggler.Snapshot()))
+ }
+}
+
+// TestApplyDeltaIdempotentOnFullResend: applying the same full attachment
+// twice must not duplicate entries or error — peers re-attach their full log
+// every cycle now.
+func TestApplyDeltaIdempotentOnFullResend(t *testing.T) {
+ var src ClusterLog
+ for i := 1; i <= 3; i++ {
+ if _, err := src.Append("n1", LogNodeJoin, nil); err != nil {
+ t.Fatal(err)
+ }
+ }
+ full := src.Snapshot()
+ dst := NewClusterLog()
+ if _, err := dst.ApplyDelta(full); err != nil {
+ t.Fatalf("first apply: %v", err)
+ }
+ wm, err := dst.ApplyDelta(full)
+ if err != nil {
+ t.Fatalf("second apply (idempotence): %v", err)
+ }
+ if wm != 3 || len(dst.Snapshot()) != 3 {
+ t.Fatalf("wm=%d len=%d, want 3/3", wm, len(dst.Snapshot()))
+ }
+}
diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go
index 8eb6964..f0a47da 100644
--- a/internal/httpapi/server.go
+++ b/internal/httpapi/server.go
@@ -144,10 +144,14 @@ func NewServeMux(h *Handler) (http.Handler, error) {
// resolve to admin via the flag fast path.
mux.HandleFunc("/frpc/", h.auth("read")(h.handleFrpcBinary))
- // Static assets behind the same auth as the API: the browser caches the
- // Basic header once and sends it on every asset + /api/* request, so the
- // SPA loads for any valid identity (viewer included).
- mux.HandleFunc("/", h.auth("read")(h.handleStatic))
+ // Static assets WITHOUT auth: the SPA must load before it can show its
+ // login form (auth.ts resolves GET /me on mount; a 401 there drops the UI
+ // into the login page). Gating index.html behind auth() would make an
+ // unauthenticated browser see {"error":"unauthorized"} instead of the
+ // app shell — exactly the bug where the domain showed raw JSON. The
+ // embedded assets are static/public (no user data); every privileged
+ // action still requires an authenticated API call.
+ mux.HandleFunc("/", h.handleStatic)
return mux, nil
}
diff --git a/internal/httpapi/server_test.go b/internal/httpapi/server_test.go
index c7021b6..e890fb5 100644
--- a/internal/httpapi/server_test.go
+++ b/internal/httpapi/server_test.go
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
+ "io"
"net/http"
"net/http/httptest"
"path/filepath"
@@ -249,3 +250,32 @@ func TestSaveCanvasPublishesRevokeTask(t *testing.T) {
t.Fatalf("expected revoke task for web, pending=%+v", ring.State().PendingList())
}
}
+
+// TestStaticServesWithoutAuth: the SPA shell (index.html) must load WITHOUT
+// credentials so an unauthenticated browser sees the login page instead of a
+// raw {"error":"unauthorized"} JSON body. API routes stay gated.
+func TestStaticServesWithoutAuth(t *testing.T) {
+ _, ts := newTestHandler(t)
+ resp, err := http.Get(ts.URL + "/")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want 200 for SPA shell", resp.StatusCode)
+ }
+ body, _ := io.ReadAll(resp.Body)
+ if !strings.Contains(string(body), "
") && !strings.Contains(string(body), "") {
+ t.Fatalf("body does not look like index.html: %.80s", body)
+ }
+ // API remains gated.
+ req, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/manager/status", nil)
+ resp2, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp2.Body.Close()
+ if resp2.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("API status = %d, want 401", resp2.StatusCode)
+ }
+}
diff --git a/web/src/api.ts b/web/src/api.ts
index 411914c..be90532 100644
--- a/web/src/api.ts
+++ b/web/src/api.ts
@@ -1,28 +1,28 @@
// HTTP client and API functions for webui4frpc.
import type {
- ApiKey,
- ApiKeyCreated,
- BinaryStatus,
- CacheResp,
- CanvasData,
- CanvasExportEnvelope,
- ClusterNodesResp,
- InstallResult,
- MeResp,
- Remote,
- RingSnapshot,
- Settings,
- StatusResp,
- User,
- WorkerLogBundle,
+ ApiKey,
+ ApiKeyCreated,
+ BinaryStatus,
+ CacheResp,
+ CanvasData,
+ CanvasExportEnvelope,
+ ClusterNodesResp,
+ InstallResult,
+ MeResp,
+ Remote,
+ RingSnapshot,
+ Settings,
+ StatusResp,
+ User,
+ WorkerLogBundle,
} from "./types";
class HTTPError extends Error {
- status: number;
- constructor(status: number, message: string) {
- super(message);
- this.status = status;
- }
+ status: number;
+ constructor(status: number, message: string) {
+ super(message);
+ this.status = status;
+ }
}
// X-W4F-UI marks every SPA request. The server treats these as session-cookie
@@ -31,219 +31,244 @@ class HTTPError extends Error {
const UI_HEADER = { "X-W4F-UI": "1" } as const;
async function request
(url: string, options: RequestInit = {}): Promise {
- const response = await fetch(url, {
- credentials: "same-origin",
- ...options,
- headers: { ...UI_HEADER, ...(options.headers as Record | undefined) },
- });
- if (!response.ok) {
- throw new HTTPError(response.status, `HTTP ${response.status}`);
- }
- const ct = response.headers.get("content-type") || "";
- if (ct.includes("application/json")) {
- return response.json() as Promise;
- }
- return response.text() as unknown as Promise;
+ const response = await fetch(url, {
+ credentials: "same-origin",
+ ...options,
+ headers: {
+ ...UI_HEADER,
+ ...(options.headers as Record | undefined),
+ },
+ });
+ if (!response.ok) {
+ throw new HTTPError(response.status, `HTTP ${response.status}`);
+ }
+ const ct = response.headers.get("content-type") || "";
+ if (ct.includes("application/json")) {
+ return response.json() as Promise;
+ }
+ return response.text() as unknown as Promise;
}
const json = (body: unknown): RequestInit => ({
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(body),
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
});
export const api = {
- status: () => request("/api/manager/status"),
+ status: () => request("/api/manager/status"),
- canvas: () => request("/api/manager/canvas"),
- saveCanvas: (data: CanvasData) =>
- request("/api/manager/canvas", json(data)),
+ canvas: () => request("/api/manager/canvas"),
+ saveCanvas: (data: CanvasData) =>
+ request("/api/manager/canvas", json(data)),
- settings: () => request("/api/manager/settings"),
- saveSettings: (s: Settings) =>
- request("/api/manager/settings", json(s)),
+ settings: () => request("/api/manager/settings"),
+ saveSettings: (s: Settings) =>
+ request("/api/manager/settings", json(s)),
- binaryStatus: () => request("/api/manager/binary/status"),
- installBinary: (version?: string) =>
- request("/api/manager/binary/install", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: version ? JSON.stringify({ version }) : undefined,
- }),
+ binaryStatus: () => request("/api/manager/binary/status"),
+ installBinary: (version?: string) =>
+ request("/api/manager/binary/install", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: version ? JSON.stringify({ version }) : undefined,
+ }),
- saveRemote: (remote: Remote) =>
- request("/api/manager/remotes", json(remote)),
- deleteRemote: (name: string) =>
- request(`/api/manager/remotes/${encodeURIComponent(name)}`, {
- method: "DELETE",
- }),
+ saveRemote: (remote: Remote) =>
+ request("/api/manager/remotes", json(remote)),
+ deleteRemote: (name: string) =>
+ request(`/api/manager/remotes/${encodeURIComponent(name)}`, {
+ method: "DELETE",
+ }),
- // Per-forward start/stop (forwards page). local-only forwards toggle the
- // local frpc worker; cluster forwards submit/revoke via the ring.
- forwardStart: (local: string, remote: string, remotePort: number) =>
- request<{ ok: boolean }>("/api/manager/forwards/start", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ local, remote, remotePort }),
- }),
- forwardStop: (local: string, remote: string, remotePort: number) =>
- request<{ ok: boolean }>("/api/manager/forwards/stop", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ local, remote, remotePort }),
- }),
- groupStart: (group: string) =>
- request<{ ok: boolean }>("/api/manager/forwards/group/start", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ group }),
- }),
- groupStop: (group: string) =>
- request<{ ok: boolean }>("/api/manager/forwards/group/stop", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ group }),
- }),
- // assignGroup changes a single forward's group label (status page chip).
- // Empty group clears the assignment (移出分组). Pure DB update, no worker.
- assignGroup: (local: string, remote: string, remotePort: number, group: string) =>
- request<{ ok: boolean }>("/api/manager/forwards/assign", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ local, remote, remotePort, group }),
- }),
- // deleteGroup dissolves a group: all members moved to 未分组. The group is
- // just a label on links, so clearing all members is the complete delete.
- deleteGroup: (group: string) =>
- request<{ ok: boolean }>("/api/manager/forwards/group/delete", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ group }),
- }),
+ // Per-forward start/stop (forwards page). local-only forwards toggle the
+ // local frpc worker; cluster forwards submit/revoke via the ring.
+ forwardStart: (local: string, remote: string, remotePort: number) =>
+ request<{ ok: boolean }>("/api/manager/forwards/start", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ local, remote, remotePort }),
+ }),
+ forwardStop: (local: string, remote: string, remotePort: number) =>
+ request<{ ok: boolean }>("/api/manager/forwards/stop", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ local, remote, remotePort }),
+ }),
+ groupStart: (group: string) =>
+ request<{ ok: boolean }>("/api/manager/forwards/group/start", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ group }),
+ }),
+ groupStop: (group: string) =>
+ request<{ ok: boolean }>("/api/manager/forwards/group/stop", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ group }),
+ }),
+ // assignGroup changes a single forward's group label (status page chip).
+ // Empty group clears the assignment (移出分组). Pure DB update, no worker.
+ assignGroup: (
+ local: string,
+ remote: string,
+ remotePort: number,
+ group: string,
+ ) =>
+ request<{ ok: boolean }>("/api/manager/forwards/assign", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ local, remote, remotePort, group }),
+ }),
+ // deleteGroup dissolves a group: all members moved to 未分组. The group is
+ // just a label on links, so clearing all members is the complete delete.
+ deleteGroup: (group: string) =>
+ request<{ ok: boolean }>("/api/manager/forwards/group/delete", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ group }),
+ }),
- profileStart: (name: string) =>
- request(`/api/manager/profiles/${encodeURIComponent(name)}/start`, {
- method: "POST",
- }),
- profileStop: (name: string) =>
- request(`/api/manager/profiles/${encodeURIComponent(name)}/stop`, {
- method: "POST",
- }),
- profileRestart: (name: string) =>
- request(`/api/manager/profiles/${encodeURIComponent(name)}/restart`, {
- method: "POST",
- }),
- profileConfig: (name: string) =>
- request(`/api/manager/profiles/${encodeURIComponent(name)}/config`),
- profileLogs: (name: string) =>
- request(`/api/manager/profiles/${encodeURIComponent(name)}/logs`),
+ profileStart: (name: string) =>
+ request(`/api/manager/profiles/${encodeURIComponent(name)}/start`, {
+ method: "POST",
+ }),
+ profileStop: (name: string) =>
+ request(`/api/manager/profiles/${encodeURIComponent(name)}/stop`, {
+ method: "POST",
+ }),
+ profileRestart: (name: string) =>
+ request(`/api/manager/profiles/${encodeURIComponent(name)}/restart`, {
+ method: "POST",
+ }),
+ profileConfig: (name: string) =>
+ request(`/api/manager/profiles/${encodeURIComponent(name)}/config`),
+ profileLogs: (name: string) =>
+ request(`/api/manager/profiles/${encodeURIComponent(name)}/logs`),
- // M6: cluster nodes + binary cache management.
- clusterNodes: () => request("/api/manager/cluster/nodes"),
- clusterCache: () => request("/api/manager/cluster/cache"),
- pruneCache: (keep: number) =>
- request("/api/manager/cluster/cache", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ keep }),
- }),
+ // M6: cluster nodes + binary cache management.
+ clusterNodes: () => request("/api/manager/cluster/nodes"),
+ clusterCache: () => request("/api/manager/cluster/cache"),
+ pruneCache: (keep: number) =>
+ request("/api/manager/cluster/cache", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ keep }),
+ }),
- // M6 token ring snapshot.
- ring: () => request("/api/manager/cluster/ring"),
+ // M6 token ring snapshot.
+ ring: () => request("/api/manager/cluster/ring"),
- // M6 cluster lifecycle (创建/加入/退出/移除).
- // clusterCreate: reseed THIS node as a fresh standalone leader (创建集群).
- clusterCreate: () =>
- request("/api/manager/cluster/create", { method: "POST" }),
- // clusterJoinRing: THIS node joins the cluster at peer addr (加入集群).
- // joinKey is the sponsor node's nodeKey — required for admission security.
- clusterJoinRing: (addr: string, joinKey: string) =>
- request("/api/manager/cluster/join-ring", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ addr, joinKey }),
- }),
- // clusterRemoveNode: publish a node-removal command (移除节点 / 退出集群[self]).
- clusterRemoveNode: (id: string) =>
- request<{ task: unknown }>("/api/manager/cluster/node-remove", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ id }),
- }),
+ // M6 cluster lifecycle (创建/加入/退出/移除).
+ // clusterCreate: reseed THIS node as a fresh standalone leader (创建集群).
+ clusterCreate: () =>
+ request("/api/manager/cluster/create", { method: "POST" }),
+ // clusterJoinRing: THIS node joins the cluster at peer addr (加入集群).
+ // joinKey is the sponsor node's nodeKey — required for admission security.
+ clusterJoinRing: (addr: string, joinKey: string) =>
+ request("/api/manager/cluster/join-ring", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ addr, joinKey }),
+ }),
+ // clusterRemoveNode: publish a node-removal command (移除节点 / 退出集群[self]).
+ clusterRemoveNode: (id: string) =>
+ request<{ task: unknown }>("/api/manager/cluster/node-remove", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ id }),
+ }),
- // M7 auth/accounts/API keys/canvas export-import/worker logs.
- me: () => request("/api/manager/me"),
- // UI session login/logout. login() sets an HttpOnly session cookie (Set-Cookie
- // on the 200 response) so the SPA stops using Basic Auth; logout clears it.
- login: (username: string, password: string) =>
- request("/api/manager/login", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ username, password }),
- }),
- logout: () =>
- request<{ ok: boolean }>("/api/manager/logout", { method: "POST" }),
- listUsers: () => request<{ users: User[] }>("/api/manager/users"),
- createUser: (username: string, password: string, role: 'admin' | 'viewer' | 'superadmin') =>
- request("/api/manager/users", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ username, password, role }),
- }),
- updateUser: (name: string, patch: { password?: string; role?: 'admin' | 'viewer' | 'superadmin'; enabled?: boolean }) =>
- request(`/api/manager/users/${encodeURIComponent(name)}`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(patch),
- }),
- deleteUser: (name: string) =>
- request(`/api/manager/users/${encodeURIComponent(name)}`, {
- method: "DELETE",
- }),
- listApiKeys: () => request<{ apiKeys: ApiKey[] }>("/api/manager/apikeys"),
- createApiKey: (userId: number, label: string, scope: 'read' | 'write' | 'admin') =>
- request("/api/manager/apikeys", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ userId, label, scope }),
- }),
- deleteApiKey: (id: number) =>
- request(`/api/manager/apikeys/${id}`, { method: "DELETE" }),
+ // M7 auth/accounts/API keys/canvas export-import/worker logs.
+ me: () => request("/api/manager/me"),
+ // UI session login/logout. login() sets an HttpOnly session cookie (Set-Cookie
+ // on the 200 response) so the SPA stops using Basic Auth; logout clears it.
+ login: (username: string, password: string) =>
+ request("/api/manager/login", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ username, password }),
+ }),
+ logout: () =>
+ request<{ ok: boolean }>("/api/manager/logout", { method: "POST" }),
+ listUsers: () => request<{ users: User[] }>("/api/manager/users"),
+ createUser: (
+ username: string,
+ password: string,
+ role: "admin" | "viewer" | "superadmin",
+ ) =>
+ request("/api/manager/users", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ username, password, role }),
+ }),
+ updateUser: (
+ name: string,
+ patch: {
+ password?: string;
+ role?: "admin" | "viewer" | "superadmin";
+ enabled?: boolean;
+ },
+ ) =>
+ request(`/api/manager/users/${encodeURIComponent(name)}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(patch),
+ }),
+ deleteUser: (name: string) =>
+ request(`/api/manager/users/${encodeURIComponent(name)}`, {
+ method: "DELETE",
+ }),
+ listApiKeys: () => request<{ apiKeys: ApiKey[] }>("/api/manager/apikeys"),
+ createApiKey: (
+ userId: number,
+ label: string,
+ scope: "read" | "write" | "admin",
+ ) =>
+ request("/api/manager/apikeys", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ userId, label, scope }),
+ }),
+ deleteApiKey: (id: number) =>
+ request(`/api/manager/apikeys/${id}`, { method: "DELETE" }),
- // Canvas export/import (转发表 备份/还原).
- exportCanvas: () => request("/api/manager/canvas/export"),
- importCanvas: (data: CanvasData | CanvasExportEnvelope) =>
- request("/api/manager/canvas/import", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(data),
- }),
+ // Canvas export/import (转发表 备份/还原).
+ exportCanvas: () =>
+ request("/api/manager/canvas/export"),
+ importCanvas: (data: CanvasData | CanvasExportEnvelope) =>
+ request("/api/manager/canvas/import", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(data),
+ }),
- // Worker-log bundle (HTTP fan-out across ring nodes; read-level/auditor).
- exportWorkerLogs: () => request("/api/manager/cluster/logs/export"),
+ // Worker-log bundle (HTTP fan-out across ring nodes; read-level/auditor).
+ exportWorkerLogs: () =>
+ request("/api/manager/cluster/logs/export"),
};
// downloadAuditCsv streams one of the /audit/*.csv endpoints to a file.
export async function downloadAuditCsv(
- kind: "users" | "apikeys" | "cluster-log" | "worker-logs",
+ kind: "users" | "apikeys" | "cluster-log" | "worker-logs",
): Promise {
- const resp = await fetch(`/api/manager/audit/${kind}.csv`, {
- credentials: "same-origin",
- headers: { ...UI_HEADER } as Record,
- });
- if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
- const disposition = resp.headers.get("content-disposition") || "";
- const m = disposition.match(/filename="?([^";]+)"?/);
- const filename = m?.[1] ?? `audit-${kind}.csv`;
- const blob = new Blob([await resp.text()], {
- type: "text/csv;charset=utf-8",
- });
- const url = URL.createObjectURL(blob);
- const a = document.createElement("a");
- a.href = url;
- a.download = filename;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
+ const resp = await fetch(`/api/manager/audit/${kind}.csv`, {
+ credentials: "same-origin",
+ headers: { ...UI_HEADER } as Record,
+ });
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
+ const disposition = resp.headers.get("content-disposition") || "";
+ const m = disposition.match(/filename="?([^";]+)"?/);
+ const filename = m?.[1] ?? `audit-${kind}.csv`;
+ const blob = new Blob([await resp.text()], {
+ type: "text/csv;charset=utf-8",
+ });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
}